feat(civilization): add japanese city theme and improve world generation

- Add japanese-themed city sprite sheet (PNG + PSD) and register in artwork config
- Switch several opponents to cyberpunk or japanese city themes
- Fix hut badge stale display by including tile coordinates in hut events and
  repainting affected tiles immediately via repaintFromEvents
- Replace diagonal lattice world generation with hash-based jittered grid:
  * Shield grassland now uses balanced 2x2 blocks hashed per position to remove
    visible screen-space banding while preserving 50% density
  * Special resources placed on jittered blocks with adjacency avoidance,
    eliminating clumping and barren regions
- Update sprites.md paths and status markers to reflect completed sheets
- Expand verifyCivilization checks for shield density range and special placement
  quality (no adjacency, balanced windows, no screen-column alignment)
- Update Tetris Attack dialogue with control instructions and typo fixes
This commit is contained in:
Brian Fertig 2026-07-22 23:48:41 -06:00
parent 6fd4510b8b
commit 2880eeab2a
10 changed files with 200 additions and 54 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -15,6 +15,7 @@
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 }, "iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
"citySheets": { "citySheets": {
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }, "classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 } "cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },
"japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 }
} }
} }

View File

@ -9,7 +9,7 @@
"alphabet", "alphabet",
"pottery" "pottery"
], ],
"citySheet": "classic", "citySheet": "cyberpunk",
"bio": "AI Bro sent from the future to show the superiority of the future.", "bio": "AI Bro sent from the future to show the superiority of the future.",
"voice": "Legion", "voice": "Legion",
"convo": [ "convo": [
@ -898,7 +898,7 @@
"masonry", "masonry",
"pottery" "pottery"
], ],
"citySheet": "classic", "citySheet": "japanese",
"bio": "I have traveled far to play these games. Honor me and let us play!", "bio": "I have traveled far to play these games. Honor me and let us play!",
"speech": { "speech": {
"intro": [ "intro": [
@ -1045,7 +1045,7 @@
"pottery", "pottery",
"thewheel" "thewheel"
], ],
"citySheet": "classic", "citySheet": "japanese",
"bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!", "bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!",
"speech": { "speech": {
"intro": [ "intro": [
@ -1080,7 +1080,7 @@
"horsebackriding", "horsebackriding",
"thewheel" "thewheel"
], ],
"citySheet": "cyberpunk", "citySheet": "japanese",
"bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.", "bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.",
"speech": { "speech": {
"intro": [ "intro": [

View File

@ -18,9 +18,11 @@
"startRows": 6, "startRows": 6,
"clearLineRows": 3, "clearLineRows": 3,
"introLines": [ "introLines": [
"Well hey there, sugar! Ain't you a sight for sore eyes.", "Well hey there, stranger! Ain't you a sight for sore eyes.",
"These panels are stackin' up faster than I can sort 'em, and I've been at it all mornin'.", "These panels are stackin' up faster than I can sort 'em, and I've been at it all mornin'.",
"Lend me a hand and we'll have this cleared by supper." "Use your arrow keys to move your selecter aroung, and press space, x, or z o swap tiles.",
"Get three in a row and they'll get out of town faster than a varmit at sundown!",
"Clear enough of 'em and you can help me clean this here town up!"
], ],
"winLines": [ "winLines": [
"Well shoot, you cleared the whole mess! Bless your heart.", "Well shoot, you cleared the whole mess! Bless your heart.",

View File

@ -1224,7 +1224,10 @@ export default class CivilizationGame extends Phaser.Scene {
afterAction() { afterAction() {
// Repaint tiles that may have changed (work orders complete on turn start, // Repaint tiles that may have changed (work orders complete on turn start,
// but roads from engineers etc. show up next refresh — cheap full check // but roads from engineers etc. show up next refresh — cheap full check
// is unnecessary; unit/city layer + HUD is enough here). // is unnecessary; unit/city layer + HUD is enough here). Baked-terrain
// features the human changes mid-turn (popping a hut) do need it now,
// though — waiting for startHumanTurn leaves a stale badge all turn.
this.repaintFromEvents();
this.view.refresh(); this.view.refresh();
this.refreshHud(); this.refreshHud();
if (this.state.over) this.onGameOver(); if (this.state.over) this.onGameOver();
@ -1373,7 +1376,9 @@ export default class CivilizationGame extends Phaser.Scene {
for (const e of this.state.events) { for (const e of this.state.events) {
if (e.painted) continue; if (e.painted) continue;
e.painted = true; e.painted = true;
if (e.type === 'workDone') { if (e.type === 'workDone' || e.type === 'hut') {
// A popped hut is cleared from world.huts, but the hut badge lives in
// the baked terrain texture — without this it stays drawn all session.
this.view.repaintTileAndNeighbors(e.x, e.y); this.view.repaintTileAndNeighbors(e.x, e.y);
} else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') { } else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') {
const city = Logic.cityById(this.state, e.cityId); const city = Logic.cityById(this.state, e.cityId);

View File

@ -899,11 +899,14 @@ export function disembarkTiles(rules, state, boat) {
function resolveHut(rules, state, unit) { function resolveHut(rules, state, unit) {
const civ = state.civs[unit.civ]; const civ = state.civs[unit.civ];
// Captured up front: the hut tile is what the view has to repaint, and an
// ambush loss removes the unit before the event is pushed.
const { x, y } = unit;
const roll = rand(state); const roll = rand(state);
if (roll < 0.4) { if (roll < 0.4) {
const gold = 25 * (1 + randInt(state, 4)); const gold = 25 * (1 + randInt(state, 4));
civ.gold += gold; civ.gold += gold;
state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold, x, y });
return { outcome: 'gold', gold }; return { outcome: 'gold', gold };
} }
if (roll < 0.65) { if (roll < 0.65) {
@ -911,17 +914,17 @@ function resolveHut(rules, state, unit) {
if (options.length) { if (options.length) {
const tech = options[randInt(state, options.length)]; const tech = options[randInt(state, options.length)];
grantTech(rules, state, civ, tech.id); grantTech(rules, state, civ, tech.id);
state.events.push({ type: 'hut', civ: civ.id, outcome: 'tech', tech: tech.id }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'tech', tech: tech.id, x, y });
return { outcome: 'tech', tech: tech.id }; return { outcome: 'tech', tech: tech.id };
} }
civ.gold += 50; civ.gold += 50;
state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold: 50 }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold: 50, x, y });
return { outcome: 'gold', gold: 50 }; return { outcome: 'gold', gold: 50 };
} }
if (roll < 0.85) { if (roll < 0.85) {
const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen'); const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen');
spawnUnit(rules, state, unit.civ, type, unit.x, unit.y, null); spawnUnit(rules, state, unit.civ, type, unit.x, unit.y, null);
state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type, x, y });
return { outcome: 'unit', unit: type }; return { outcome: 'unit', unit: type };
} }
// Ambush: fight a phantom era-scaled hostile on the spot. // Ambush: fight a phantom era-scaled hostile on the spot.
@ -932,11 +935,11 @@ function resolveHut(rules, state, unit) {
phantom.defense, phantom.hp, phantom.fp, unit.hp); phantom.defense, phantom.hp, phantom.fp, unit.hp);
if (!survived.attackerWon) { if (!survived.attackerWon) {
removeUnit(state, unit); removeUnit(state, unit);
state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushLost' }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushLost', x, y });
return { outcome: 'ambushLost' }; return { outcome: 'ambushLost' };
} }
unit.hp = survived.attackerHp; unit.hp = survived.attackerHp;
state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushWon' }); state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushWon', x, y });
return { outcome: 'ambushWon' }; return { outcome: 'ambushWon' };
} }

View File

@ -4,8 +4,8 @@
// east-west wrap). Terrain from layered value noise: elevation picks land/sea // east-west wrap). Terrain from layered value noise: elevation picks land/sea
// (exact land fraction via percentile threshold), a ridged channel places // (exact land fraction via percentile threshold), a ridged channel places
// hills/mountains, latitude bands place tundra/glacier/desert, and a moisture // hills/mountains, latitude bands place tundra/glacier/desert, and a moisture
// channel places forest/jungle/swamp. Specials sit on a Civ II-style diagonal // channel places forest/jungle/swamp. Specials sit on a jittered grid (fixed
// lattice; huts scatter on land away from starting positions. // density, hashed position per block); huts scatter on land away from starts.
export function mulberry32(seed) { export function mulberry32(seed) {
let a = seed >>> 0; let a = seed >>> 0;
@ -50,12 +50,74 @@ function makeNoise(rng, cols, rows, octaves = 3, baseFreq = 5) {
}; };
} }
// Civ II-ish deterministic lattices (independent of RNG so they look "placed"). // 32-bit avalanche hash. Deterministic and order-independent, so tile features
export function shieldGrassAt(x, y) { return ((x * 2 + y) % 4) < 2; } // can be decided per-coordinate without consuming the RNG stream.
function specialSlotAt(x, y, offset) { function hash3(a, b, c) {
return ((x * 3 + y * 5 + offset) % 16) === 0; let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1) ^ Math.imul(c | 0, 0x9e3779b1);
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
return (h ^ (h >>> 15)) >>> 0;
}
// Shield grassland: exactly 50% of tiles, stratified into 2x2 blocks.
//
// The old `(x*2+y) % 4 < 2` lattice repeats along a fixed grid direction, which
// the isometric projection turns into visible screen-space banding. Plain white
// noise removes the banding but is a balance change, not just a cosmetic one:
// it holds the *mean* at 50% while adding per-city variance, and city growth is
// convex, so a city that happens to draw shield-heavy tiles snowballs. In the
// self-play soak that produced runaway empires and tripled AI turn time.
//
// Giving every aligned 2x2 block exactly two shield tiles — choosing which two
// by hash — keeps a city radius near 50% the way the old lattice did, while the
// per-block choice leaves no directional structure to read as stripes.
//
// Baked into a mask at load so tileYield(), which hits this for every grassland
// tile of every city every turn, pays only a masked array read.
// 128 exceeds the largest world (72x56), so the wrap is never visible in play.
const SHIELD_MASK_BITS = 7;
const SHIELD_MASK_SIZE = 1 << SHIELD_MASK_BITS;
const BALANCED_2X2 = [
[1, 1, 0, 0], [1, 0, 1, 0], [1, 0, 0, 1],
[0, 1, 1, 0], [0, 1, 0, 1], [0, 0, 1, 1],
];
const SHIELD_MASK = (() => {
const mask = new Uint8Array(SHIELD_MASK_SIZE * SHIELD_MASK_SIZE);
for (let by = 0; by < SHIELD_MASK_SIZE / 2; by += 1) {
for (let bx = 0; bx < SHIELD_MASK_SIZE / 2; bx += 1) {
const pattern = BALANCED_2X2[hash3(bx, by, 0x5e1d) % BALANCED_2X2.length];
for (let dy = 0; dy < 2; dy += 1) {
for (let dx = 0; dx < 2; dx += 1) {
mask[(((by * 2 + dy) << SHIELD_MASK_BITS)) + bx * 2 + dx] = pattern[dy * 2 + dx];
}
}
}
}
return mask;
})();
export function shieldGrassAt(x, y) {
return SHIELD_MASK[((y & (SHIELD_MASK_SIZE - 1)) << SHIELD_MASK_BITS)
+ (x & (SHIELD_MASK_SIZE - 1))] === 0;
}
// Specials: one per SPECIAL_BLOCK² tiles, jittered inside the block. Fixed
// density keeps regions from coming out lopsidedly rich or poor; the hashed
// offset inside each block removes the alignment a plain lattice would show.
const SPECIAL_BLOCK = 4;
// Is any of the 8 neighbours of (x,y) already a special?
function specialNear(special, cols, rows, x, y) {
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
if (special[ny * cols + nx] >= 0) return true;
}
}
return false;
} }
function specialPick(x, y) { return ((x >> 1) + (y >> 1)) % 2; }
// Improvement bit flags stored per tile. // Improvement bit flags stored per tile.
export const IMP = { ROAD: 1, RAILROAD: 2, IRRIGATION: 4, FARMLAND: 8, MINE: 16, FORTRESS: 32 }; export const IMP = { ROAD: 1, RAILROAD: 2, IRRIGATION: 4, FARMLAND: 8, MINE: 16, FORTRESS: 32 };
@ -155,21 +217,45 @@ function tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt) {
const largest = Math.max(0, ...contSizes); const largest = Math.max(0, ...contSizes);
if (landCount === 0 || largest / landCount < 0.15) return null; // reroll if (landCount === 0 || largest / landCount < 0.15) return null; // reroll
// Specials on the diagonal lattice (grassland gets shield variant instead). // Specials: jittered grid. One per SPECIAL_BLOCK² block, placed on a hashed
const latticeOffset = Math.floor(rng() * 16); // pick from that block's eligible tiles (grassland gets the shield variant
// instead, so it is never eligible). Picking among eligible tiles rather than
// a fixed slot means a mostly-grassland block still seeds its few hills,
// instead of wasting the slot and leaving the region barren.
const specialSalt = (rng() * 4294967296) >>> 0;
const special = new Array(n).fill(-1); const special = new Array(n).fill(-1);
const specialIndexById = {}; const specialIndexById = {};
rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; }); rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; });
for (let y = 0; y < rows; y += 1) { const blockCols = Math.ceil(cols / SPECIAL_BLOCK);
for (let x = 0; x < cols; x += 1) { const blockRows = Math.ceil(rows / SPECIAL_BLOCK);
const i = y * cols + x; for (let by = 0; by < blockRows; by += 1) {
const terr = rules.terrainList[terrain[i]]; for (let bx = 0; bx < blockCols; bx += 1) {
const eligible = [];
for (let dy = 0; dy < SPECIAL_BLOCK; dy += 1) {
for (let dx = 0; dx < SPECIAL_BLOCK; dx += 1) {
const x = bx * SPECIAL_BLOCK + dx;
const y = by * SPECIAL_BLOCK + dy;
if (x >= cols || y >= rows) continue;
const terr = rules.terrainList[terrain[y * cols + x]];
if (terr.id === 'grassland') continue; if (terr.id === 'grassland') continue;
if (!specialSlotAt(x, y, latticeOffset)) continue; if (!rules.specialsByTerrain[terr.id]?.length) continue;
const opts = rules.specialsByTerrain[terr.id]; eligible.push([x, y]);
if (!opts || !opts.length) continue; }
const pick = opts[specialPick(x, y) % opts.length]; }
special[i] = specialIndexById[pick.id]; if (!eligible.length) continue;
// Walk candidates from a hashed start, skipping any that would sit next
// to an already-placed special — blocks are independent otherwise, so
// two can both pick against their shared border and clump.
const start = hash3(bx, by, specialSalt) % eligible.length;
for (let k = 0; k < eligible.length; k += 1) {
const [x, y] = eligible[(start + k) % eligible.length];
if (specialNear(special, cols, rows, x, y)) continue;
const opts = rules.specialsByTerrain[rules.terrainList[terrain[y * cols + x]].id];
const pick = opts[hash3(x, y, specialSalt ^ 0x5bf03635) % opts.length];
special[y * cols + x] = specialIndexById[pick.id];
break;
}
} }
} }

View File

@ -7,10 +7,10 @@ box clusters. This document lists the optional sprite sheets you can drop in
to replace those placeholders, with exact dimensions, layouts, and to replace those placeholders, with exact dimensions, layouts, and
frame-by-frame maps. frame-by-frame maps.
All art is wired through **`public/data/civilization-artwork.json`**. The scene All art is wired through **`data/civilization-artwork.json`**. The scene
checks whether each sheet's texture is loaded; if it is, it uses your frames, checks whether each sheet's texture is loaded; if it is, it uses your frames,
otherwise it falls back to procedural drawing. You never need to touch code — otherwise it falls back to procedural drawing. You never need to touch code —
just add a PNG under `public/assets/images/civilization/` and set its `path` just add a PNG under `assets/images/civilization/` and set its `path`
in that JSON. in that JSON.
Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left, Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left,
@ -36,11 +36,11 @@ against each other with no blending in v1 (hard Civ I-style edges).
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-terrain.png` | | **Path** | `assets/images/civilization/civilization-terrain.png` |
| **Sheet size** | **768 × 192 px** | | **Sheet size** | **768 × 192 px** |
| **Frame size** | **128 × 96 px** | | **Frame size** | **128 × 96 px** |
| **Layout** | 6 columns × 2 rows = 12 frames | | **Layout** | 6 columns × 2 rows = 12 frames |
| **Status** | ❌ not created — procedural diamonds render meanwhile | | **Status** | ✅ created and loading |
| **JSON** | `terrainSheet` | | **JSON** | `terrainSheet` |
### Frame map ### Frame map
@ -72,11 +72,11 @@ on the diamond).
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-resources.png` | | **Path** | `assets/images/civilization/civilization-resources.png` |
| **Sheet size** | **320 × 256 px** | | **Sheet size** | **320 × 256 px** |
| **Frame size** | **64 × 64 px** | | **Frame size** | **64 × 64 px** |
| **Layout** | 5 columns × 4 rows = 20 frames | | **Layout** | 5 columns × 4 rows = 20 frames |
| **Status** | ❌ not created — colored dot badges render meanwhile | | **Status** | ✅ created and loading |
| **JSON** | `resourceSheet` | | **JSON** | `resourceSheet` |
### Frame map ### Frame map
@ -113,7 +113,7 @@ vector-drawn (they connect dynamically between tiles), so they are NOT here.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-improvements.png` | | **Path** | `assets/images/civilization/civilization-improvements.png` |
| **Sheet size** | **384 × 64 px** | | **Sheet size** | **384 × 64 px** |
| **Frame size** | **64 × 64 px** | | **Frame size** | **64 × 64 px** |
| **Layout** | 6 columns × 1 row = 6 frames | | **Layout** | 6 columns × 1 row = 6 frames |
@ -151,11 +151,11 @@ headroom band (y = 032), not the diamond zone.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-units.png` | | **Path** | `assets/images/civilization/civilization-units.png` |
| **Sheet size** | **512 × 672 px** | | **Sheet size** | **512 × 672 px** |
| **Frame size** | **64 × 96 px** | | **Frame size** | **64 × 96 px** |
| **Layout** | 8 columns × 7 rows = 56 frames (51 used, 5255 reserved) | | **Layout** | 8 columns × 7 rows = 56 frames (51 used, 5255 reserved) |
| **Status** | ❌ not created — roundel + 2-letter label renders meanwhile | | **Status** | ✅ created and loading |
| **JSON** | `unitSheet` | | **JSON** | `unitSheet` |
### Frame map ### Frame map
@ -196,16 +196,17 @@ Frame indexes are pinned in `data/civilization-rules.json` (`units[].frame`).
## 5. City sheet (classic theme) — `civilization-cities-classic.png` ## 5. City sheet (classic theme) — `civilization-cities-classic.png`
Cities scale with population and show walls when City Walls are built. This is Cities scale with population and show walls when City Walls are built. This is
the **classic/western** theme; more themes (e.g. `asian`) drop in later by the **classic/western** theme; `cyberpunk` and `japanese` sheets already ship
adding an entry to `citySheets` in the artwork JSON — the format already alongside it, and further themes drop in by adding an entry to `citySheets`
supports it and no code changes are needed. in the artwork JSON — the format already supports it and no code changes are
needed. Every theme uses the same frame map below.
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-cities-classic.png` | | **Path** | `assets/images/civilization/civilization-cities-classic.png` |
| **Sheet size** | **512 × 192 px** | | **Sheet size** | **512 × 192 px** |
| **Frame size** | **128 × 96 px** (same diamond+headroom as terrain) | | **Frame size** | **128 × 96 px** (same diamond+headroom as terrain) |
| **Status** | ❌ not created — box-cluster fallback renders meanwhile | | **Status** | ✅ created and loading |
| **Layout** | 4 columns × 2 rows = 8 frames | | **Layout** | 4 columns × 2 rows = 8 frames |
| **JSON** | `citySheets.classic` | | **JSON** | `citySheets.classic` |
@ -233,7 +234,7 @@ Small interface icons (yields, governments, spaceship parts, moods).
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/civilization/civilization-icons.png` | | **Path** | `assets/images/civilization/civilization-icons.png` |
| **Sheet size** | **480 × 96 px** | | **Sheet size** | **480 × 96 px** |
| **Frame size** | **48 × 48 px** | | **Frame size** | **48 × 48 px** |
| **Layout** | 10 columns × 2 rows = 20 frames | | **Layout** | 10 columns × 2 rows = 20 frames |
@ -263,7 +264,7 @@ Government frames match `governments[].frame` in the rules JSON (1015).
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/game-icons.png` (shared sheet) | | **Path** | `assets/images/game-icons.png` (shared sheet) |
| **Sheet size** | 660 × 660 px (15 × 15 grid of 44 × 44 frames) | | **Sheet size** | 660 × 660 px (15 × 15 grid of 44 × 44 frames) |
| **Frame** | **83** (row 5, col 8) | | **Frame** | **83** (row 5, col 8) |
| **Status** | ❌ needs painting | | **Status** | ❌ needs painting |
@ -285,6 +286,6 @@ city. The menu shows a generic fallback until painted.
| 6 | `civilization-icons.png` | 480×96 | Low | | 6 | `civilization-icons.png` | 480×96 | Low |
| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) | | 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) |
After painting a sheet, set its `path` in `public/data/civilization-artwork.json`, After painting a sheet, set its `path` in `data/civilization-artwork.json`,
e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and
reload — no code changes. reload — no code changes.

View File

@ -324,11 +324,59 @@ if (RULES) {
|| RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false; || RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false;
} }
check('huts on land (not glacier)', hutsOnLand); check('huts on land (not glacier)', hutsOnLand);
check('shield-grass lattice ~50%', (() => { check('shield-grass ~50%', (() => {
let c = 0; let c = 0;
for (let y = 0; y < 20; y += 1) for (let x = 0; x < 20; x += 1) c += shieldGrassAt(x, y) ? 1 : 0; for (let y = 0; y < 60; y += 1) for (let x = 0; x < 60; x += 1) c += shieldGrassAt(x, y) ? 1 : 0;
return c === 200; return c > 3600 * 0.45 && c < 3600 * 0.55;
})()); })());
// Placement must look organic: no clumps, no barren regions, no alignment.
let adjacentSpecials = 0;
for (let y = 0; y < w.rows; y += 1) {
for (let x = 0; x < w.cols; x += 1) {
if (w.special[y * w.cols + x] < 0) continue;
for (const [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w.cols || ny >= w.rows) continue;
if (w.special[ny * w.cols + nx] >= 0) adjacentSpecials += 1;
}
}
}
check('no two specials adjacent', adjacentSpecials === 0, `${adjacentSpecials} pairs`);
// Coarse 12x12 windows: every window with land should hold some specials, and
// none should hoard them. Catches both barren and over-rich regions.
let minWin = Infinity;
let maxWin = 0;
for (let wy = 0; wy + 12 <= w.rows; wy += 12) {
for (let wx = 0; wx + 12 <= w.cols; wx += 12) {
let c = 0;
for (let y = wy; y < wy + 12; y += 1) {
for (let x = wx; x < wx + 12; x += 1) if (w.special[y * w.cols + x] >= 0) c += 1;
}
minWin = Math.min(minWin, c);
maxWin = Math.max(maxWin, c);
}
}
check('no barren 12x12 region', minWin >= 3, `min ${minWin} per window`);
check('no over-rich 12x12 region', maxWin <= 14, `max ${maxWin} per window`);
// Straightness: a modular lattice puts most specials on a few screen columns
// (iso screen-x is proportional to x - y). Real jitter spreads them out.
const byScreenCol = new Map();
let specTotal = 0;
for (let y = 0; y < w.rows; y += 1) {
for (let x = 0; x < w.cols; x += 1) {
if (w.special[y * w.cols + x] < 0) continue;
const k = x - y;
byScreenCol.set(k, (byScreenCol.get(k) ?? 0) + 1);
specTotal += 1;
}
}
const worstCol = Math.max(...byScreenCol.values());
check('specials not aligned in screen columns', worstCol <= specTotal * 0.06,
`worst column holds ${worstCol}/${specTotal}`);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------