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

View File

@ -18,9 +18,11 @@
"startRows": 6,
"clearLineRows": 3,
"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'.",
"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": [
"Well shoot, you cleared the whole mess! Bless your heart.",

View File

@ -1224,7 +1224,10 @@ export default class CivilizationGame extends Phaser.Scene {
afterAction() {
// Repaint tiles that may have changed (work orders complete on turn start,
// 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.refreshHud();
if (this.state.over) this.onGameOver();
@ -1373,7 +1376,9 @@ export default class CivilizationGame extends Phaser.Scene {
for (const e of this.state.events) {
if (e.painted) continue;
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);
} else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') {
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) {
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);
if (roll < 0.4) {
const gold = 25 * (1 + randInt(state, 4));
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 };
}
if (roll < 0.65) {
@ -911,17 +914,17 @@ function resolveHut(rules, state, unit) {
if (options.length) {
const tech = options[randInt(state, options.length)];
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 };
}
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 };
}
if (roll < 0.85) {
const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen');
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 };
}
// 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);
if (!survived.attackerWon) {
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' };
}
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' };
}

View File

@ -4,8 +4,8 @@
// east-west wrap). Terrain from layered value noise: elevation picks land/sea
// (exact land fraction via percentile threshold), a ridged channel places
// hills/mountains, latitude bands place tundra/glacier/desert, and a moisture
// channel places forest/jungle/swamp. Specials sit on a Civ II-style diagonal
// lattice; huts scatter on land away from starting positions.
// channel places forest/jungle/swamp. Specials sit on a jittered grid (fixed
// density, hashed position per block); huts scatter on land away from starts.
export function mulberry32(seed) {
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").
export function shieldGrassAt(x, y) { return ((x * 2 + y) % 4) < 2; }
function specialSlotAt(x, y, offset) {
return ((x * 3 + y * 5 + offset) % 16) === 0;
// 32-bit avalanche hash. Deterministic and order-independent, so tile features
// can be decided per-coordinate without consuming the RNG stream.
function hash3(a, b, c) {
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.
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);
if (landCount === 0 || largest / landCount < 0.15) return null; // reroll
// Specials on the diagonal lattice (grassland gets shield variant instead).
const latticeOffset = Math.floor(rng() * 16);
// Specials: jittered grid. One per SPECIAL_BLOCK² block, placed on a hashed
// 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 specialIndexById = {};
rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; });
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < cols; x += 1) {
const i = y * cols + x;
const terr = rules.terrainList[terrain[i]];
if (terr.id === 'grassland') continue;
if (!specialSlotAt(x, y, latticeOffset)) continue;
const opts = rules.specialsByTerrain[terr.id];
if (!opts || !opts.length) continue;
const pick = opts[specialPick(x, y) % opts.length];
special[i] = specialIndexById[pick.id];
const blockCols = Math.ceil(cols / SPECIAL_BLOCK);
const blockRows = Math.ceil(rows / SPECIAL_BLOCK);
for (let by = 0; by < blockRows; by += 1) {
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 (!rules.specialsByTerrain[terr.id]?.length) continue;
eligible.push([x, y]);
}
}
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
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,
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.
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** |
| **Frame size** | **128 × 96 px** |
| **Layout** | 6 columns × 2 rows = 12 frames |
| **Status** | ❌ not created — procedural diamonds render meanwhile |
| **Status** | ✅ created and loading |
| **JSON** | `terrainSheet` |
### 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** |
| **Frame size** | **64 × 64 px** |
| **Layout** | 5 columns × 4 rows = 20 frames |
| **Status** | ❌ not created — colored dot badges render meanwhile |
| **Status** | ✅ created and loading |
| **JSON** | `resourceSheet` |
### 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** |
| **Frame size** | **64 × 64 px** |
| **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** |
| **Frame size** | **64 × 96 px** |
| **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` |
### 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`
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
adding an entry to `citySheets` in the artwork JSON — the format already
supports it and no code changes are needed.
the **classic/western** theme; `cyberpunk` and `japanese` sheets already ship
alongside it, and further themes drop in by adding an entry to `citySheets`
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** |
| **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 |
| **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** |
| **Frame size** | **48 × 48 px** |
| **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) |
| **Frame** | **83** (row 5, col 8) |
| **Status** | ❌ needs painting |
@ -285,6 +286,6 @@ city. The menu shows a generic fallback until painted.
| 6 | `civilization-icons.png` | 480×96 | Low |
| 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
reload — no code changes.

View File

@ -324,11 +324,59 @@ if (RULES) {
|| RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false;
}
check('huts on land (not glacier)', hutsOnLand);
check('shield-grass lattice ~50%', (() => {
check('shield-grass ~50%', (() => {
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;
return c === 200;
for (let y = 0; y < 60; y += 1) for (let x = 0; x < 60; x += 1) c += shieldGrassAt(x, y) ? 1 : 0;
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}`);
}
// ---------------------------------------------------------------------------