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:
parent
6fd4510b8b
commit
2880eeab2a
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue