2PlayerGame/detective-game/casegen/district.js
2026-08-15 14:07:39 +03:00

132 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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