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
def5c9904a
commit
b4155b096a
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
|
@ -14,6 +14,6 @@
|
||||||
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
|
"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 },
|
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||||
"citySheets": {
|
"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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,19 @@ const FONT = '"Julius Sans One"';
|
||||||
const SAVE_KEY = 'civilization-save';
|
const SAVE_KEY = 'civilization-save';
|
||||||
const D = { hud: 30, modal: 60, toast: 80 };
|
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 {
|
export default class CivilizationGame extends Phaser.Scene {
|
||||||
constructor() { super('CivilizationGame'); }
|
constructor() { super('CivilizationGame'); }
|
||||||
|
|
||||||
|
|
@ -51,6 +64,9 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.clickGuard = false;
|
this.clickGuard = false;
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.endTurnFlashTween = null;
|
this.endTurnFlashTween = null;
|
||||||
|
this.logEntries = [];
|
||||||
|
this.logScrollY = 0;
|
||||||
|
this.statusQueue = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
@ -106,6 +122,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
// is simply skipped when missing rather than needing its own else-branch.
|
// is simply skipped when missing rather than needing its own else-branch.
|
||||||
if (this.textures.exists('civilization-setup-bg')) {
|
if (this.textures.exists('civilization-setup-bg')) {
|
||||||
root.add(this.add.image(cx, GAME_HEIGHT / 2, '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', {
|
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);
|
{ fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||||
this.hudRoot.add(this.hintText);
|
this.hudRoot.add(this.hintText);
|
||||||
this.refreshHud();
|
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() {
|
refreshHud() {
|
||||||
|
|
@ -421,6 +553,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
this.input.on('pointerdown', (pointer) => {
|
this.input.on('pointerdown', (pointer) => {
|
||||||
if (this.modalOpen || this.phase !== 'playing') return;
|
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 };
|
dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y };
|
||||||
dragged = false;
|
dragged = false;
|
||||||
});
|
});
|
||||||
|
|
@ -450,6 +583,13 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
||||||
if (this.modalOpen || this.phase !== 'playing') return;
|
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);
|
this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +637,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const path = Logic.findPath(this.rules, this.state, unit, c, r);
|
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.view.showPath(path);
|
||||||
this.walkPath(unit, 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 curIdx = units.findIndex((u) => u.id === this.selectedUnitId);
|
||||||
const next = units[(curIdx + 1) % units.length];
|
const next = units[(curIdx + 1) % units.length];
|
||||||
this.selectUnit(next);
|
this.selectUnit(next);
|
||||||
this.view.centerOn(next.x, next.y);
|
this.view.panToTile(next.x, next.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
tryStep(unit, dx, dy, animate = false) {
|
tryStep(unit, dx, dy, animate = false) {
|
||||||
|
|
@ -715,7 +855,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
const def = this.rules.units[unit.type];
|
const def = this.rules.units[unit.type];
|
||||||
if (!def.flags.includes('settler')) return;
|
if (!def.flags.includes('settler')) return;
|
||||||
if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (this.modalOpen) return;
|
if (this.modalOpen) return;
|
||||||
|
|
@ -723,7 +863,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
this.promptCityName(defaultName, (name) => {
|
this.promptCityName(defaultName, (name) => {
|
||||||
const city = Logic.foundCity(this.rules, this.state, unit, name);
|
const city = Logic.foundCity(this.rules, this.state, unit, name);
|
||||||
if (city) {
|
if (city) {
|
||||||
this.toast(`${city.name} founded!`);
|
this.announceStatus(`${city.name} founded!`);
|
||||||
this.view.repaintTileAndNeighbors(city.x, city.y);
|
this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||||
this.afterAction();
|
this.afterAction();
|
||||||
this.selectNextUnit();
|
this.selectNextUnit();
|
||||||
|
|
@ -771,20 +911,20 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
|
|
||||||
tryWork(unit, impId) {
|
tryWork(unit, impId) {
|
||||||
if (Logic.startWork(this.rules, this.state, 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.afterAction();
|
||||||
this.selectNextUnit();
|
this.selectNextUnit();
|
||||||
} else {
|
} 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) {
|
tryCaravan(unit) {
|
||||||
const pair = Logic.canEstablishRoute(this.rules, this.state, 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);
|
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
|
||||||
if (out) {
|
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.afterAction();
|
||||||
this.selectNextUnit();
|
this.selectNextUnit();
|
||||||
}
|
}
|
||||||
|
|
@ -1010,18 +1150,19 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
if (e.announced) continue;
|
if (e.announced) continue;
|
||||||
e.announced = true;
|
e.announced = true;
|
||||||
if (e.type === 'war' && e.b === human) {
|
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) {
|
} 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)) {
|
} 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') {
|
} 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') {
|
} 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)) {
|
} else if (e.type === 'contact' && (e.a === human || e.b === human)) {
|
||||||
const other = e.a === human ? e.b : e.a;
|
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));
|
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human));
|
||||||
const next = () => {
|
const next = () => {
|
||||||
const p = proposals.shift();
|
const p = proposals.shift();
|
||||||
if (!p) return;
|
if (!p) { this.showNextStatus(); return; }
|
||||||
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
|
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
|
||||||
const from = this.state.civs[p.from];
|
const from = this.state.civs[p.from];
|
||||||
this.confirmDialog(
|
this.confirmDialog(
|
||||||
|
|
@ -1052,7 +1193,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
||||||
ambushLost: 'Hostile tribe! Your unit was lost!',
|
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) {
|
toast(msg) {
|
||||||
|
|
@ -1108,14 +1249,15 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openDiplomacy() {
|
openDiplomacy({ focusCivId = null, playIntro = false } = {}) {
|
||||||
if (this.modalOpen) return;
|
if (this.modalOpen) return;
|
||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
|
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
|
||||||
this.modalOpen = false;
|
this.modalOpen = false;
|
||||||
this.view.refresh();
|
this.view.refresh();
|
||||||
this.refreshHud();
|
this.refreshHud();
|
||||||
});
|
this.showNextStatus();
|
||||||
|
}, focusCivId, playIntro);
|
||||||
}
|
}
|
||||||
|
|
||||||
openSpaceship() {
|
openSpaceship() {
|
||||||
|
|
@ -1144,7 +1286,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
||||||
{ width: 320, height: 56 });
|
{ width: 320, height: 56 });
|
||||||
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
|
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
|
||||||
this.saveGame();
|
this.saveGame();
|
||||||
this.toast('Game saved');
|
this.logMessage('Game saved');
|
||||||
close();
|
close();
|
||||||
}, { width: 320, height: 56, variant: 'ghost' });
|
}, { width: 320, height: 56, variant: 'ghost' });
|
||||||
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {
|
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
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];
|
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
|
||||||
|
|
||||||
export class CivilizationMapView {
|
export class CivilizationMapView {
|
||||||
|
|
@ -163,17 +171,25 @@ export class CivilizationMapView {
|
||||||
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
|
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
|
||||||
|
|
||||||
panBy(dx, dy) {
|
panBy(dx, dy) {
|
||||||
|
this.panTween?.stop();
|
||||||
this.root.x += dx;
|
this.root.x += dx;
|
||||||
this.root.y += dy;
|
this.root.y += dy;
|
||||||
this.clampPan();
|
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 s = this.root.scaleX;
|
||||||
const minX = GAME_WIDTH - this.worldW * s - 100;
|
const minX = GAME_WIDTH - this.worldW * s - 100;
|
||||||
const minY = GAME_HEIGHT - this.worldH * s - 100;
|
const minY = GAME_HEIGHT - this.worldH * s - 100;
|
||||||
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100);
|
return { minX: Math.min(100, minX), maxX: 100, minY: Math.min(100, minY), maxY: 100 };
|
||||||
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 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) {
|
centerOn(c, r) {
|
||||||
|
|
@ -183,6 +199,23 @@ export class CivilizationMapView {
|
||||||
this.clampPan();
|
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
|
// Terrain baking
|
||||||
|
|
||||||
|
|
@ -199,11 +232,20 @@ export class CivilizationMapView {
|
||||||
}
|
}
|
||||||
|
|
||||||
repaintTileAndNeighbors(c, r) {
|
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 dy = -1; dy <= 1; dy += 1) {
|
||||||
for (let dx = -1; dx <= 1; dx += 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) {
|
paintTile(c, r) {
|
||||||
|
|
@ -567,7 +609,7 @@ export class CivilizationMapView {
|
||||||
// read as standing on the tile instead of at its front edge.
|
// read as standing on the tile instead of at its front edge.
|
||||||
const spriteMode = scene.textures.exists('civilization-units');
|
const spriteMode = scene.textures.exists('civilization-units');
|
||||||
if (spriteMode) {
|
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);
|
const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
|
||||||
container.add([ring, img]);
|
container.add([ring, img]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -621,7 +663,7 @@ export class CivilizationMapView {
|
||||||
if (!unit) { this.selectedUnitId = null; return; }
|
if (!unit) { this.selectedUnitId = null; return; }
|
||||||
const x = this.isoX(unit.x, unit.y);
|
const x = this.isoX(unit.x, unit.y);
|
||||||
const y = this.isoY(unit.x, unit.y) + TILE_H / 2;
|
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
|
// 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
|
// renders in front of its own pulsing selection ring instead of the ring
|
||||||
// sitting on top of it.
|
// sitting on top of it.
|
||||||
|
|
@ -722,7 +764,7 @@ export class CivilizationMapView {
|
||||||
let img = null;
|
let img = null;
|
||||||
let roundel = null;
|
let roundel = null;
|
||||||
if (spriteMode) {
|
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);
|
img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
|
||||||
container.add([ring, img]);
|
container.add([ring, img]);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,8 @@ export function openTechScreen(scene, rules, state, onClose) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Diplomacy
|
// 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 human = state.humanIndex;
|
||||||
const civ = state.civs[human];
|
const civ = state.civs[human];
|
||||||
let portrait = null;
|
let portrait = null;
|
||||||
|
|
@ -152,7 +153,11 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
|
||||||
|
|
||||||
let detail = scene.add.container(0, 0);
|
let detail = scene.add.container(0, 0);
|
||||||
root.add(detail);
|
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.
|
// Left rail: contacted civs.
|
||||||
contacts.forEach((other, i) => {
|
contacts.forEach((other, i) => {
|
||||||
|
|
@ -190,8 +195,10 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
|
||||||
// Video portrait with the character's current mood.
|
// Video portrait with the character's current mood.
|
||||||
const opData = opponentsData.find((o) => o.id === other.leaderId)
|
const opData = opponentsData.find((o) => o.id === other.leaderId)
|
||||||
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
|
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
|
||||||
|
const shouldPlayIntro = introPending && other.id === focusCivId;
|
||||||
|
introPending = false;
|
||||||
try {
|
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);
|
if (mood !== 'idle') portrait.playEmotion(mood);
|
||||||
} catch (_) { /* portrait optional */ }
|
} catch (_) { /* portrait optional */ }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue