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 12:36:53 -06:00
parent def5c9904a
commit b4155b096a
6 changed files with 221 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -14,6 +14,6 @@
"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 }
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }
}
}

View File

@ -23,6 +23,19 @@ const FONT = '"Julius Sans One"';
const SAVE_KEY = 'civilization-save';
const D = { hud: 30, modal: 60, toast: 80 };
// Status log panel — bottom-right, 30px padding from the bottom/right edges.
const LOG_PAD = 30;
const LOG_W = 360;
const LOG_H = 360;
const LOG_HEADER_H = 34;
const LOG_X = GAME_WIDTH - LOG_PAD - LOG_W;
const LOG_Y = GAME_HEIGHT - LOG_PAD - LOG_H;
const LOG_MASK_X = LOG_X + 10;
const LOG_MASK_Y = LOG_Y + LOG_HEADER_H;
const LOG_MASK_W = LOG_W - 20;
const LOG_MASK_H = LOG_H - LOG_HEADER_H - 12;
const LOG_ENTRY_CAP = 60;
export default class CivilizationGame extends Phaser.Scene {
constructor() { super('CivilizationGame'); }
@ -51,6 +64,9 @@ export default class CivilizationGame extends Phaser.Scene {
this.clickGuard = false;
this.busy = false;
this.endTurnFlashTween = null;
this.logEntries = [];
this.logScrollY = 0;
this.statusQueue = [];
}
create() {
@ -106,6 +122,7 @@ export default class CivilizationGame extends Phaser.Scene {
// is simply skipped when missing rather than needing its own else-branch.
if (this.textures.exists('civilization-setup-bg')) {
root.add(this.add.image(cx, GAME_HEIGHT / 2, 'civilization-setup-bg'));
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55));
}
root.add(this.add.text(cx, 52, 'CIVILIZATION', {
@ -375,6 +392,121 @@ export default class CivilizationGame extends Phaser.Scene {
{ fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5);
this.hudRoot.add(this.hintText);
this.refreshHud();
this.buildStatusLog();
}
// ---------------------------------------------------------------------------
// Status log (bottom-right) + queued "significant event" popups.
//
// logMessage() appends straight to the log (minor/operational feedback).
// announceStatus() queues a big centered popup with a CONTINUE button for
// significant events; clicking CONTINUE shrinks/flies the message into the
// log panel, then advances to the next queued popup (if any) — mirrors the
// shift-and-recurse queue presentAIProposals() already uses for AI dialogs.
buildStatusLog() {
const bg = this.add.graphics();
bg.fillStyle(COLORS.panel, 0.92);
bg.fillRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
bg.lineStyle(2, COLORS.accent, 0.7);
bg.strokeRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
this.hudRoot.add(bg);
const title = this.add.text(LOG_X + LOG_W / 2, LOG_Y + 18, 'STATUS LOG', {
fontFamily: 'Righteous', fontSize: '15px', color: COLORS.accentHex,
}).setOrigin(0.5);
this.hudRoot.add(title);
this.logScrollArea = this.add.container(LOG_MASK_X, LOG_MASK_Y);
this.hudRoot.add(this.logScrollArea);
const maskGfx = this.make.graphics({ add: false });
maskGfx.fillStyle(0xffffff);
maskGfx.fillRect(LOG_MASK_X, LOG_MASK_Y, LOG_MASK_W, LOG_MASK_H);
this.logScrollArea.setMask(maskGfx.createGeometryMask());
this.logPanelRect = {
x: LOG_X, y: LOG_Y, w: LOG_W, h: LOG_H,
};
}
appendLogEntry(msg) {
if (this.logEntries.length >= LOG_ENTRY_CAP) this.logEntries.shift().destroy();
const t = this.add.text(8, 0, msg, {
fontFamily: FONT, fontSize: '15px', color: COLORS.textHex,
wordWrap: { width: LOG_MASK_W - 16 }, lineSpacing: 3,
}).setOrigin(0, 0);
t.setAlpha(0);
this.logScrollArea.add(t);
this.logEntries.push(t);
this.relayoutLog();
this.tweens.add({ targets: t, alpha: 1, duration: 200 });
}
relayoutLog() {
let y = 0;
for (const e of this.logEntries) { e.setY(y); y += e.height + 8; }
this.logScrollY = Math.max(0, y - LOG_MASK_H);
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
}
logMessage(msg) {
this.appendLogEntry(msg);
}
// `onDismiss`, if given, runs instead of auto-advancing to the next queued
// 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 });
if (!this.modalOpen) this.showNextStatus();
}
showNextStatus() {
const item = this.statusQueue.shift();
if (!item) return;
const { msg, onDismiss } = item;
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, 760, 280, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const txt = this.add.text(cx, cy - 30, msg, {
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
wordWrap: { width: 680 }, align: 'center',
}).setOrigin(0.5);
const onContinue = () => {
btn.disableInteractive();
// Detach without destroying — root sits at (0,0), so txt's numeric x/y
// already equal its on-screen position; Phaser re-adds a removed child
// straight to the scene's top-level display list, so it keeps rendering
// in place (and keeps respecting setDepth) once outside the container.
root.remove(txt, false);
txt.setDepth(D.modal + 1);
const t = this.logPanelRect;
this.tweens.add({
targets: txt,
x: t.x + t.w / 2,
y: t.y + t.h - 16,
scaleX: 15 / 32,
scaleY: 15 / 32,
duration: 450,
ease: 'Cubic.easeInOut',
onComplete: () => { txt.destroy(); this.appendLogEntry(msg); },
});
this.tweens.add({
targets: [dim, panel],
alpha: 0,
duration: 380,
ease: 'Cubic.easeOut',
onComplete: () => root.destroy(true),
});
this.modalOpen = false;
if (onDismiss) onDismiss(); else this.showNextStatus();
};
const btn = new Button(this, cx, cy + 100, 'CONTINUE', onContinue, { width: 220, height: 56 });
root.add([dim, panel, txt, btn]);
}
refreshHud() {
@ -421,6 +553,7 @@ export default class CivilizationGame extends Phaser.Scene {
let dragged = false;
this.input.on('pointerdown', (pointer) => {
if (this.modalOpen || this.phase !== 'playing') return;
this.view.panTween?.stop();
dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y };
dragged = false;
});
@ -450,6 +583,13 @@ export default class CivilizationGame extends Phaser.Scene {
});
this.input.on('wheel', (pointer, objs, dx, dy) => {
if (this.modalOpen || this.phase !== 'playing') return;
const r = this.logPanelRect;
if (r && pointer.x >= r.x && pointer.x <= r.x + r.w && pointer.y >= r.y && pointer.y <= r.y + r.h) {
const contentH = this.logEntries.reduce((h, e) => h + e.height + 8, 0);
this.logScrollY = Phaser.Math.Clamp(this.logScrollY + dy * 0.5, 0, Math.max(0, contentH - LOG_MASK_H));
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
return;
}
this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y);
});
}
@ -497,7 +637,7 @@ export default class CivilizationGame extends Phaser.Scene {
return;
}
const path = Logic.findPath(this.rules, this.state, unit, c, r);
if (!path) { this.toast('No route there'); return; }
if (!path) { this.logMessage('No route there'); return; }
this.view.showPath(path);
this.walkPath(unit, path);
}
@ -617,7 +757,7 @@ export default class CivilizationGame extends Phaser.Scene {
const curIdx = units.findIndex((u) => u.id === this.selectedUnitId);
const next = units[(curIdx + 1) % units.length];
this.selectUnit(next);
this.view.centerOn(next.x, next.y);
this.view.panToTile(next.x, next.y);
}
tryStep(unit, dx, dy, animate = false) {
@ -715,7 +855,7 @@ export default class CivilizationGame extends Phaser.Scene {
const def = this.rules.units[unit.type];
if (!def.flags.includes('settler')) return;
if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) {
this.toast('Cannot found a city here');
this.logMessage('Cannot found a city here');
return;
}
if (this.modalOpen) return;
@ -723,7 +863,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.promptCityName(defaultName, (name) => {
const city = Logic.foundCity(this.rules, this.state, unit, name);
if (city) {
this.toast(`${city.name} founded!`);
this.announceStatus(`${city.name} founded!`);
this.view.repaintTileAndNeighbors(city.x, city.y);
this.afterAction();
this.selectNextUnit();
@ -771,20 +911,20 @@ export default class CivilizationGame extends Phaser.Scene {
tryWork(unit, impId) {
if (Logic.startWork(this.rules, this.state, unit, impId)) {
this.toast(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
this.logMessage(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
this.afterAction();
this.selectNextUnit();
} else {
this.toast(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
this.logMessage(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
}
}
tryCaravan(unit) {
const pair = Logic.canEstablishRoute(this.rules, this.state, unit);
if (!pair) { this.toast('Caravans need a city 8+ tiles from home'); return; }
if (!pair) { this.logMessage('Caravans need a city 8+ tiles from home'); return; }
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
if (out) {
this.toast(`Trade route: ${out.from.name}${out.to.name} (+${out.bonus} gold & beakers)`);
this.announceStatus(`Trade route: ${out.from.name}${out.to.name} (+${out.bonus} gold & beakers)`);
this.afterAction();
this.selectNextUnit();
}
@ -1010,18 +1150,19 @@ export default class CivilizationGame extends Phaser.Scene {
if (e.announced) continue;
e.announced = true;
if (e.type === 'war' && e.b === human) {
this.toast(`${this.state.civs[e.a].name} declares WAR on you!`);
this.announceStatus(`${this.state.civs[e.a].name} declares WAR on you!`);
} else if (e.type === 'techDone' && e.civ === human) {
this.toast(`Research complete: ${this.rules.techs[e.tech].name}`);
this.announceStatus(`Research complete: ${this.rules.techs[e.tech].name}`);
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
this.toast(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
this.announceStatus(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
} else if (e.type === 'civEliminated') {
this.toast(`${this.state.civs[e.civ].name} has been destroyed`);
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
} else if (e.type === 'spaceshipLaunched') {
this.toast(`${this.state.civs[e.civ].name} launched a spaceship!`);
this.announceStatus(`${this.state.civs[e.civ].name} launched a spaceship!`);
} else if (e.type === 'contact' && (e.a === human || e.b === human)) {
const other = e.a === human ? e.b : e.a;
this.toast(`You have made contact with ${this.state.civs[other].name}`);
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
() => this.openDiplomacy({ focusCivId: other, playIntro: true }));
}
}
}
@ -1032,7 +1173,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human));
const next = () => {
const p = proposals.shift();
if (!p) return;
if (!p) { this.showNextStatus(); return; }
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
const from = this.state.civs[p.from];
this.confirmDialog(
@ -1052,7 +1193,7 @@ export default class CivilizationGame extends Phaser.Scene {
ambushWon: 'Hostile tribe! Your unit fought them off.',
ambushLost: 'Hostile tribe! Your unit was lost!',
};
this.toast(msgs[hut.outcome] ?? 'An empty hut.');
this.announceStatus(msgs[hut.outcome] ?? 'An empty hut.');
}
toast(msg) {
@ -1108,14 +1249,15 @@ export default class CivilizationGame extends Phaser.Scene {
});
}
openDiplomacy() {
openDiplomacy({ focusCivId = null, playIntro = false } = {}) {
if (this.modalOpen) return;
this.modalOpen = true;
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
this.modalOpen = false;
this.view.refresh();
this.refreshHud();
});
this.showNextStatus();
}, focusCivId, playIntro);
}
openSpaceship() {
@ -1144,7 +1286,7 @@ export default class CivilizationGame extends Phaser.Scene {
{ width: 320, height: 56 });
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
this.saveGame();
this.toast('Game saved');
this.logMessage('Game saved');
close();
}, { width: 320, height: 56, variant: 'ghost' });
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {

View File

@ -22,6 +22,14 @@ 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
// Ground-marker ovals (the colored civ ring under a unit, and the white
// selection ring) share the tile's 2:1 width:height ratio so they read as
// circles laid flat on the isometric ground rather than face-on circles.
const RING_H = 44;
const RING_W = RING_H * (TILE_W / TILE_H);
const SEL_RING_H = 48;
const SEL_RING_W = SEL_RING_H * (TILE_W / TILE_H);
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
export class CivilizationMapView {
@ -163,17 +171,25 @@ export class CivilizationMapView {
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
panBy(dx, dy) {
this.panTween?.stop();
this.root.x += dx;
this.root.y += dy;
this.clampPan();
}
clampPan() {
// Shared by clampPan (mutates root.x/y in place) and panToTile (needs the
// clamped destination up front, to tween straight to it).
panBounds() {
const s = this.root.scaleX;
const minX = GAME_WIDTH - this.worldW * s - 100;
const minY = GAME_HEIGHT - this.worldH * s - 100;
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100);
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 100);
return { minX: Math.min(100, minX), maxX: 100, minY: Math.min(100, minY), maxY: 100 };
}
clampPan() {
const { minX, maxX, minY, maxY } = this.panBounds();
this.root.x = Phaser.Math.Clamp(this.root.x, minX, maxX);
this.root.y = Phaser.Math.Clamp(this.root.y, minY, maxY);
}
centerOn(c, r) {
@ -183,6 +199,23 @@ export class CivilizationMapView {
this.clampPan();
}
// Same destination as centerOn, but glides there instead of snapping —
// used when the camera advances to a different unit (e.g. selectNextUnit)
// so the move reads as a deliberate pan rather than a disorienting cut.
panToTile(c, r, duration = 450) {
const s = this.root.scaleX;
const rawX = GAME_WIDTH / 2 - this.isoX(c, r) * s;
const rawY = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s;
const { minX, maxX, minY, maxY } = this.panBounds();
const targetX = Phaser.Math.Clamp(rawX, minX, maxX);
const targetY = Phaser.Math.Clamp(rawY, minY, maxY);
this.panTween?.stop();
if (targetX === this.root.x && targetY === this.root.y) return;
this.panTween = this.scene.tweens.add({
targets: this.root, x: targetX, y: targetY, duration, ease: 'Sine.easeInOut',
});
}
// -------------------------------------------------------------------------
// Terrain baking
@ -199,11 +232,20 @@ export class CivilizationMapView {
}
repaintTileAndNeighbors(c, r) {
// Must paint back-to-front like bakeTerrain (greater c+r paints later) —
// tiles carry 32px of headroom that tall features (mountains, trees,
// city skylines) rise into, overlapping the tile "behind" them. Visiting
// dy/dx in raster order doesn't guarantee ascending c+r, so a lower-sum
// neighbor can get repainted after a higher-sum one and stomp its peak,
// which reads as nearby terrain jumping even though no tile's x/y moved.
const tiles = [];
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (inBounds(this.state.world, c + dx, r + dy)) this.paintTile(c + dx, r + dy);
if (inBounds(this.state.world, c + dx, r + dy)) tiles.push([c + dx, r + dy]);
}
}
tiles.sort(([ac, ar], [bc, br]) => (ac + ar) - (bc + br));
for (const [tc, tr] of tiles) this.paintTile(tc, tr);
}
paintTile(c, r) {
@ -567,7 +609,7 @@ export class CivilizationMapView {
// read as standing on the tile instead of at its front edge.
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.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1);
const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
container.add([ring, img]);
} else {
@ -621,7 +663,7 @@ export class CivilizationMapView {
if (!unit) { this.selectedUnitId = null; return; }
const x = this.isoX(unit.x, unit.y);
const y = this.isoY(unit.x, unit.y) + TILE_H / 2;
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
const ring = this.scene.add.ellipse(x, y - 2, SEL_RING_W, SEL_RING_H).setStrokeStyle(3, 0xffffff, 1);
// Depth below the unit's own y + 2 (see drawUnit) so the unit sprite
// renders in front of its own pulsing selection ring instead of the ring
// sitting on top of it.
@ -722,7 +764,7 @@ export class CivilizationMapView {
let img = null;
let roundel = null;
if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
const ring = scene.add.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1);
img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
container.add([ring, img]);
} else {

View File

@ -126,7 +126,8 @@ export function openTechScreen(scene, rules, state, onClose) {
// ---------------------------------------------------------------------------
// Diplomacy
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose) {
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose,
focusCivId = null, playIntroOnFocus = false) {
const human = state.humanIndex;
const civ = state.civs[human];
let portrait = null;
@ -152,7 +153,11 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
let detail = scene.add.container(0, 0);
root.add(detail);
let selected = contacts[0];
let selected = contacts.find((c) => c.id === focusCivId) ?? contacts[0];
// Only the very first draw (the auto-focused civ this screen was opened
// for) may play the intro clip — cleared immediately so re-selecting or
// returning to that same civ later never replays it.
let introPending = playIntroOnFocus;
// Left rail: contacted civs.
contacts.forEach((other, i) => {
@ -190,8 +195,10 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
// Video portrait with the character's current mood.
const opData = opponentsData.find((o) => o.id === other.leaderId)
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
const shouldPlayIntro = introPending && other.id === focusCivId;
introPending = false;
try {
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: false });
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: shouldPlayIntro });
if (mood !== 'idle') portrait.playEmotion(mood);
} catch (_) { /* portrait optional */ }