feat(civilization): sprite units, animated movement, city naming, mouse zoom
- Add civilization-units.png sprite sheet (64×96 frames with 32px headroom) - Animate unit movement along paths with smooth gliding after resolving logic - Add city founding name prompt modal with TextInput and Enter/Escape support - Support zoom-to-mouse-position via wheel, with extended zoom levels - Flash-pulse End Turn button when no units have remaining movement - Fix banner rendering with setSize instead of direct width assignment - Adjust badge positions for taller sprite frames - Decouple city name preview (peekCityName) from cursor consumption
This commit is contained in:
parent
1261f7149b
commit
e4ad09de67
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 171 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
|
@ -11,7 +11,7 @@
|
|||
"terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"unitSheet": { "key": "civilization-units", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
|
||||
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||
"citySheets": {
|
||||
"classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { TextInput } from '../../ui/TextInput.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||
import * as Logic from './CivilizationLogic.js';
|
||||
|
|
@ -32,6 +33,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.phase = 'setup';
|
||||
this.modalOpen = false;
|
||||
this.busy = false;
|
||||
this.endTurnFlashTween = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -382,7 +384,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
});
|
||||
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
||||
if (this.modalOpen || this.phase !== 'playing') return;
|
||||
this.view.zoomBy(dy > 0 ? -1 : 1);
|
||||
this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -425,7 +427,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
moveTo(unit, c, r) {
|
||||
if (this.state.current !== this.state.humanIndex) return;
|
||||
if (Logic.cheb(unit.x, unit.y, c, r) === 1) {
|
||||
this.tryStep(unit, c - unit.x, r - unit.y);
|
||||
this.tryStep(unit, c - unit.x, r - unit.y, true);
|
||||
return;
|
||||
}
|
||||
const path = Logic.findPath(this.rules, this.state, unit, c, r);
|
||||
|
|
@ -508,6 +510,28 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y);
|
||||
this.view.refresh();
|
||||
this.refreshUnitPanel();
|
||||
// Nothing left to move this turn — nudge the player toward End Turn.
|
||||
const noneLeft = !unit && this.phase === 'playing' && !this.busy
|
||||
&& this.state?.current === this.state?.humanIndex;
|
||||
this.setEndTurnFlash(noneLeft);
|
||||
}
|
||||
|
||||
setEndTurnFlash(on) {
|
||||
if (on === !!this.endTurnFlashTween) return;
|
||||
if (!on) {
|
||||
this.endTurnFlashTween?.stop();
|
||||
this.endTurnFlashTween = null;
|
||||
this.endTurnBtn?.setAlpha(1);
|
||||
return;
|
||||
}
|
||||
this.endTurnFlashTween = this.tweens.add({
|
||||
targets: this.endTurnBtn,
|
||||
alpha: 0.55,
|
||||
duration: 550,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
selectNextUnit() {
|
||||
|
|
@ -521,49 +545,68 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.view.centerOn(next.x, next.y);
|
||||
}
|
||||
|
||||
tryStep(unit, dx, dy) {
|
||||
tryStep(unit, dx, dy, animate = false) {
|
||||
if (this.state.current !== this.state.humanIndex) return;
|
||||
const from = [unit.x, unit.y];
|
||||
const out = unit.carriedBy
|
||||
? Logic.disembark(this.rules, this.state, unit, dx, dy)
|
||||
: Logic.tryMove(this.rules, this.state, unit, dx, dy);
|
||||
if (out.result === 'blocked' && out.needsWar !== undefined) {
|
||||
this.confirmWar(out.needsWar, () => {
|
||||
Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar);
|
||||
this.tryStep(unit, dx, dy);
|
||||
this.tryStep(unit, dx, dy, animate);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (out.result === 'invalid') return;
|
||||
if (out.hut) this.toastHut(out.hut);
|
||||
this.afterAction();
|
||||
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
|
||||
if (!this.state.units.includes(unit)) this.selectNextUnit();
|
||||
const finish = () => {
|
||||
this.afterAction();
|
||||
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
|
||||
if (!this.state.units.includes(unit)) this.selectNextUnit();
|
||||
};
|
||||
const moved = unit.x !== from[0] || unit.y !== from[1];
|
||||
if (!animate || !moved) { finish(); return; }
|
||||
this.animateMoveThen([from, [unit.x, unit.y]], finish);
|
||||
}
|
||||
|
||||
walkPath(unit, path) {
|
||||
// Step along the found path until movement runs out or something happens.
|
||||
// Resolve the whole turn's worth of movement instantly (combat, huts,
|
||||
// etc. all need the real logic), then replay the tiles actually crossed
|
||||
// as a smooth glide so the player sees continuous motion, not a snap.
|
||||
const visited = [[unit.x, unit.y]];
|
||||
let i = 0;
|
||||
const step = () => {
|
||||
if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) {
|
||||
this.view.showPath(null);
|
||||
this.afterAction();
|
||||
if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit();
|
||||
return;
|
||||
}
|
||||
let hutEvent = null;
|
||||
while (i < path.length && unit.mp > 0 && this.state.units.includes(unit) && !this.state.over) {
|
||||
const [nx, ny] = path[i];
|
||||
i += 1;
|
||||
const out = Logic.tryMove(this.rules, this.state, unit,
|
||||
Math.sign(nx - unit.x), Math.sign(ny - unit.y));
|
||||
if (out.result === 'invalid' || out.result === 'blocked') {
|
||||
this.view.showPath(null);
|
||||
this.afterAction();
|
||||
return;
|
||||
}
|
||||
if (out.hut) this.toastHut(out.hut);
|
||||
this.view.refresh();
|
||||
this.time.delayedCall(60, step);
|
||||
if (out.result === 'invalid' || out.result === 'blocked') break;
|
||||
if (out.hut) hutEvent = out.hut;
|
||||
visited.push([unit.x, unit.y]);
|
||||
}
|
||||
if (hutEvent) this.toastHut(hutEvent);
|
||||
const finish = () => {
|
||||
this.view.showPath(null);
|
||||
this.afterAction();
|
||||
if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit();
|
||||
};
|
||||
step();
|
||||
this.animateMoveThen(visited, finish);
|
||||
}
|
||||
|
||||
// Glides the selected unit's marker smoothly across `tiles`
|
||||
// ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the
|
||||
// destination, then invokes `finish`. Falls straight through to `finish`
|
||||
// when there's nothing to animate (unit didn't actually move).
|
||||
animateMoveThen(tiles, finish) {
|
||||
if (tiles.length < 2) { finish(); return; }
|
||||
this.view.refresh();
|
||||
this.busy = true;
|
||||
this.view.animateUnitAlong(tiles, 1000, 250, () => {
|
||||
this.busy = false;
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
tryFound(unit) {
|
||||
|
|
@ -573,13 +616,55 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.toast('Cannot found a city here');
|
||||
return;
|
||||
}
|
||||
const city = Logic.foundCity(this.rules, this.state, unit);
|
||||
if (city) {
|
||||
this.toast(`${city.name} founded!`);
|
||||
this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||
this.afterAction();
|
||||
this.selectNextUnit();
|
||||
}
|
||||
if (this.modalOpen) return;
|
||||
const defaultName = Logic.peekCityName(this.rules, this.state, unit.civ);
|
||||
this.promptCityName(defaultName, (name) => {
|
||||
const city = Logic.foundCity(this.rules, this.state, unit, name);
|
||||
if (city) {
|
||||
this.toast(`${city.name} founded!`);
|
||||
this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||
this.afterAction();
|
||||
this.selectNextUnit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
promptCityName(defaultName, onConfirm) {
|
||||
this.modalOpen = true;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const root = this.add.container(0, 0).setDepth(D.modal);
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
|
||||
const panel = this.add.rectangle(cx, cy, 560, 240, COLORS.panel).setStrokeStyle(2, COLORS.accent);
|
||||
const title = this.add.text(cx, cy - 84, 'Found City', {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5);
|
||||
const label = this.add.text(cx, cy - 40, 'Name this city:', {
|
||||
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
root.add([dim, panel, title, label]);
|
||||
|
||||
const input = new TextInput(this, cx, cy + 4, {
|
||||
width: 380, height: 48, value: defaultName, maxLength: 24, autocomplete: 'off',
|
||||
});
|
||||
input.focus();
|
||||
input.el.select();
|
||||
|
||||
const close = () => { input.destroy(); root.destroy(true); this.modalOpen = false; };
|
||||
const confirm = () => {
|
||||
const name = input.value.trim() || defaultName;
|
||||
close();
|
||||
onConfirm(name);
|
||||
};
|
||||
input.on('keydown', (e) => {
|
||||
if (e.key === 'Enter') confirm();
|
||||
else if (e.key === 'Escape') close();
|
||||
});
|
||||
|
||||
const found = new Button(this, cx - 110, cy + 68, 'FOUND', confirm, { width: 180, height: 52 });
|
||||
const cancel = new Button(this, cx + 110, cy + 68, 'CANCEL', close,
|
||||
{ width: 180, height: 52, variant: 'ghost' });
|
||||
root.add([found, cancel]);
|
||||
}
|
||||
|
||||
tryWork(unit, impId) {
|
||||
|
|
@ -645,6 +730,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
runToHumanTurn() {
|
||||
this.setEndTurnFlash(false);
|
||||
this.busy = true;
|
||||
this.endTurnBtn?.setAlpha(0.4);
|
||||
this.refreshUnitPanel();
|
||||
|
|
|
|||
|
|
@ -378,16 +378,28 @@ export function cityYields(rules, state, city) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Cities
|
||||
|
||||
export function nextCityName(rules, state, civ) {
|
||||
// Read-only preview of the name a new city would get, without consuming it
|
||||
// from the pool — lets UI show a default before the player confirms.
|
||||
export function peekCityName(rules, state, civ) {
|
||||
const c = state.civs[civ];
|
||||
if (civCities(state, civ).length === 0) return `${c.name} City`;
|
||||
const pool = rules.cityNames;
|
||||
const idx = c.nameOrder[c.nameCursor % pool.length];
|
||||
const round = Math.floor(c.nameCursor / pool.length);
|
||||
c.nameCursor += state.civs.length;
|
||||
return round > 0 ? `${pool[idx]} ${'I'.repeat(round + 1)}` : pool[idx];
|
||||
}
|
||||
|
||||
function advanceCityNameCursor(state, civ) {
|
||||
if (civCities(state, civ).length === 0) return; // first city never draws from the pool
|
||||
state.civs[civ].nameCursor += state.civs.length;
|
||||
}
|
||||
|
||||
export function nextCityName(rules, state, civ) {
|
||||
const name = peekCityName(rules, state, civ);
|
||||
advanceCityNameCursor(state, civ);
|
||||
return name;
|
||||
}
|
||||
|
||||
export function canFoundCity(rules, state, x, y) {
|
||||
const terr = terrainAt(rules, state.world, x, y);
|
||||
if (terr.water || terr.id === 'glacier') return false;
|
||||
|
|
@ -397,15 +409,17 @@ export function canFoundCity(rules, state, x, y) {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function foundCity(rules, state, unit) {
|
||||
export function foundCity(rules, state, unit, name) {
|
||||
if (!canFoundCity(rules, state, unit.x, unit.y)) return null;
|
||||
const civ = state.civs[unit.civ];
|
||||
const cityName = name && name.trim() ? name.trim() : peekCityName(rules, state, unit.civ);
|
||||
advanceCityNameCursor(state, unit.civ);
|
||||
const city = {
|
||||
id: state.nextCityId,
|
||||
civ: unit.civ,
|
||||
x: unit.x,
|
||||
y: unit.y,
|
||||
name: nextCityName(rules, state, unit.civ),
|
||||
name: cityName,
|
||||
size: 1,
|
||||
foodBox: 0,
|
||||
shieldBox: 0,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import {
|
|||
export const TILE_W = 128;
|
||||
export const TILE_H = 64;
|
||||
export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond
|
||||
export const UNIT_FRAME_H = 96; // unit frames get the same 32px headroom as terrain
|
||||
|
||||
const ZOOMS = [0.5, 0.75, 1.0];
|
||||
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
|
||||
|
||||
export class CivilizationMapView {
|
||||
constructor(scene, rules, state, callbacks = {}) {
|
||||
|
|
@ -140,12 +141,25 @@ export class CivilizationMapView {
|
|||
return best;
|
||||
}
|
||||
|
||||
setZoom(idx) {
|
||||
setZoom(idx, mouseX, mouseY) {
|
||||
const oldScale = this.root.scaleX;
|
||||
this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1);
|
||||
this.root.setScale(ZOOMS[this.zoomIdx]);
|
||||
const newScale = this.root.scaleX;
|
||||
|
||||
// Zoom toward mouse position if provided.
|
||||
if (mouseX != null && mouseY != null) {
|
||||
// Convert screen mouse position to world space using old scale.
|
||||
const worldX = (mouseX - this.root.x) / oldScale;
|
||||
const worldY = (mouseY - this.root.y) / oldScale;
|
||||
// Adjust root position so that world point stays under the mouse.
|
||||
this.root.x += worldX * (oldScale - newScale);
|
||||
this.root.y += worldY * (oldScale - newScale);
|
||||
}
|
||||
|
||||
this.clampPan();
|
||||
}
|
||||
zoomBy(delta) { this.setZoom(this.zoomIdx + delta); }
|
||||
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
|
||||
|
||||
panBy(dx, dy) {
|
||||
this.root.x += dx;
|
||||
|
|
@ -517,7 +531,10 @@ export class CivilizationMapView {
|
|||
const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color,
|
||||
}).setOrigin(0.5);
|
||||
banner.width = label.width + 16;
|
||||
// Direct `.width =` leaves the cached display origin stale (it's only
|
||||
// recomputed by setSize/setOrigin), so the box renders left-anchored
|
||||
// instead of staying centered under the label — use setSize instead.
|
||||
banner.setSize(label.width + 16, 22);
|
||||
container.add([banner, label]);
|
||||
container.setDepth(y + 1);
|
||||
banner.setInteractive({ useHandCursor: true });
|
||||
|
|
@ -537,9 +554,13 @@ export class CivilizationMapView {
|
|||
const y = this.isoY(c, r) + TILE_H / 2;
|
||||
const container = scene.add.container(x, y);
|
||||
|
||||
if (scene.textures.exists('civilization-units')) {
|
||||
// Sprite mode: the 64x96 unit frame carries the same 32px headroom as
|
||||
// terrain, so its bottom 64px (the diamond zone) sits over the tile and
|
||||
// the top 32px rises into the tile behind it, like a tree or peak does.
|
||||
const spriteMode = scene.textures.exists('civilization-units');
|
||||
if (spriteMode) {
|
||||
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
|
||||
const img = scene.add.image(0, -6, 'civilization-units', def.frame);
|
||||
const img = scene.add.image(0, TILE_H / 2 - UNIT_FRAME_H / 2, 'civilization-units', def.frame);
|
||||
container.add([ring, img]);
|
||||
} else {
|
||||
const g = scene.add.graphics();
|
||||
|
|
@ -555,12 +576,15 @@ export class CivilizationMapView {
|
|||
}).setOrigin(0.5);
|
||||
container.add(label);
|
||||
}
|
||||
// Badges ride at shoulder height: low against the flat procedural
|
||||
// roundel, higher up against the taller sprite frame.
|
||||
const badgeY = spriteMode ? -28 : -14;
|
||||
if (unit.vet) {
|
||||
container.add(scene.add.circle(12, -14, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
|
||||
container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
|
||||
}
|
||||
if (stackCount > 1) {
|
||||
const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8);
|
||||
const num = scene.add.text(-16, -14, `${stackCount}`, {
|
||||
const badge = scene.add.circle(-16, badgeY, 8, 0x000000, 0.8);
|
||||
const num = scene.add.text(-16, badgeY, `${stackCount}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
|
||||
}).setOrigin(0.5);
|
||||
container.add([badge, num]);
|
||||
|
|
@ -571,6 +595,7 @@ export class CivilizationMapView {
|
|||
}
|
||||
|
||||
drawSelection() {
|
||||
this.selectionRing = null;
|
||||
if (!this.selectedUnitId) return;
|
||||
const unit = this.state.units.find((u) => u.id === this.selectedUnitId);
|
||||
if (!unit) { this.selectedUnitId = null; return; }
|
||||
|
|
@ -579,11 +604,51 @@ export class CivilizationMapView {
|
|||
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
|
||||
ring.setDepth(y + 3);
|
||||
this.dynamic.add(ring);
|
||||
this.selectionRing = ring;
|
||||
this.scene.tweens.add({
|
||||
targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1,
|
||||
});
|
||||
}
|
||||
|
||||
// Glides the currently-selected unit's marker (and its selection ring)
|
||||
// through `tiles` ([[c,r], ...]) — state is already at the final tile by
|
||||
// the time this runs, so we snap the marker back to the first point and
|
||||
// tween forward, splitting `duration` evenly across the hops. Pauses
|
||||
// `pauseMs` on arrival before calling `onComplete`.
|
||||
animateUnitAlong(tiles, duration, pauseMs, onComplete) {
|
||||
const container = this.selectedContainer;
|
||||
const points = tiles.map(([c, r]) => ({
|
||||
x: this.isoX(c, r), y: this.isoY(c, r) + TILE_H / 2,
|
||||
}));
|
||||
if (!container || points.length < 2) { onComplete?.(); return; }
|
||||
const ring = this.selectionRing;
|
||||
const place = (p) => {
|
||||
container.setPosition(p.x, p.y);
|
||||
container.setDepth(p.y + 2);
|
||||
if (ring) { ring.setPosition(p.x, p.y - 2); ring.setDepth(p.y + 3); }
|
||||
};
|
||||
place(points[0]);
|
||||
const segDuration = duration / (points.length - 1);
|
||||
let idx = 0;
|
||||
const nextSeg = () => {
|
||||
idx += 1;
|
||||
const target = points[idx];
|
||||
this.scene.tweens.add({
|
||||
targets: container,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
duration: segDuration,
|
||||
ease: 'Sine.easeInOut',
|
||||
onUpdate: () => place({ x: container.x, y: container.y }),
|
||||
onComplete: () => {
|
||||
if (idx < points.length - 1) nextSeg();
|
||||
else this.scene.time.delayedCall(pauseMs, () => onComplete?.());
|
||||
},
|
||||
});
|
||||
};
|
||||
nextSeg();
|
||||
}
|
||||
|
||||
showPath(path) {
|
||||
this.pathGfx.clear();
|
||||
if (!path || !path.length) return;
|
||||
|
|
|
|||
|
|
@ -136,14 +136,24 @@ vector-drawn (they connect dynamically between tiles), so they are NOT here.
|
|||
## 4. Units sheet — `civilization-units.png`
|
||||
|
||||
Unit art is **neutral** (no civ color baked in): the game draws a player-color
|
||||
ring/roundel underneath your frame. Center the unit in the frame with its feet
|
||||
around y = 52 so it sits on the tile.
|
||||
ring/roundel underneath your frame. Frames use the classic pronounced look —
|
||||
taller than the tile, so the figure stands up off the diamond instead of
|
||||
being squashed flat into it.
|
||||
|
||||
The frame carries the same **32px headroom** convention as the terrain sheet:
|
||||
the bottom 64px is the diamond zone (matches the 128×64 tile footprint,
|
||||
just narrower), the top 32px is headroom the figure's head/torso rises into,
|
||||
overlapping the tile behind it exactly the way a tree or mountain peak does
|
||||
on the terrain sheet. Plant the feet around **y = 84** (within the diamond
|
||||
zone, forward of center toward the tile's front tip) and let the body extend
|
||||
upward from there — most of a standing unit's height should land in the
|
||||
headroom band (y = 0–32), not the diamond zone.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/civilization/civilization-units.png` |
|
||||
| **Sheet size** | **512 × 448 px** |
|
||||
| **Frame size** | **64 × 64 px** |
|
||||
| **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 |
|
||||
| **JSON** | `unitSheet` |
|
||||
|
|
@ -269,7 +279,7 @@ city. The menu shows a generic fallback until painted.
|
|||
|---|---|---|---|
|
||||
| 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade |
|
||||
| 2 | `civilization-cities-classic.png` | 512×192 | **High** |
|
||||
| 3 | `civilization-units.png` | 512×448 | **High** (51 frames — biggest lift) |
|
||||
| 3 | `civilization-units.png` | 512×672 | **High** (51 frames — biggest lift) |
|
||||
| 4 | `civilization-resources.png` | 320×256 | Medium |
|
||||
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
||||
| 6 | `civilization-icons.png` | 480×96 | Low |
|
||||
|
|
|
|||
Loading…
Reference in New Issue