133 lines
9.2 KiB
JavaScript
133 lines
9.2 KiB
JavaScript
|
|
// Генератор району: геометрія, назви і приміщення — свої для кожного сіда.
|
|||
|
|
// Захист від незграбних мап: будівлі не перекривають дороги й одна одну (зазор GAP),
|
|||
|
|
// повністю в межах світу; якщо розкладка не зійшлась — повний перезапуск розкладки.
|
|||
|
|
const N = require('./names');
|
|||
|
|
|
|||
|
|
const W = 1000, H = 700, GAP = 30, MARGIN = 24, ROAD_PAD = 10;
|
|||
|
|
|
|||
|
|
function makeDistrict(r) {
|
|||
|
|
const ri = (a, b) => a + Math.floor(r() * (b - a + 1));
|
|||
|
|
const pick = arr => arr[Math.floor(r() * arr.length)];
|
|||
|
|
const shuffle = arr => {
|
|||
|
|
const a = arr.slice();
|
|||
|
|
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
|||
|
|
return a;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const mainSt = pick(N.STREETS), laneNm = pick(N.LANES);
|
|||
|
|
const hy = ri(280, 330), vx = ri(420, 560);
|
|||
|
|
const roads = [
|
|||
|
|
{ x: 0, y: hy, w: W, h: 64, name: 'вул. ' + mainSt },
|
|||
|
|
{ x: vx, y: 0, w: 56, h: H, name: 'пров. ' + laneNm },
|
|||
|
|
{ x: 0, y: 616, w: W, h: 84, name: 'набережна' }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// приміщення: перші — обов'язкові (на них зав'язані справи: місця злочину,
|
|||
|
|
// схованки зброї, лігва), далі — випадковий колорит
|
|||
|
|
const EXTRA = ['задній двір', 'щитова', 'стоянка велосипедів', 'ґанок', 'вентиляційна ніша'];
|
|||
|
|
const spots = (req, n) => [...req, ...shuffle(EXTRA).slice(0, n)];
|
|||
|
|
const units = (letter, n) => Array.from({ length: n }, (_, i) => letter + (i + 1));
|
|||
|
|
|
|||
|
|
const defs = [
|
|||
|
|
{ id: 'station', name: 'Відділок поліції', public: true, spots: [], w: ri(110, 150), h: ri(85, 110) },
|
|||
|
|
{ id: 'apt_a', name: `Будинок «${pick(N.HOUSES)}», вул. ${mainSt} ${ri(3, 30)}`, units: units('A', ri(5, 8)), w: ri(160, 200), h: ri(95, 120) },
|
|||
|
|
{ id: 'apt_b', name: `Будинок «${pick(N.HOUSES)}», вул. ${mainSt} ${ri(31, 60)}`, units: units('B', ri(5, 8)), w: ri(160, 200), h: ri(95, 120) },
|
|||
|
|
{ id: 'apt_c', name: `Гуртожиток «${pick(N.DORMS)}», пров. ${laneNm} ${ri(1, 12)}`, units: units('C', ri(6, 10)), w: ri(170, 220), h: ri(95, 120) },
|
|||
|
|
{ id: 'bar', name: `Бар «${pick(N.BARS)}»`, public: true, spots: spots(['стійка', 'підсобка', 'смітник у дворі'], ri(0, 1)), jobs: ['бармен', 'офіціант', 'кухар'], w: ri(120, 160), h: ri(90, 115) },
|
|||
|
|
{ id: 'diner', name: `Їдальня «${pick(N.DINERS)}»`, public: true, spots: spots(['зала', 'кухня', 'роздягальня персоналу'], ri(0, 1)), jobs: ['кухар', 'касир', 'офіціант'], w: ri(115, 150), h: ri(85, 105) },
|
|||
|
|
{ id: 'office', name: `Бізнес-центр «${pick(N.OFFICES)}»`, public: true, spots: spots(['рецепція', 'офіс «Логістік-Юг»', 'юридичне бюро', 'паркінг'], ri(1, 2)), jobs: ['менеджер з логістики', 'експедитор', 'програміст', 'тестувальник', 'юрист', 'помічник юриста', 'бухгалтер', 'офіс-менеджер'], w: ri(140, 175), h: ri(110, 140) },
|
|||
|
|
{ id: 'shop', name: `Мінімаркет «${pick(N.SHOPS)}»`, public: true, spots: spots(['каса', 'склад', 'камера зовнішнього спостереження'], ri(0, 1)), jobs: ['касир', 'касир', 'товарознавець'], w: ri(105, 140), h: ri(80, 100) },
|
|||
|
|
{ id: 'clinic', name: `Клініка «${pick(N.CLINICS)}»`, public: true, spots: spots(['реєстратура', 'кабінет лікаря', 'аптечний пункт'], ri(0, 1)), jobs: ['лікар', 'медсестра', 'медбрат', 'адміністратор'], w: ri(130, 170), h: ri(90, 110) },
|
|||
|
|
{ id: 'garage', name: `Автомайстерня «${pick(N.GARAGES)}»`, public: true, spots: spots(['яма', 'стелаж з інструментами', 'конторка', 'двір за боксами'], ri(0, 1)), jobs: ['механік', 'механік', 'приймальник'], w: ri(105, 140), h: ri(90, 115) },
|
|||
|
|
{ id: 'park', name: pick(N.PARKS), public: true, spots: spots(['алея', 'кущі біля лавок', 'старий фонтан'], ri(0, 1)), w: ri(230, 280), h: ri(60, 80) },
|
|||
|
|
{ id: 'pier', name: pick(N.PIERS), public: true, spots: spots(['пірс', 'елінг', 'урна біля сходів'], ri(0, 1)), jobs: ['доглядач причалу'], w: ri(230, 280), h: ri(60, 80) }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Розкладка «полицями» у 4 кварталах, які вирізають дороги: гарантований зазор GAP,
|
|||
|
|
// випадковий порядок і джитер дають різні мапи. Не зійшлося — перезапуск із новим порядком.
|
|||
|
|
// ponytail: полиці замість фізики/force-layout — простіше і не ламається
|
|||
|
|
const cells0 = [
|
|||
|
|
{ x0: MARGIN, x1: vx - ROAD_PAD, y0: MARGIN, y1: hy - ROAD_PAD },
|
|||
|
|
{ x0: vx + 56 + ROAD_PAD, x1: W - MARGIN, y0: MARGIN, y1: hy - ROAD_PAD },
|
|||
|
|
{ x0: MARGIN, x1: vx - ROAD_PAD, y0: hy + 64 + ROAD_PAD, y1: 608 },
|
|||
|
|
{ x0: vx + 56 + ROAD_PAD, x1: W - MARGIN, y0: hy + 64 + ROAD_PAD, y1: 608 }
|
|||
|
|
];
|
|||
|
|
let buildings;
|
|||
|
|
for (let attempt = 0; ; attempt++) {
|
|||
|
|
if (attempt >= 200) throw new Error('мапа не зійшлася: ' + attempt + ' перезапусків');
|
|||
|
|
buildings = [];
|
|||
|
|
const cells = cells0.map(c => ({ ...c, cx: c.x0, cy: c.y0, rowH: 0 }));
|
|||
|
|
let ok = true;
|
|||
|
|
for (const d of shuffle(defs)) {
|
|||
|
|
let placedOne = false;
|
|||
|
|
for (const cell of shuffle(cells)) {
|
|||
|
|
// спроба в поточному рядку, інакше — новий рядок
|
|||
|
|
for (let nl = 0; nl < 2 && !placedOne; nl++) {
|
|||
|
|
if (cell.cx + d.w > cell.x1) { cell.cy += cell.rowH + GAP; cell.cx = cell.x0; cell.rowH = 0; }
|
|||
|
|
if (cell.cx + d.w <= cell.x1 && cell.cy + d.h <= cell.y1) {
|
|||
|
|
const jy = ri(0, Math.min(14, cell.y1 - cell.cy - d.h));
|
|||
|
|
const { w, h, ...rest } = d;
|
|||
|
|
buildings.push({ ...rest, pos: { x: cell.cx, y: cell.cy + jy, w, h } });
|
|||
|
|
cell.cx += d.w + GAP;
|
|||
|
|
cell.rowH = Math.max(cell.rowH, d.h + jy);
|
|||
|
|
placedOne = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (placedOne) break;
|
|||
|
|
}
|
|||
|
|
if (!placedOne) { ok = false; break; }
|
|||
|
|
}
|
|||
|
|
if (ok) break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// камери: три «власні» + муніципальні, щоб кожне можливе місце злочину було під наглядом
|
|||
|
|
const byId = id => buildings.find(b => b.id === id);
|
|||
|
|
const center = b => ({ x: b.pos.x + b.pos.w / 2, y: b.pos.y + b.pos.h / 2 });
|
|||
|
|
const near = (host, dist) => buildings
|
|||
|
|
.filter(o => Math.hypot(center(o).x - center(host).x, center(o).y - center(host).y) <= dist)
|
|||
|
|
.map(o => o.id);
|
|||
|
|
const cameras = [];
|
|||
|
|
for (const hostId of ['shop', 'bar', 'office']) {
|
|||
|
|
const h = byId(hostId);
|
|||
|
|
cameras.push({ id: 'cam_' + hostId, name: 'Камера біля: ' + h.name.split(',')[0], covers: near(h, 240) });
|
|||
|
|
}
|
|||
|
|
for (const must of ['park', 'pier', 'bar', 'garage', 'office'])
|
|||
|
|
if (!cameras.some(c => c.covers.includes(must)))
|
|||
|
|
cameras.push({ id: 'cam_' + must, name: 'Муніципальна камера: ' + byId(must).name.split(',')[0], covers: near(byId(must), 200) });
|
|||
|
|
|
|||
|
|
const towers = [
|
|||
|
|
{ id: 't1', name: 'Вежа «Північ»', pos: { x: ri(60, 300), y: ri(20, 70) } },
|
|||
|
|
{ id: 't2', name: 'Вежа «Схід»', pos: { x: ri(870, 970), y: ri(200, 450) } },
|
|||
|
|
{ id: 't3', name: 'Вежа «Південь»', pos: { x: ri(250, 600), y: ri(640, 690) } }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
name: `Район «${pick(N.DISTRICTS)}»`,
|
|||
|
|
world: { w: W, h: H },
|
|||
|
|
roads, buildings, cameras, towers,
|
|||
|
|
homeSlots: ['письмовий стіл', 'шафа', 'кухня', 'ванна кімната', 'під ліжком',
|
|||
|
|
...shuffle(['балкон', 'антресолі', 'комора']).slice(0, ri(0, 2))],
|
|||
|
|
dumpSpots: [
|
|||
|
|
{ building: 'bar', spot: 'смітник у дворі' },
|
|||
|
|
{ building: 'park', spot: 'кущі біля лавок' },
|
|||
|
|
{ building: 'pier', spot: 'урна біля сходів' },
|
|||
|
|
{ building: 'park', spot: 'старий фонтан' }
|
|||
|
|
],
|
|||
|
|
crimeScenes: [
|
|||
|
|
{ building: 'park', spot: 'алея', name: 'алея парку' },
|
|||
|
|
{ building: 'pier', spot: 'пірс', name: 'пірс на набережній' },
|
|||
|
|
{ building: 'bar', spot: 'смітник у дворі', name: 'двір за баром' },
|
|||
|
|
{ building: 'garage', spot: 'двір за боксами', name: 'двір за боксами автомайстерні' },
|
|||
|
|
{ building: 'office', spot: 'паркінг', name: 'нічний паркінг бізнес-центру' }
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unitBuilding(unit) { return unit[0] === 'A' ? 'apt_a' : unit[0] === 'B' ? 'apt_b' : 'apt_c'; }
|
|||
|
|
function allUnits(d) { return d.buildings.flatMap(b => b.units || []); }
|
|||
|
|
function allJobs(d) {
|
|||
|
|
return d.buildings.flatMap(b => (b.jobs || []).map(title => ({ title, building: b.id })));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { makeDistrict, unitBuilding, allUnits, allJobs, GAP, MARGIN };
|