// Генератор району: геометрія, назви і приміщення — свої для кожного сіда. // Захист від незграбних мап: будівлі не перекривають дороги й одна одну (зазор 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 };