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 },
|
"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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = 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** |
|
| **Sheet size** | **512 × 672 px** |
|
||||||
| **Frame size** | **64 × 96 px** |
|
| **Frame size** | **64 × 96 px** |
|
||||||
| **Layout** | 8 columns × 7 rows = 56 frames (51 used, 52–55 reserved) |
|
| **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` |
|
| **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 (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) |
|
| **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.
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue