diff --git a/assets/images/civilization/civilization-cities-japanese.png b/assets/images/civilization/civilization-cities-japanese.png new file mode 100644 index 0000000..47d73da Binary files /dev/null and b/assets/images/civilization/civilization-cities-japanese.png differ diff --git a/assets/images/civilization/civilization-cities-japanese.psd b/assets/images/civilization/civilization-cities-japanese.psd new file mode 100644 index 0000000..a5aac5c Binary files /dev/null and b/assets/images/civilization/civilization-cities-japanese.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index 7d4d442..93e0a0e 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -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 } } } diff --git a/data/opponents.json b/data/opponents.json index 18a7f9f..47a7cba 100644 --- a/data/opponents.json +++ b/data/opponents.json @@ -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": [ diff --git a/data/tetrisattack.json b/data/tetrisattack.json index a363d80..4fe1316 100644 --- a/data/tetrisattack.json +++ b/data/tetrisattack.json @@ -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.", diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index af1701d..4e2839c 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 424e78f..3190311 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -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' }; } diff --git a/src/games/civilization/CivilizationWorldGen.js b/src/games/civilization/CivilizationWorldGen.js index b9051b5..204403f 100644 --- a/src/games/civilization/CivilizationWorldGen.js +++ b/src/games/civilization/CivilizationWorldGen.js @@ -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; + } } } diff --git a/src/games/civilization/sprites.md b/src/games/civilization/sprites.md index e5768d4..f70b267 100644 --- a/src/games/civilization/sprites.md +++ b/src/games/civilization/sprites.md @@ -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 = 0–32), 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, 52–55 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 (10–15). | | | |---|---| -| **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. diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 8a89ff8..53a00f9 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -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}`); } // ---------------------------------------------------------------------------