feat(civ): add status log panel, event announcements, smooth camera pan, and city sprites

- Add bottom-right status log panel with scrollable entries, word wrap,
  mask clipping, and mouse wheel scrolling; cap at 60 entries
- Replace toast popups with announceStatus() for major events (war, tech,
  first contact, city founded, trade routes, huts) — centered modal with
  CONTINUE button that animates into the log; supports queued popups and
  custom onDismiss callbacks (e.g. first contact opens diplomacy)
- Add smooth panToTile() tween for camera movement when selecting next unit,
  refactored clampPan into panBounds() reused by both clamp and tween
- Add classic city sprite sheet (civilization-cities-classic.png) and
  register it in artwork JSON and asset manifest
- Replace circular civ rings and selection ring with isometric ellipses
  matching the 2:1 tile ratio for better depth reading
- Paint tile neighbors in ascending c+r order during repaintTileAndNeighbors
  to prevent tall features from being overwritten by lower-sum neighbors
- Stop panTween on pointer down to avoid conflict with manual dragging
This commit is contained in:
Brian Fertig 2026-07-16 13:48:39 -06:00
parent b4155b096a
commit 237a024f80
8 changed files with 200 additions and 19 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

@ -14,6 +14,7 @@
"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": "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 }
}
}

View File

@ -9,6 +9,7 @@
"alphabet",
"pottery"
],
"citySheet": "classic",
"bio": "AI Bro sent from the future to show the superiority of the future.",
"voice": "Legion",
"convo": [
@ -50,6 +51,7 @@
"bronzeworking",
"thewheel"
],
"citySheet": "classic",
"bio": "Came for the honey, stayed for the salmon!",
"voice": "Jewel Weaver - Bear",
"convo": [
@ -87,6 +89,7 @@
"name": "Victor",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "cyberpunk",
"bio": "Patient and calculating. Moves are made using ancient technology.",
"voice": "Starfield - Vasco",
"convo": [
@ -127,6 +130,7 @@
"bronzeworking",
"masonry"
],
"citySheet": "classic",
"bio": "Wassssuppp everybody! I'm here for the party!!",
"voice": "Batman - Killer Croc",
"convo": [
@ -168,6 +172,7 @@
"warriorcode",
"thewheel"
],
"citySheet": "classic",
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
"speech": {
"intro": [
@ -195,6 +200,7 @@
"horsebackriding",
"masonry"
],
"citySheet": "classic",
"bio": "Thank you for visiting with me.",
"voice": "Betty White",
"convo": [
@ -235,6 +241,7 @@
"horsebackriding",
"pottery"
],
"citySheet": "classic",
"bio": "I play slow, I win fast!",
"voice": "SlowAltered",
"convo": [
@ -276,6 +283,7 @@
"warriorcode",
"masonry"
],
"citySheet": "classic",
"bio": "I'm here for the thrill of extreme victory!",
"voice": "Mass Effect - Commander Shepard",
"convo": [
@ -317,6 +325,7 @@
"thewheel",
"bronzeworking"
],
"citySheet": "classic",
"bio": "Come on let's play! I promise not to use my x-ray eyes.",
"voice": "Zero - Borderlands 3",
"convo": [
@ -358,6 +367,7 @@
"ceremonialburial",
"bronzeworking"
],
"citySheet": "classic",
"bio": "Having fun and playing is the name of the game for me!",
"speech": {
"intro": [
@ -385,6 +395,7 @@
"pottery",
"alphabet"
],
"citySheet": "classic",
"bio": "Well now yall know I want to play some them games!",
"voice": "Life is Strange - Jed Lucan",
"convo": [
@ -422,6 +433,7 @@
"name": "Natasha",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "classic",
"bio": "Perhaps if I win, you will spill the secrets you've been keeping!",
"voice": "Street Fighter 6 - Marisa",
"convo": [
@ -462,6 +474,7 @@
"thewheel",
"alphabet"
],
"citySheet": "classic",
"bio": "Hocus Pocus Alacazam!!!!",
"convo": [
"terry",
@ -502,6 +515,7 @@
"masonry",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "Woof! Woof!",
"speech": {
"intro": [
@ -529,6 +543,7 @@
"warriorcode",
"masonry"
],
"citySheet": "classic",
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
"voice": "Djed Spence",
"convo": [
@ -568,6 +583,7 @@
"thewheel",
"alphabet"
],
"citySheet": "classic",
"bio": "Let's take a break on a warm summer day to spend time playing games!",
"voice": "Street Fighter 6 - Crimson Viper",
"speech": {
@ -600,6 +616,7 @@
"name": "Balam",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "classic",
"bio": "Mystical Powers and a knack for games.",
"speech": {
"intro": [
@ -635,6 +652,7 @@
"masonry",
"bronzeworking"
],
"citySheet": "classic",
"bio": "I traveled WAY to far for this stupid Earth Internet.",
"speech": {
"intro": [
@ -669,6 +687,7 @@
"alphabet",
"pottery"
],
"citySheet": "classic",
"bio": "I ain't seen you 'round these parts before.",
"speech": {
"intro": [
@ -703,6 +722,7 @@
"masonry",
"alphabet"
],
"citySheet": "classic",
"bio": "Aaaaaaargh! Me be playin' these games matey!",
"voice": "pirate",
"speech": {
@ -738,6 +758,7 @@
"bronzeworking",
"horsebackriding"
],
"citySheet": "cyberpunk",
"bio": "She's here from the future to learn our games and study our culture.",
"voice": "Invincible VS - Atom Eve",
"speech": {
@ -774,6 +795,7 @@
"pottery",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "Oooooooh yeah brother!!! You wanna play with The Smasher?!",
"speech": {
"intro": [
@ -805,6 +827,7 @@
"name": "DV-8-2303",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "cyberpunk",
"bio": "Cybernetic Systems online. Activate human defeat protocol... EOF",
"speech": {
"intro": [
@ -839,6 +862,7 @@
"pottery",
"ceremonialburial"
],
"citySheet": "classic",
"bio": "I've disabled the firewall protections on this website. Now to hack the core!",
"voice": "Life is Strange - Rachael Amber",
"speech": {
@ -874,6 +898,7 @@
"masonry",
"pottery"
],
"citySheet": "classic",
"bio": "I have traveled far to play these games. Honor me and let us play!",
"speech": {
"intro": [
@ -908,6 +933,7 @@
"ceremonialburial",
"masonry"
],
"citySheet": "classic",
"bio": "I haven't had this much fun since the Mezasoic era!",
"speech": {
"intro": [
@ -942,6 +968,7 @@
"ceremonialburial",
"horsebackriding"
],
"citySheet": "classic",
"bio": "Ahoy you land lovers!",
"voice": "Defiant Calder",
"convo": [
@ -983,6 +1010,7 @@
"pottery",
"thewheel"
],
"citySheet": "classic",
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
"speech": {
"intro": [
@ -1017,6 +1045,7 @@
"pottery",
"thewheel"
],
"citySheet": "classic",
"bio": "From a different galaxy, but has the same great Earth based lifeforms taste for games!",
"speech": {
"intro": [
@ -1051,6 +1080,7 @@
"horsebackriding",
"thewheel"
],
"citySheet": "cyberpunk",
"bio": "MISSION OBJECTIVE: Victory.... all other priorities recinded.",
"speech": {
"intro": [

View File

@ -67,6 +67,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.logEntries = [];
this.logScrollY = 0;
this.statusQueue = [];
this.pendingCityAttacks = [];
}
create() {
@ -292,7 +293,9 @@ export default class CivilizationGame extends Phaser.Scene {
[rivals[i], rivals[j]] = [rivals[j], rivals[i]];
}
const leaders = [me, ...rivals.slice(0, opponents)]
.map((op) => ({ id: op.id, name: op.name, trait: op.trait, startingTechs: op.startingTechs }));
.map((op) => ({
id: op.id, name: op.name, trait: op.trait, startingTechs: op.startingTechs, citySheet: op.citySheet,
}));
const seed = (Date.now() % 1000000) + 1;
try {
this.state = Logic.createGame(this.rules, {
@ -457,16 +460,30 @@ export default class CivilizationGame extends Phaser.Scene {
// popup once this one is dismissed (e.g. first-contact opens the diplomacy
// screen next) — that callback is then responsible for resuming the queue
// itself once IT closes (see openDiplomacy()'s onClose).
announceStatus(msg, onDismiss) {
this.statusQueue.push({ msg, onDismiss });
//
// `pre`, if given, is a cinematic step (e.g. pan the camera + replay a
// combat) that runs BEFORE the text popup appears — it receives a
// `proceed` callback to call once it's done. Used for city-attack/capture
// announcements, which should show the attack playing out before the
// outcome text, rather than the text popping up first.
announceStatus(msg, onDismiss, pre) {
this.statusQueue.push({ msg, onDismiss, pre });
if (!this.modalOpen) this.showNextStatus();
}
showNextStatus() {
const item = this.statusQueue.shift();
if (!item) return;
const { msg, onDismiss } = item;
// Set modalOpen up front, even though the popup itself isn't built until
// `proceed()` runs — a `pre` cinematic (camera pan + combat replay)
// should block input exactly like the popup that follows it does.
this.modalOpen = true;
const { msg, onDismiss, pre } = item;
const proceed = () => this.showStatusPopup(msg, onDismiss);
if (pre) pre(proceed); else proceed();
}
showStatusPopup(msg, onDismiss) {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
@ -613,10 +630,22 @@ export default class CivilizationGame extends Phaser.Scene {
}
if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) {
// A unit is already active and the player clicked a DIFFERENT tile
// with one of their own units on it — ambiguous whether they meant to
// send the active unit there (joining that tile's stack) or take
// control of the unit they clicked, so ask instead of guessing.
if (sel && state.current === human) {
this.openUnitInteractionMenu(sel, myUnits, c, r);
return;
}
this.selectUnit(myUnits[0]);
return;
}
if (myUnits.length && sel && sel.x === c && sel.y === r) {
if (myUnits.length > 1 && state.current === human) {
this.openStackSwitchMenu(sel, myUnits);
return;
}
// Cycle the stack.
const i = myUnits.indexOf(sel);
this.selectUnit(myUnits[(i + 1) % myUnits.length]);
@ -981,10 +1010,40 @@ export default class CivilizationGame extends Phaser.Scene {
}
openUnitActionMenu(unit) {
this.modalOpen = true;
const def = this.rules.units[unit.type];
const actions = this.getUnitActions(unit);
this.openActionListMenu(def.name, this.getUnitActions(unit));
}
// Clicked a DIFFERENT tile that has one or more of the player's own units
// while a unit is already selected — offer to move the selected unit there
// (joining whatever's on that tile) or to switch active control to one of
// the units already there.
openUnitInteractionMenu(sel, targetUnits, c, r) {
const actions = [
{ label: 'Move', onSelect: () => this.moveTo(sel, c, r) },
...targetUnits.map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
})),
];
this.openActionListMenu(this.rules.units[targetUnits[0].type].name, actions);
}
// Clicked the SAME tile the selected unit is already stacked on — offer to
// switch active control to one of the other units there, or "Join" to
// leave the current selection as-is (they're already stacked together).
openStackSwitchMenu(sel, stackUnits) {
const actions = stackUnits.filter((u) => u.id !== sel.id).map((u) => ({
label: `Switch to ${this.rules.units[u.type].name}`,
onSelect: () => this.selectUnit(u),
}));
this.openActionListMenu('Unit Stack', actions, 'JOIN');
}
// Shared list-of-buttons modal used by openUnitActionMenu (clicking your
// own selected unit) and openUnitInteractionMenu (clicking another unit).
openActionListMenu(title, actions, cancelLabel = 'CANCEL') {
this.modalOpen = true;
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const itemH = 52;
@ -997,16 +1056,16 @@ export default class CivilizationGame extends Phaser.Scene {
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, py + ph / 2, pw, ph, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const title = this.add.text(cx, py + 34, def.name, {
const titleText = this.add.text(cx, py + 34, title, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5);
root.add([dim, panel, title]);
root.add([dim, panel, titleText]);
// This menu opens from a pointerdown on the unit's sprite, so the mouse
// button is typically still held when these buttons appear. Selected
// units are frequently screen-centered (selectNextUnit centers the view
// on them), same as this menu, so the imminent mouse-up can land right
// on a button and fire it before the player ever sees the menu. Ignore
// Callers open this from a pointerdown/click, so the mouse button is
// often still held when these buttons appear (e.g. selected units are
// frequently screen-centered — selectNextUnit centers the view on them —
// same as this menu, so the imminent mouse-up can land right on a
// button and fire it before the player ever sees the menu). Ignore
// clicks until that one release has passed.
let armed = !this.input.activePointer.isDown;
const arm = () => { armed = true; };
@ -1029,7 +1088,7 @@ export default class CivilizationGame extends Phaser.Scene {
});
const cancelY = py + 76 + actions.length * (itemH + gap) + 8;
const cancel = new Button(this, cx, cancelY + 20, 'CANCEL', () => {
const cancel = new Button(this, cx, cancelY + 20, cancelLabel, () => {
if (!armed) return;
close();
}, { width: pw - 48, height: 44, fontSize: 16, variant: 'ghost' });
@ -1063,6 +1122,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.openTech();
}
this.presentAIProposals();
this.announceCityAttacks();
this.announceEvents();
this.view.refresh();
this.refreshHud();
@ -1103,7 +1163,21 @@ export default class CivilizationGame extends Phaser.Scene {
// "resolve now, animate after" trick as the player's own moves, just
// via a before/after snapshot instead of a waypoint path.
const combatEvents = this.collectNewCombatEvents();
const visibleCombat = combatEvents.filter((e) => this.view.isTileVisible(e.ax, e.ay)
// Combats against one of the human's own cities are always deferred to
// a dedicated cinematic (camera pan + replay + outcome popup) played
// at the start of the human's next turn, rather than silently replayed
// here (possibly off-screen) like every other AI-vs-AI combat.
const human = this.state.humanIndex;
const elsewhere = [];
for (const e of combatEvents) {
const city = Logic.cityAt(this.state, e.x, e.y);
if (city && city.civ === human) {
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
} else {
elsewhere.push(e);
}
}
const visibleCombat = elsewhere.filter((e) => this.view.isTileVisible(e.ax, e.ay)
|| this.view.isTileVisible(e.x, e.y));
const combatUnitIds = new Set(combatEvents.flatMap((e) => [e.attackerId, e.defenderId]));
const moves = [];
@ -1144,6 +1218,24 @@ export default class CivilizationGame extends Phaser.Scene {
}
}
// Combats against one of the human's own cities, deferred from
// runToHumanTurn()'s per-civ stepping (see stepCiv there) so they always
// get a camera pan + replay + outcome popup instead of possibly playing
// silently off-screen alongside every other AI-vs-AI combat.
announceCityAttacks() {
const attacks = this.pendingCityAttacks;
this.pendingCityAttacks = [];
for (const atk of attacks) {
const { e, cityName, x, y } = atk;
const attackerName = this.state.civs[e.attackerCiv]?.name ?? 'An enemy';
const outcome = e.attackerWon ? `${cityName}'s defenders were defeated!` : `${cityName} held its ground!`;
this.announceStatus(`${attackerName} attacked ${cityName}! ${outcome}`, undefined, (proceed) => {
this.view.panToTile(x, y);
this.view.animateCombat([e], proceed);
});
}
}
announceEvents() {
const human = this.state.humanIndex;
for (const e of this.state.events) {
@ -1154,7 +1246,26 @@ export default class CivilizationGame extends Phaser.Scene {
} else if (e.type === 'techDone' && e.civ === human) {
this.announceStatus(`Research complete: ${this.rules.techs[e.tech].name}`);
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
this.announceStatus(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
if (e.to === human) {
this.announceStatus(`You captured ${e.name}!`);
} else {
// Losing a city gets the same camera-pan-then-replay treatment as
// a defended attack (see announceCityAttacks) — this covers the
// undefended-city case, which never generates a 'combat' event at
// all (see captureCity in CivilizationLogic.js), so this is the
// only place that ever shows anything for it.
const conqueror = this.state.civs[e.to]?.name ?? 'the enemy';
const cityRef = Logic.cityById(this.state, e.cityId);
this.announceStatus(`${e.name} has fallen to ${conqueror}!`, undefined, (proceed) => {
if (!cityRef) { proceed(); return; }
this.view.panToTile(cityRef.x, cityRef.y);
if (e.attackerType) {
this.view.animateCityFall(e.attackerCiv, e.attackerType, cityRef.x, cityRef.y, proceed);
} else {
this.time.delayedCall(500, proceed);
}
});
}
} else if (e.type === 'civEliminated') {
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
} else if (e.type === 'spaceshipLaunched') {

View File

@ -96,6 +96,7 @@ export function createGame(rules, opts) {
id: i,
leaderId: leaders[i].id,
traitId: leaders[i].trait ?? null,
citySheet: leaders[i].citySheet ?? 'classic',
name: leaders[i].name,
color: rules.playerColors[i % rules.playerColors.length],
human: i === humanIndex,
@ -1033,7 +1034,10 @@ export function captureCity(rules, state, unit, city) {
const remaining = civCities(state, oldCiv.id);
if (wasCapital && remaining.length) remaining[0].buildings.palace = true;
state.events.push({ type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx });
state.events.push({
type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx,
attackerCiv: unit.civ, attackerType: unit.type,
});
if (!remaining.length) eliminateCiv(rules, state, oldCiv.id);
checkVictory(rules, state);
return { result: 'captured', cityId: city.id };

View File

@ -549,7 +549,7 @@ export class CivilizationMapView {
const container = scene.add.container(x, y).setDepth(depthY);
const walled = !!city.buildings.citywalls;
const themeKey = 'civilization-cities-classic';
const themeKey = `civilization-cities-${civ.citySheet ?? 'classic'}`;
if (scene.textures.exists(themeKey)) {
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
const img = scene.add.image(0, TILE_H - FRAME_H + 48, themeKey, walled ? 4 + tier : tier);
@ -786,6 +786,33 @@ export class CivilizationMapView {
return { container, img, roundel, color };
}
// Undefended-city capture has no duel to replay (captureCity in
// CivilizationLogic.js never pushes a combat event) — just the attacker's
// ghost popping into the city, holding a moment, then fading, to at least
// show who took it before the outcome popup appears.
animateCityFall(civIdx, unitType, c, r, onDone) {
const ghost = this.buildCombatGhost(civIdx, unitType, c, r);
ghost.container.setScale(0.4);
ghost.container.setAlpha(0);
this.scene.tweens.add({
targets: ghost.container,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
onComplete: () => {
this.scene.time.delayedCall(500, () => {
this.scene.tweens.add({
targets: ghost.container,
alpha: 0,
duration: 300,
onComplete: () => { ghost.container.destroy(); onDone?.(); },
});
});
},
});
}
// Tints the loser bright red (setTint on sprite art; a manual red redraw
// for the procedural-roundel fallback, which has no Tint component),
// flashes it a few times, then shrinks it away and destroys it.

View File

@ -193,6 +193,10 @@ section('1b. opponents data');
const opponentsJson = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8'));
check('opponents present', Array.isArray(opponentsJson.opponents) && opponentsJson.opponents.length > 0);
let cityThemes = null;
try {
cityThemes = JSON.parse(readFileSync(join(root, 'data/civilization-artwork.json'), 'utf8')).citySheets;
} catch { /* checked separately in section 1; opponents citySheet check just skips below */ }
if (RULES) {
for (const op of opponentsJson.opponents ?? []) {
check(`opponent ${op.id} has trait`, typeof op.trait === 'string' && op.trait.length > 0);
@ -201,6 +205,10 @@ if (RULES) {
for (const techId of op.startingTechs ?? []) {
check(`opponent ${op.id} startingTechs ${techId} known tech`, !!RULES.techs[techId]);
}
if (cityThemes) {
check(`opponent ${op.id} has citySheet`, typeof op.citySheet === 'string' && op.citySheet.length > 0);
check(`opponent ${op.id} citySheet resolves`, !!cityThemes[op.citySheet], op.citySheet);
}
const traitTechs = RULES.civTraits[op.trait]?.startingTechs ?? [];
const total = new Set([...traitTechs, ...(op.startingTechs ?? [])]).size;
check(`opponent ${op.id} has 2-3 total starting techs`, total === 2 || total === 3, `${total}`);