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 },
|
"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 },
|
"resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||||
"improvementSheet": { "key": "civilization-improvements", "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 },
|
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||||
"citySheets": {
|
"citySheets": {
|
||||||
"classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 }
|
"classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { TextInput } from '../../ui/TextInput.js';
|
||||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||||
import * as Logic from './CivilizationLogic.js';
|
import * as Logic from './CivilizationLogic.js';
|
||||||
|
|
@ -32,6 +33,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.phase = 'setup';
|
this.phase = 'setup';
|
||||||
this.modalOpen = false;
|
this.modalOpen = false;
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
|
this.endTurnFlashTween = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -382,7 +384,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
||||||
if (this.modalOpen || this.phase !== 'playing') return;
|
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) {
|
moveTo(unit, c, r) {
|
||||||
if (this.state.current !== this.state.humanIndex) return;
|
if (this.state.current !== this.state.humanIndex) return;
|
||||||
if (Logic.cheb(unit.x, unit.y, c, r) === 1) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const path = Logic.findPath(this.rules, this.state, unit, c, r);
|
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);
|
if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y);
|
||||||
this.view.refresh();
|
this.view.refresh();
|
||||||
this.refreshUnitPanel();
|
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() {
|
selectNextUnit() {
|
||||||
|
|
@ -521,49 +545,68 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.view.centerOn(next.x, next.y);
|
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;
|
if (this.state.current !== this.state.humanIndex) return;
|
||||||
|
const from = [unit.x, unit.y];
|
||||||
const out = unit.carriedBy
|
const out = unit.carriedBy
|
||||||
? Logic.disembark(this.rules, this.state, unit, dx, dy)
|
? Logic.disembark(this.rules, this.state, unit, dx, dy)
|
||||||
: Logic.tryMove(this.rules, this.state, unit, dx, dy);
|
: Logic.tryMove(this.rules, this.state, unit, dx, dy);
|
||||||
if (out.result === 'blocked' && out.needsWar !== undefined) {
|
if (out.result === 'blocked' && out.needsWar !== undefined) {
|
||||||
this.confirmWar(out.needsWar, () => {
|
this.confirmWar(out.needsWar, () => {
|
||||||
Logic.declareWar(this.rules, this.state, this.state.humanIndex, 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;
|
return;
|
||||||
}
|
}
|
||||||
if (out.result === 'invalid') return;
|
if (out.result === 'invalid') return;
|
||||||
if (out.hut) this.toastHut(out.hut);
|
if (out.hut) this.toastHut(out.hut);
|
||||||
this.afterAction();
|
const finish = () => {
|
||||||
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
|
this.afterAction();
|
||||||
if (!this.state.units.includes(unit)) this.selectNextUnit();
|
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) {
|
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;
|
let i = 0;
|
||||||
const step = () => {
|
let hutEvent = null;
|
||||||
if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) {
|
while (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;
|
|
||||||
}
|
|
||||||
const [nx, ny] = path[i];
|
const [nx, ny] = path[i];
|
||||||
i += 1;
|
i += 1;
|
||||||
const out = Logic.tryMove(this.rules, this.state, unit,
|
const out = Logic.tryMove(this.rules, this.state, unit,
|
||||||
Math.sign(nx - unit.x), Math.sign(ny - unit.y));
|
Math.sign(nx - unit.x), Math.sign(ny - unit.y));
|
||||||
if (out.result === 'invalid' || out.result === 'blocked') {
|
if (out.result === 'invalid' || out.result === 'blocked') break;
|
||||||
this.view.showPath(null);
|
if (out.hut) hutEvent = out.hut;
|
||||||
this.afterAction();
|
visited.push([unit.x, unit.y]);
|
||||||
return;
|
}
|
||||||
}
|
if (hutEvent) this.toastHut(hutEvent);
|
||||||
if (out.hut) this.toastHut(out.hut);
|
const finish = () => {
|
||||||
this.view.refresh();
|
this.view.showPath(null);
|
||||||
this.time.delayedCall(60, step);
|
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) {
|
tryFound(unit) {
|
||||||
|
|
@ -573,13 +616,55 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.toast('Cannot found a city here');
|
this.toast('Cannot found a city here');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const city = Logic.foundCity(this.rules, this.state, unit);
|
if (this.modalOpen) return;
|
||||||
if (city) {
|
const defaultName = Logic.peekCityName(this.rules, this.state, unit.civ);
|
||||||
this.toast(`${city.name} founded!`);
|
this.promptCityName(defaultName, (name) => {
|
||||||
this.view.repaintTileAndNeighbors(city.x, city.y);
|
const city = Logic.foundCity(this.rules, this.state, unit, name);
|
||||||
this.afterAction();
|
if (city) {
|
||||||
this.selectNextUnit();
|
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) {
|
tryWork(unit, impId) {
|
||||||
|
|
@ -645,6 +730,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
runToHumanTurn() {
|
runToHumanTurn() {
|
||||||
|
this.setEndTurnFlash(false);
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.endTurnBtn?.setAlpha(0.4);
|
this.endTurnBtn?.setAlpha(0.4);
|
||||||
this.refreshUnitPanel();
|
this.refreshUnitPanel();
|
||||||
|
|
|
||||||
|
|
@ -378,16 +378,28 @@ export function cityYields(rules, state, city) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cities
|
// 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];
|
const c = state.civs[civ];
|
||||||
if (civCities(state, civ).length === 0) return `${c.name} City`;
|
if (civCities(state, civ).length === 0) return `${c.name} City`;
|
||||||
const pool = rules.cityNames;
|
const pool = rules.cityNames;
|
||||||
const idx = c.nameOrder[c.nameCursor % pool.length];
|
const idx = c.nameOrder[c.nameCursor % pool.length];
|
||||||
const round = Math.floor(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];
|
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) {
|
export function canFoundCity(rules, state, x, y) {
|
||||||
const terr = terrainAt(rules, state.world, x, y);
|
const terr = terrainAt(rules, state.world, x, y);
|
||||||
if (terr.water || terr.id === 'glacier') return false;
|
if (terr.water || terr.id === 'glacier') return false;
|
||||||
|
|
@ -397,15 +409,17 @@ export function canFoundCity(rules, state, x, y) {
|
||||||
return true;
|
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;
|
if (!canFoundCity(rules, state, unit.x, unit.y)) return null;
|
||||||
const civ = state.civs[unit.civ];
|
const civ = state.civs[unit.civ];
|
||||||
|
const cityName = name && name.trim() ? name.trim() : peekCityName(rules, state, unit.civ);
|
||||||
|
advanceCityNameCursor(state, unit.civ);
|
||||||
const city = {
|
const city = {
|
||||||
id: state.nextCityId,
|
id: state.nextCityId,
|
||||||
civ: unit.civ,
|
civ: unit.civ,
|
||||||
x: unit.x,
|
x: unit.x,
|
||||||
y: unit.y,
|
y: unit.y,
|
||||||
name: nextCityName(rules, state, unit.civ),
|
name: cityName,
|
||||||
size: 1,
|
size: 1,
|
||||||
foodBox: 0,
|
foodBox: 0,
|
||||||
shieldBox: 0,
|
shieldBox: 0,
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ import {
|
||||||
export const TILE_W = 128;
|
export const TILE_W = 128;
|
||||||
export const TILE_H = 64;
|
export const TILE_H = 64;
|
||||||
export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond
|
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 {
|
export class CivilizationMapView {
|
||||||
constructor(scene, rules, state, callbacks = {}) {
|
constructor(scene, rules, state, callbacks = {}) {
|
||||||
|
|
@ -140,12 +141,25 @@ export class CivilizationMapView {
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoom(idx) {
|
setZoom(idx, mouseX, mouseY) {
|
||||||
|
const oldScale = this.root.scaleX;
|
||||||
this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1);
|
this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1);
|
||||||
this.root.setScale(ZOOMS[this.zoomIdx]);
|
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();
|
this.clampPan();
|
||||||
}
|
}
|
||||||
zoomBy(delta) { this.setZoom(this.zoomIdx + delta); }
|
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
|
||||||
|
|
||||||
panBy(dx, dy) {
|
panBy(dx, dy) {
|
||||||
this.root.x += dx;
|
this.root.x += dx;
|
||||||
|
|
@ -517,7 +531,10 @@ export class CivilizationMapView {
|
||||||
const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, {
|
const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color,
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color,
|
||||||
}).setOrigin(0.5);
|
}).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.add([banner, label]);
|
||||||
container.setDepth(y + 1);
|
container.setDepth(y + 1);
|
||||||
banner.setInteractive({ useHandCursor: true });
|
banner.setInteractive({ useHandCursor: true });
|
||||||
|
|
@ -537,9 +554,13 @@ export class CivilizationMapView {
|
||||||
const y = this.isoY(c, r) + TILE_H / 2;
|
const y = this.isoY(c, r) + TILE_H / 2;
|
||||||
const container = scene.add.container(x, y);
|
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 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]);
|
container.add([ring, img]);
|
||||||
} else {
|
} else {
|
||||||
const g = scene.add.graphics();
|
const g = scene.add.graphics();
|
||||||
|
|
@ -555,12 +576,15 @@ export class CivilizationMapView {
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
container.add(label);
|
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) {
|
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) {
|
if (stackCount > 1) {
|
||||||
const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8);
|
const badge = scene.add.circle(-16, badgeY, 8, 0x000000, 0.8);
|
||||||
const num = scene.add.text(-16, -14, `${stackCount}`, {
|
const num = scene.add.text(-16, badgeY, `${stackCount}`, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
|
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
container.add([badge, num]);
|
container.add([badge, num]);
|
||||||
|
|
@ -571,6 +595,7 @@ export class CivilizationMapView {
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSelection() {
|
drawSelection() {
|
||||||
|
this.selectionRing = null;
|
||||||
if (!this.selectedUnitId) return;
|
if (!this.selectedUnitId) return;
|
||||||
const unit = this.state.units.find((u) => u.id === this.selectedUnitId);
|
const unit = this.state.units.find((u) => u.id === this.selectedUnitId);
|
||||||
if (!unit) { this.selectedUnitId = null; return; }
|
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);
|
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
|
||||||
ring.setDepth(y + 3);
|
ring.setDepth(y + 3);
|
||||||
this.dynamic.add(ring);
|
this.dynamic.add(ring);
|
||||||
|
this.selectionRing = ring;
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1,
|
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) {
|
showPath(path) {
|
||||||
this.pathGfx.clear();
|
this.pathGfx.clear();
|
||||||
if (!path || !path.length) return;
|
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`
|
## 4. Units sheet — `civilization-units.png`
|
||||||
|
|
||||||
Unit art is **neutral** (no civ color baked in): the game draws a player-color
|
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
|
ring/roundel underneath your frame. Frames use the classic pronounced look —
|
||||||
around y = 52 so it sits on the tile.
|
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` |
|
| **Path** | `public/assets/images/civilization/civilization-units.png` |
|
||||||
| **Sheet size** | **512 × 448 px** |
|
| **Sheet size** | **512 × 672 px** |
|
||||||
| **Frame size** | **64 × 64 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** | ❌ not created — roundel + 2-letter label renders meanwhile |
|
||||||
| **JSON** | `unitSheet` |
|
| **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 |
|
| 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade |
|
||||||
| 2 | `civilization-cities-classic.png` | 512×192 | **High** |
|
| 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 |
|
| 4 | `civilization-resources.png` | 320×256 | Medium |
|
||||||
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
||||||
| 6 | `civilization-icons.png` | 480×96 | Low |
|
| 6 | `civilization-icons.png` | 480×96 | Low |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue