Tornado disasters + settings toggle

- Monthly-chance tornadoes (after month 30 grace) carve a wide erratic
  path, flattening buildings into rubble; anchors stay consistent
- 'Tornado disasters' toggle in menu settings, persisted
- Help handbook documents the hazard and the rebuild loop
- Engine suite grows to 45 assertions (spawn damage, event, anchors,
  suppression when disabled); soak keeps economy focus disaster-free
This commit is contained in:
PolyCity
2026-08-22 19:37:36 +00:00
parent 2a614b5435
commit 77931e9747
14 changed files with 101 additions and 1 deletions
+1 -1
View File
@@ -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 |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 182 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 73 KiB

+2
View File
@@ -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,
+54
View File
@@ -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;
+2
View File
@@ -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) {
+3
View File
@@ -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 => `<div class="hb-item"><span class="hb-ico">${ICONS[b[sid].id === 'road' ? 'road' : b[sid].id]}</span><div><b>${b[sid].name}</b> <em>$${fmtNum(b[sid].cost)}${b[sid].upkeep ? ` · $${b[sid].upkeep}/mo` : ''}</em><br>${b[sid].desc}</div></div>`).join('')}
</div>
<p class="dim" style="margin:10px 0 0">🌪️ Once your city matures, tornadoes can strike — they flatten buildings into rubble. Bulldoze the rubble to rebuild. Disasters can be disabled in ☰ Menu → Settings.</p>
<button class="btn primary" id="helpClose">Lets build! 🔨</button>
</div>`);
this.showModal(modal);
@@ -529,6 +530,7 @@ export class UI {
<label><input type="checkbox" id="setShadow" ${this.settings.shadows ? 'checked' : ''}> Shadows (pretty, costs FPS)</label>
<label><input type="checkbox" id="setAutosave" ${this.settings.autosave ? 'checked' : ''}> Autosave every year</label>
<label><input type="checkbox" id="setMinimap" ${this.settings.minimap ? 'checked' : ''}> Minimap</label>
<label><input type="checkbox" id="setDisasters" ${this.settings.disasters !== false ? 'checked' : ''}> Tornado disasters</label>
</div>
<p class="hint">PolyCity v1.0 — built with Three.js. Everything runs locally in your browser.</p>
<button class="btn primary" id="mClose">Back to city</button>
@@ -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 });
+38
View File
@@ -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);
+1
View File
@@ -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;