diff --git a/assets/images/civilization/civilization-cities-classic.png b/assets/images/civilization/civilization-cities-classic.png new file mode 100644 index 0000000..7065f19 Binary files /dev/null and b/assets/images/civilization/civilization-cities-classic.png differ diff --git a/assets/images/civilization/civilization-cities-classic.psd b/assets/images/civilization/civilization-cities-classic.psd new file mode 100644 index 0000000..8da3b27 Binary files /dev/null and b/assets/images/civilization/civilization-cities-classic.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index a73940e..9bdf4fc 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -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 } } } diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 19d7b67..d2bd094 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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', () => { diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 0480b2f..cbcf52c 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -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 { diff --git a/src/games/civilization/CivilizationScreens.js b/src/games/civilization/CivilizationScreens.js index 16c1952..967586d 100644 --- a/src/games/civilization/CivilizationScreens.js +++ b/src/games/civilization/CivilizationScreens.js @@ -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 */ }