diff --git a/README.md b/README.md index f026fc4..f424480 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ procedural graphics (no asset downloads), and a simulation modeled on the classi | ⚑ **Power grid** | Coal, solar & wind plants; brownouts when demand exceeds supply; wind turbines get +50 % on the coast | | πŸ“ˆ **Land value & upgrades** | Parks, plazas, services and waterfronts raise land value; buildings grow through 3 density tiers | | πŸš“ **City services** | Police, fire stations, hospitals and schools shape happiness and value | -| πŸ”₯ **Disasters** | Pollution-driven fires spread, burn buildings to rubble and are contained by fire coverage | +| πŸ”₯ **Disasters** | Pollution-driven fires spread and are contained by fire coverage; mature cities face tornadoes that flatten whole streets (toggle in settings) | | πŸ’° **Budget & taxes** | Live tax slider, itemized income/upkeep ledger, debt warnings | | πŸš— **Traffic** | Animated cars flow along your road network | | πŸŒ— **Day/night cycle** | Windows light up as the sun sets | diff --git a/shots/01-boot.png b/shots/01-boot.png index 2d37c1b..938e59a 100644 Binary files a/shots/01-boot.png and b/shots/01-boot.png differ diff --git a/shots/02-built.png b/shots/02-built.png index 71854f4..ec51669 100644 Binary files a/shots/02-built.png and b/shots/02-built.png differ diff --git a/shots/03-grown.png b/shots/03-grown.png index 96a4d83..78a9e46 100644 Binary files a/shots/03-grown.png and b/shots/03-grown.png differ diff --git a/shots/08-budget.png b/shots/08-budget.png index a0c2daa..c1ad570 100644 Binary files a/shots/08-budget.png and b/shots/08-budget.png differ diff --git a/shots/08-menu.png b/shots/08-menu.png index 010c683..9eb9f76 100644 Binary files a/shots/08-menu.png and b/shots/08-menu.png differ diff --git a/shots/08-stats.png b/shots/08-stats.png index 30a7460..e89b263 100644 Binary files a/shots/08-stats.png and b/shots/08-stats.png differ diff --git a/shots/09-final.png b/shots/09-final.png index 6802813..43c8e88 100644 Binary files a/shots/09-final.png and b/shots/09-final.png differ diff --git a/src/config.js b/src/config.js index 88bf90e..250d75f 100644 --- a/src/config.js +++ b/src/config.js @@ -106,6 +106,8 @@ export const SIM = { growthAttemptsBase: 55, upgradeSamplesPerMonth: 70, fireBaseChance: 0.00045, + disasterMinMonth: 30, // grace period before tornadoes can spawn + disasterChance: 0.0022, // per-month probability once eligible fireSpreadChance: 0.06, abandonAfterBadPowerMonths: 2, autosaveEveryMonths: 12, diff --git a/src/game/city.js b/src/game/city.js index 57de8e4..ff64003 100644 --- a/src/game/city.js +++ b/src/game/city.js @@ -48,6 +48,7 @@ export class City extends EventBus { this._roadGraphDirty = true; this.roadGraph = { nodes: [], byTile: new Int32Array(n).fill(-1) }; this.rng = mulberry32(seed ^ 0x5f3759df); + this.disastersEnabled = true; this._debtWarnedAt = -99; } @@ -471,6 +472,7 @@ export class City extends EventBus { this.growZones(); this.upgradeAndAbandon(); this.fires(); + this.maybeDisaster(); // final aggregates after growth this.recomputePower(); @@ -611,6 +613,58 @@ export class City extends EventBus { else if (spreadHappened) this.notify('πŸ”₯ The fire is spreading!', 'danger'); } + maybeDisaster() { + if (!this.disastersEnabled) return; + if (this.monthIndex < SIM.disasterMinMonth || this.rng() >= SIM.disasterChance) return; + this.spawnTornado(); + } + + /** A tornado tears an erratic path through town, flattening buildings + * into rubble. Roads survive. */ + spawnTornado() { + const g = this.grid, S = g.size; + let sx = -1, sz = -1, best = -1; + for (let a = 0; a < 400; a++) { + const x = 4 + ((this.rng() * (S - 8)) | 0), z = 4 + ((this.rng() * (S - 8)) | 0); + let dev = 0; + for (let dz = -3; dz <= 3; dz += 2) for (let dx = -3; dx <= 3; dx += 2) { + if (!g.inB(x + dx, z + dz)) continue; + const j = g.idx(x + dx, z + dz); + if (g.isDeveloped(j) || g.struct[j]) dev++; + } + if (dev > best) { best = dev; sx = x; sz = z; } + } + if (best <= 0) return; + + let x = sx, z = sz, angle = this.rng() * Math.PI * 2, destroyed = 0; + const steps = 9 + ((this.rng() * 6) | 0); + const wreck = (tx, tz) => { + if (!g.inB(tx, tz)) return; + const i = g.idx(tx, tz); + if (g.isDeveloped(i)) { + g.burning[i] = 0; g.rubble[i] = 1; + g.level[i] = 0; g.variant[i] = 0; g.age[i] = 0; + return true; + } + if (g.struct[i] && g.struct[i] !== STRUCT.ROAD) { g.struct[i] = 0; return true; } + }; + for (let s = 0; s < steps; s++) { + // the funnel has width: strike this cell plus its four neighbours + for (const [dx, dz] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) { + if (wreck(x + dx, z + dz)) destroyed++; + } + angle += (this.rng() - 0.5) * 1.2; + x += Math.round(Math.cos(angle)); z += Math.round(Math.sin(angle)); + if (!g.inB(x, z)) break; + } + if (!destroyed) return; + this.recomputeAnchors(); + this.markTilesChanged(); + this.notify('\u{1F32A} A tornado tore through the city!', 'danger'); + this.emit('disaster', { type: 'tornado', destroyed }); + } + + economy() { const t = this.taxRate / 100; const g = this.grid; diff --git a/src/main.js b/src/main.js index 09fe59a..8578ef1 100644 --- a/src/main.js +++ b/src/main.js @@ -49,6 +49,7 @@ class Game { // ---------- lifecycle ---------- start(city) { + city.disastersEnabled = this.settings.disasters !== false; this.teardown(); this.city = city; @@ -131,6 +132,7 @@ class Game { Object.assign(this.settings, patch); localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings)); if ('shadows' in patch && this._parts) this._parts.renderer.setShadows(patch.shadows); + if ('disasters' in patch && this.city) this.city.disastersEnabled = patch.disasters !== false; } queryTile(tile, x, y) { diff --git a/src/ui/ui.js b/src/ui/ui.js index 165d32d..08335bb 100644 --- a/src/ui/ui.js +++ b/src/ui/ui.js @@ -507,6 +507,7 @@ export class UI { ${[STRUCT.ROAD, STRUCT.COAL, STRUCT.SOLAR, STRUCT.WIND, STRUCT.POLICE, STRUCT.FIRE, STRUCT.HOSPITAL, STRUCT.SCHOOL, STRUCT.PARK, STRUCT.PLAZA, STRUCT.STADIUM] .map(sid => `
${ICONS[b[sid].id === 'road' ? 'road' : b[sid].id]}
${b[sid].name} $${fmtNum(b[sid].cost)}${b[sid].upkeep ? ` Β· $${b[sid].upkeep}/mo` : ''}
${b[sid].desc}
`).join('')} +

πŸŒͺ️ Once your city matures, tornadoes can strike β€” they flatten buildings into rubble. Bulldoze the rubble to rebuild. Disasters can be disabled in ☰ Menu β†’ Settings.

`); this.showModal(modal); @@ -529,6 +530,7 @@ export class UI { +

PolyCity v1.0 β€” built with Three.js. Everything runs locally in your browser.

@@ -578,6 +580,7 @@ export class UI { }); m.querySelector('#setSound').addEventListener('change', e => this.game.applySettings({ sound: e.target.checked })); m.querySelector('#setShadow').addEventListener('change', e => this.game.applySettings({ shadows: e.target.checked })); + m.querySelector('#setDisasters').addEventListener('change', e => this.game.applySettings({ disasters: e.target.checked })); m.querySelector('#setAutosave').addEventListener('change', e => this.game.applySettings({ autosave: e.target.checked })); m.querySelector('#setMinimap').addEventListener('change', e => { this.game.applySettings({ minimap: e.target.checked }); diff --git a/tests/engine.mjs b/tests/engine.mjs index a127d3c..53c43d9 100644 --- a/tests/engine.mjs +++ b/tests/engine.mjs @@ -217,5 +217,43 @@ console.log('β€” save / load integrity β€”'); ok(c2.stats.powerCap >= 6000, 'power recomputed on load'); } +// ---------------- tornado disasters ---------------- +{ + const c = new City(777, 'Tornadoville'); + for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30); + const tiles = []; + for (let x = 9; x < 30; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]); + c.placeZone(ZONE.RES, tiles); + const cp = findSpot(c, 2, 2, 12, 36); + c.placeStruct(STRUCT.COAL, cp[0], cp[1]); + for (let i = 0; i < 10; i++) c.tick(); + const devBefore = [...c.grid.level].filter(v => v > 0).length; + ok(devBefore > 0, 'town exists before tornado'); + + let eventFired = false; + c.on('disaster', (e) => { if (e.type === 'tornado' && e.destroyed > 0) eventFired = true; }); + c.spawnTornado(); + + const rubbleAfter = [...c.grid.rubble].filter(v => v > 0).length; + ok(eventFired, 'disaster event emitted with damage count'); + ok(rubbleAfter > 0, `tornado left rubble behind (${rubbleAfter} tiles)`); + let anchorBad = 0; + for (let i = 0; i < c.grid.n; i++) { + const s = c.grid.struct[i]; + if (!s || s === STRUCT.ROAD || c.grid.anchor[i] !== i) continue; + if (c.grid.struct[c.grid.anchor[i]] !== s) anchorBad++; + } + ok(anchorBad === 0, 'anchors consistent after tornado destruction'); + + // disabled flag suppresses natural spawning + c.disastersEnabled = false; + let spawnedWhileOff = false; + const origSpawn = c.spawnTornado.bind(c); + c.spawnTornado = () => { spawnedWhileOff = true; return origSpawn(); }; + c.monthIndex = 60; + for (let m = 0; m < 80; m++) c.tick(); + ok(!spawnedWhileOff, 'disabled flag prevents tornado spawns'); +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/tests/soak.mjs b/tests/soak.mjs index 6b6aafe..d7afe7a 100644 --- a/tests/soak.mjs +++ b/tests/soak.mjs @@ -10,6 +10,7 @@ let pass = 0, fail = 0; const ok = (cond, name) => { if (cond) { pass++; console.log(' βœ”', name); } else { fail++; console.log(' ✘ FAIL:', name); } }; const c = new City(90210, 'Soakville'); +c.disastersEnabled = false; // economy focus here; tornado behaviour is covered by engine.mjs const g = c.grid; c.money = 60000;