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
@@ -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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 73 KiB |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">Let’s 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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||