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:
Brian Fertig 2026-07-15 22:41:02 -06:00
parent 1261f7149b
commit e4ad09de67
9 changed files with 225 additions and 50 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

View File

@ -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 }

View File

@ -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();

View File

@ -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,

View File

@ -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;

View File

@ -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 = 032), 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, 5255 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 |