Compare commits
2 Commits
b4155b096a
...
2a08df9cd8
| Author | SHA1 | Date |
|---|---|---|
|
|
2a08df9cd8 | |
|
|
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 };
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ export class CivilizationMapView {
|
|||
if (bits & (IMP.ROAD | IMP.RAILROAD)) {
|
||||
g.clear();
|
||||
const rail = !!(bits & IMP.RAILROAD);
|
||||
let drewAny = false;
|
||||
const segments = [];
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
|
|
@ -355,21 +355,24 @@ export class CivilizationMapView {
|
|||
if (!(nBits & (IMP.ROAD | IMP.RAILROAD))) continue;
|
||||
const ex = (this.isoX(nc, nr) - this.isoX(c, r)) / 2;
|
||||
const ey = (this.isoY(nc, nr) - this.isoY(c, r)) / 2;
|
||||
g.lineStyle(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 0.9);
|
||||
g.beginPath();
|
||||
g.moveTo(0, cy);
|
||||
g.lineTo(ex, cy + ey);
|
||||
g.strokePath();
|
||||
drewAny = true;
|
||||
segments.push([ex, cy + ey]);
|
||||
}
|
||||
}
|
||||
if (!drewAny && !hasCity) {
|
||||
g.lineStyle(3, 0x8a6f4d, 0.9);
|
||||
g.beginPath();
|
||||
g.moveTo(-14, cy);
|
||||
g.lineTo(14, cy);
|
||||
g.strokePath();
|
||||
}
|
||||
if (!segments.length && !hasCity) segments.push([-14, cy], [14, cy]);
|
||||
// A dark outline pass under the road/rail colour itself makes the line
|
||||
// read clearly against any terrain, instead of blending in on lighter
|
||||
// ground like desert/plains.
|
||||
const strokeAll = (width, color, alpha) => {
|
||||
g.lineStyle(width, color, alpha);
|
||||
for (const [ex, ey] of segments) {
|
||||
g.beginPath();
|
||||
g.moveTo(0, cy);
|
||||
g.lineTo(ex, ey);
|
||||
g.strokePath();
|
||||
}
|
||||
};
|
||||
strokeAll(rail ? 7 : 6, 0x000000, 0.4);
|
||||
strokeAll(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 1);
|
||||
this.drawStampAt(g, x, y);
|
||||
}
|
||||
|
||||
|
|
@ -549,7 +552,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 +789,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.
|
||||
|
|
|
|||
|
|
@ -340,6 +340,11 @@ export function updateAI(ai, state, dtMs) {
|
|||
&& !(enemy.fx.cloaked && dist > 900)) {
|
||||
input.fire = true;
|
||||
}
|
||||
// Kohr-Ah blades are one-shot-per-press: the ship must let go of the
|
||||
// button before it can launch another, so tap it instead of holding it.
|
||||
if (ship.def.primary.type === 'guidedBlades' && ship.fx.bladeFireHeld) {
|
||||
input.fire = false;
|
||||
}
|
||||
// Charge weapons (Melnorme pump-up): keep pumping while far or unaimed,
|
||||
// release once the shot is charged and the nose is on target.
|
||||
if (ship.fx.pump) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const FACING_STEP = (Math.PI * 2) / FACINGS;
|
|||
export const TUNE = {
|
||||
MAX_FRAMES_PER_STEP: 6, // spiral-of-death cap after tab suspend
|
||||
PLANET_RADIUS: 220, // world units (UQM planet ~29 display px)
|
||||
PLANET_GRAVITY: 1.6e6, // accel = G / r^2 (world units / frame^2)
|
||||
PLANET_GRAVITY: 1.1e6, // accel = G / r^2 (world units / frame^2)
|
||||
PLANET_GRAVITY_MIN_R: 420, // clamp radius so slingshots stay sane
|
||||
PLANET_CREW_DAMAGE: 3, // crew lost on planet impact
|
||||
PLANET_DAMAGE_COOLDOWN: 24, // frames between crew-damaging planet hits
|
||||
|
|
|
|||
|
|
@ -318,23 +318,56 @@ export const PRIMARIES = {
|
|||
},
|
||||
},
|
||||
|
||||
// Kohr-Ah spinning blades: launched forward, then steered toward wherever
|
||||
// the Marauder points while the button is held.
|
||||
// Kohr-Ah spinning blades: one shot per button press (the button must be
|
||||
// released before another blade can launch), launched dead ahead and left
|
||||
// flying straight while the button stays held. Releasing lets a blade
|
||||
// coast to a stop and then drift as a slow homing mine toward any enemy
|
||||
// that strays close, otherwise it just sits there as a trap. Blades never
|
||||
// expire on their own — only dying to a ship, asteroid, planet or another
|
||||
// blade — but only maxCount (default 8) may be live at once; launching
|
||||
// past that cap pops the oldest.
|
||||
guidedBlades: {
|
||||
fire(state, ship, def) {
|
||||
ship.fx.bladeFireHeld = true;
|
||||
const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0);
|
||||
spawnProjectile(state, ship, {
|
||||
const blade = spawnProjectile(state, ship, {
|
||||
type: 'blade',
|
||||
x: m.x, y: m.y, angle: m.angle,
|
||||
speed: def.speed,
|
||||
damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames,
|
||||
damage: def.damage, hp: def.hp ?? 10, life: Infinity,
|
||||
radius: def.radius ?? 30,
|
||||
data: { trackRange: (def.trackRange ?? 224) * 4, wait: 0 },
|
||||
behavior(st, p) {
|
||||
const owner = st.ships[p.owner];
|
||||
if (!owner.alive || !owner.input.fire) return;
|
||||
steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed);
|
||||
if (owner.alive && owner.input.fire) return;
|
||||
if (Math.abs(p.vx) > 1 || Math.abs(p.vy) > 1) {
|
||||
p.vx *= 0.5; p.vy *= 0.5;
|
||||
return;
|
||||
}
|
||||
p.vx = 0; p.vy = 0;
|
||||
if (p.data.wait > 0) { p.data.wait -= 1; return; }
|
||||
p.data.wait = 4;
|
||||
const enemy = enemyOf(st, owner);
|
||||
if (!enemy.alive || tdist(p.x, p.y, enemy.x, enemy.y) > p.data.trackRange) return;
|
||||
const a = bearingTo(p, enemy);
|
||||
p.vx = Math.cos(a) * 8; p.vy = Math.sin(a) * 8;
|
||||
},
|
||||
});
|
||||
const blades = (ship.fx.blades ?? []).filter((b) => b.alive);
|
||||
blades.push(blade);
|
||||
const max = def.maxCount ?? 8;
|
||||
while (blades.length > max) {
|
||||
const oldest = blades.shift();
|
||||
oldest.alive = false;
|
||||
emit(state, { t: 'shotHit', x: oldest.x, y: oldest.y, kind: 'blade' });
|
||||
}
|
||||
ship.fx.blades = blades;
|
||||
},
|
||||
canFire(state, ship) {
|
||||
return !ship.fx.bladeFireHeld;
|
||||
},
|
||||
tick(state, ship) {
|
||||
if (!ship.input.fire) ship.fx.bladeFireHeld = false;
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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