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:
parent
b4155b096a
commit
237a024f80
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
Binary file not shown.
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue