diff --git a/assets/images/civilization/civilization-cities-cyberpunk.png b/assets/images/civilization/civilization-cities-cyberpunk.png new file mode 100644 index 0000000..e230cee Binary files /dev/null and b/assets/images/civilization/civilization-cities-cyberpunk.png differ diff --git a/assets/images/civilization/civilization-cities-cyberpunk.psd b/assets/images/civilization/civilization-cities-cyberpunk.psd new file mode 100644 index 0000000..585b62f Binary files /dev/null and b/assets/images/civilization/civilization-cities-cyberpunk.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index 9bdf4fc..66ba714 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -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 } } } diff --git a/data/opponents.json b/data/opponents.json index 8762e0a..18a7f9f 100644 --- a/data/opponents.json +++ b/data/opponents.json @@ -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": [ diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index d2bd094..8fe6740 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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') { diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 9e994c7..bb297a2 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -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 }; diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index cbcf52c..f972211 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -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. diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index f877c1e..37b6c77 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -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}`);