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 },
|
||||
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||
"citySheets": {
|
||||
"classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 }
|
||||
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ const FONT = '"Julius Sans One"';
|
|||
const SAVE_KEY = 'civilization-save';
|
||||
const D = { hud: 30, modal: 60, toast: 80 };
|
||||
|
||||
// Status log panel — bottom-right, 30px padding from the bottom/right edges.
|
||||
const LOG_PAD = 30;
|
||||
const LOG_W = 360;
|
||||
const LOG_H = 360;
|
||||
const LOG_HEADER_H = 34;
|
||||
const LOG_X = GAME_WIDTH - LOG_PAD - LOG_W;
|
||||
const LOG_Y = GAME_HEIGHT - LOG_PAD - LOG_H;
|
||||
const LOG_MASK_X = LOG_X + 10;
|
||||
const LOG_MASK_Y = LOG_Y + LOG_HEADER_H;
|
||||
const LOG_MASK_W = LOG_W - 20;
|
||||
const LOG_MASK_H = LOG_H - LOG_HEADER_H - 12;
|
||||
const LOG_ENTRY_CAP = 60;
|
||||
|
||||
export default class CivilizationGame extends Phaser.Scene {
|
||||
constructor() { super('CivilizationGame'); }
|
||||
|
||||
|
|
@ -51,6 +64,9 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.clickGuard = false;
|
||||
this.busy = false;
|
||||
this.endTurnFlashTween = null;
|
||||
this.logEntries = [];
|
||||
this.logScrollY = 0;
|
||||
this.statusQueue = [];
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -106,6 +122,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
// is simply skipped when missing rather than needing its own else-branch.
|
||||
if (this.textures.exists('civilization-setup-bg')) {
|
||||
root.add(this.add.image(cx, GAME_HEIGHT / 2, 'civilization-setup-bg'));
|
||||
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55));
|
||||
}
|
||||
|
||||
root.add(this.add.text(cx, 52, 'CIVILIZATION', {
|
||||
|
|
@ -375,6 +392,121 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
{ fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||
this.hudRoot.add(this.hintText);
|
||||
this.refreshHud();
|
||||
this.buildStatusLog();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status log (bottom-right) + queued "significant event" popups.
|
||||
//
|
||||
// logMessage() appends straight to the log (minor/operational feedback).
|
||||
// announceStatus() queues a big centered popup with a CONTINUE button for
|
||||
// significant events; clicking CONTINUE shrinks/flies the message into the
|
||||
// log panel, then advances to the next queued popup (if any) — mirrors the
|
||||
// shift-and-recurse queue presentAIProposals() already uses for AI dialogs.
|
||||
|
||||
buildStatusLog() {
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(COLORS.panel, 0.92);
|
||||
bg.fillRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
|
||||
bg.lineStyle(2, COLORS.accent, 0.7);
|
||||
bg.strokeRoundedRect(LOG_X, LOG_Y, LOG_W, LOG_H, 8);
|
||||
this.hudRoot.add(bg);
|
||||
|
||||
const title = this.add.text(LOG_X + LOG_W / 2, LOG_Y + 18, 'STATUS LOG', {
|
||||
fontFamily: 'Righteous', fontSize: '15px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5);
|
||||
this.hudRoot.add(title);
|
||||
|
||||
this.logScrollArea = this.add.container(LOG_MASK_X, LOG_MASK_Y);
|
||||
this.hudRoot.add(this.logScrollArea);
|
||||
const maskGfx = this.make.graphics({ add: false });
|
||||
maskGfx.fillStyle(0xffffff);
|
||||
maskGfx.fillRect(LOG_MASK_X, LOG_MASK_Y, LOG_MASK_W, LOG_MASK_H);
|
||||
this.logScrollArea.setMask(maskGfx.createGeometryMask());
|
||||
|
||||
this.logPanelRect = {
|
||||
x: LOG_X, y: LOG_Y, w: LOG_W, h: LOG_H,
|
||||
};
|
||||
}
|
||||
|
||||
appendLogEntry(msg) {
|
||||
if (this.logEntries.length >= LOG_ENTRY_CAP) this.logEntries.shift().destroy();
|
||||
const t = this.add.text(8, 0, msg, {
|
||||
fontFamily: FONT, fontSize: '15px', color: COLORS.textHex,
|
||||
wordWrap: { width: LOG_MASK_W - 16 }, lineSpacing: 3,
|
||||
}).setOrigin(0, 0);
|
||||
t.setAlpha(0);
|
||||
this.logScrollArea.add(t);
|
||||
this.logEntries.push(t);
|
||||
this.relayoutLog();
|
||||
this.tweens.add({ targets: t, alpha: 1, duration: 200 });
|
||||
}
|
||||
|
||||
relayoutLog() {
|
||||
let y = 0;
|
||||
for (const e of this.logEntries) { e.setY(y); y += e.height + 8; }
|
||||
this.logScrollY = Math.max(0, y - LOG_MASK_H);
|
||||
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
|
||||
}
|
||||
|
||||
logMessage(msg) {
|
||||
this.appendLogEntry(msg);
|
||||
}
|
||||
|
||||
// `onDismiss`, if given, runs instead of auto-advancing to the next queued
|
||||
// popup once this one is dismissed (e.g. first-contact opens the diplomacy
|
||||
// screen next) — that callback is then responsible for resuming the queue
|
||||
// itself once IT closes (see openDiplomacy()'s onClose).
|
||||
announceStatus(msg, onDismiss) {
|
||||
this.statusQueue.push({ msg, onDismiss });
|
||||
if (!this.modalOpen) this.showNextStatus();
|
||||
}
|
||||
|
||||
showNextStatus() {
|
||||
const item = this.statusQueue.shift();
|
||||
if (!item) return;
|
||||
const { msg, onDismiss } = item;
|
||||
this.modalOpen = true;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const root = this.add.container(0, 0).setDepth(D.modal);
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
|
||||
const panel = this.add.rectangle(cx, cy, 760, 280, COLORS.panel).setStrokeStyle(2, COLORS.accent);
|
||||
const txt = this.add.text(cx, cy - 30, msg, {
|
||||
fontFamily: FONT, fontSize: '32px', color: COLORS.textHex,
|
||||
wordWrap: { width: 680 }, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
const onContinue = () => {
|
||||
btn.disableInteractive();
|
||||
// Detach without destroying — root sits at (0,0), so txt's numeric x/y
|
||||
// already equal its on-screen position; Phaser re-adds a removed child
|
||||
// straight to the scene's top-level display list, so it keeps rendering
|
||||
// in place (and keeps respecting setDepth) once outside the container.
|
||||
root.remove(txt, false);
|
||||
txt.setDepth(D.modal + 1);
|
||||
const t = this.logPanelRect;
|
||||
this.tweens.add({
|
||||
targets: txt,
|
||||
x: t.x + t.w / 2,
|
||||
y: t.y + t.h - 16,
|
||||
scaleX: 15 / 32,
|
||||
scaleY: 15 / 32,
|
||||
duration: 450,
|
||||
ease: 'Cubic.easeInOut',
|
||||
onComplete: () => { txt.destroy(); this.appendLogEntry(msg); },
|
||||
});
|
||||
this.tweens.add({
|
||||
targets: [dim, panel],
|
||||
alpha: 0,
|
||||
duration: 380,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => root.destroy(true),
|
||||
});
|
||||
this.modalOpen = false;
|
||||
if (onDismiss) onDismiss(); else this.showNextStatus();
|
||||
};
|
||||
const btn = new Button(this, cx, cy + 100, 'CONTINUE', onContinue, { width: 220, height: 56 });
|
||||
root.add([dim, panel, txt, btn]);
|
||||
}
|
||||
|
||||
refreshHud() {
|
||||
|
|
@ -421,6 +553,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
let dragged = false;
|
||||
this.input.on('pointerdown', (pointer) => {
|
||||
if (this.modalOpen || this.phase !== 'playing') return;
|
||||
this.view.panTween?.stop();
|
||||
dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y };
|
||||
dragged = false;
|
||||
});
|
||||
|
|
@ -450,6 +583,13 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
});
|
||||
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
||||
if (this.modalOpen || this.phase !== 'playing') return;
|
||||
const r = this.logPanelRect;
|
||||
if (r && pointer.x >= r.x && pointer.x <= r.x + r.w && pointer.y >= r.y && pointer.y <= r.y + r.h) {
|
||||
const contentH = this.logEntries.reduce((h, e) => h + e.height + 8, 0);
|
||||
this.logScrollY = Phaser.Math.Clamp(this.logScrollY + dy * 0.5, 0, Math.max(0, contentH - LOG_MASK_H));
|
||||
this.logScrollArea.y = LOG_MASK_Y - this.logScrollY;
|
||||
return;
|
||||
}
|
||||
this.view.zoomBy(dy > 0 ? -1 : 1, pointer.x, pointer.y);
|
||||
});
|
||||
}
|
||||
|
|
@ -497,7 +637,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
const path = Logic.findPath(this.rules, this.state, unit, c, r);
|
||||
if (!path) { this.toast('No route there'); return; }
|
||||
if (!path) { this.logMessage('No route there'); return; }
|
||||
this.view.showPath(path);
|
||||
this.walkPath(unit, path);
|
||||
}
|
||||
|
|
@ -617,7 +757,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
const curIdx = units.findIndex((u) => u.id === this.selectedUnitId);
|
||||
const next = units[(curIdx + 1) % units.length];
|
||||
this.selectUnit(next);
|
||||
this.view.centerOn(next.x, next.y);
|
||||
this.view.panToTile(next.x, next.y);
|
||||
}
|
||||
|
||||
tryStep(unit, dx, dy, animate = false) {
|
||||
|
|
@ -715,7 +855,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
const def = this.rules.units[unit.type];
|
||||
if (!def.flags.includes('settler')) return;
|
||||
if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) {
|
||||
this.toast('Cannot found a city here');
|
||||
this.logMessage('Cannot found a city here');
|
||||
return;
|
||||
}
|
||||
if (this.modalOpen) return;
|
||||
|
|
@ -723,7 +863,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.promptCityName(defaultName, (name) => {
|
||||
const city = Logic.foundCity(this.rules, this.state, unit, name);
|
||||
if (city) {
|
||||
this.toast(`${city.name} founded!`);
|
||||
this.announceStatus(`${city.name} founded!`);
|
||||
this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||
this.afterAction();
|
||||
this.selectNextUnit();
|
||||
|
|
@ -771,20 +911,20 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
|
||||
tryWork(unit, impId) {
|
||||
if (Logic.startWork(this.rules, this.state, unit, impId)) {
|
||||
this.toast(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
|
||||
this.logMessage(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
|
||||
this.afterAction();
|
||||
this.selectNextUnit();
|
||||
} else {
|
||||
this.toast(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
|
||||
this.logMessage(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
|
||||
}
|
||||
}
|
||||
|
||||
tryCaravan(unit) {
|
||||
const pair = Logic.canEstablishRoute(this.rules, this.state, unit);
|
||||
if (!pair) { this.toast('Caravans need a city 8+ tiles from home'); return; }
|
||||
if (!pair) { this.logMessage('Caravans need a city 8+ tiles from home'); return; }
|
||||
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
|
||||
if (out) {
|
||||
this.toast(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`);
|
||||
this.announceStatus(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`);
|
||||
this.afterAction();
|
||||
this.selectNextUnit();
|
||||
}
|
||||
|
|
@ -1010,18 +1150,19 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
if (e.announced) continue;
|
||||
e.announced = true;
|
||||
if (e.type === 'war' && e.b === human) {
|
||||
this.toast(`${this.state.civs[e.a].name} declares WAR on you!`);
|
||||
this.announceStatus(`${this.state.civs[e.a].name} declares WAR on you!`);
|
||||
} else if (e.type === 'techDone' && e.civ === human) {
|
||||
this.toast(`Research complete: ${this.rules.techs[e.tech].name}`);
|
||||
this.announceStatus(`Research complete: ${this.rules.techs[e.tech].name}`);
|
||||
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
|
||||
this.toast(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
|
||||
this.announceStatus(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
|
||||
} else if (e.type === 'civEliminated') {
|
||||
this.toast(`${this.state.civs[e.civ].name} has been destroyed`);
|
||||
this.announceStatus(`${this.state.civs[e.civ].name} has been destroyed`);
|
||||
} else if (e.type === 'spaceshipLaunched') {
|
||||
this.toast(`${this.state.civs[e.civ].name} launched a spaceship!`);
|
||||
this.announceStatus(`${this.state.civs[e.civ].name} launched a spaceship!`);
|
||||
} else if (e.type === 'contact' && (e.a === human || e.b === human)) {
|
||||
const other = e.a === human ? e.b : e.a;
|
||||
this.toast(`You have made contact with ${this.state.civs[other].name}`);
|
||||
this.announceStatus(`You have made contact with ${this.state.civs[other].name}`,
|
||||
() => this.openDiplomacy({ focusCivId: other, playIntro: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1032,7 +1173,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human));
|
||||
const next = () => {
|
||||
const p = proposals.shift();
|
||||
if (!p) return;
|
||||
if (!p) { this.showNextStatus(); return; }
|
||||
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
|
||||
const from = this.state.civs[p.from];
|
||||
this.confirmDialog(
|
||||
|
|
@ -1052,7 +1193,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
||||
ambushLost: 'Hostile tribe! Your unit was lost!',
|
||||
};
|
||||
this.toast(msgs[hut.outcome] ?? 'An empty hut.');
|
||||
this.announceStatus(msgs[hut.outcome] ?? 'An empty hut.');
|
||||
}
|
||||
|
||||
toast(msg) {
|
||||
|
|
@ -1108,14 +1249,15 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
openDiplomacy() {
|
||||
openDiplomacy({ focusCivId = null, playIntro = false } = {}) {
|
||||
if (this.modalOpen) return;
|
||||
this.modalOpen = true;
|
||||
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
|
||||
this.modalOpen = false;
|
||||
this.view.refresh();
|
||||
this.refreshHud();
|
||||
});
|
||||
this.showNextStatus();
|
||||
}, focusCivId, playIntro);
|
||||
}
|
||||
|
||||
openSpaceship() {
|
||||
|
|
@ -1144,7 +1286,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
{ width: 320, height: 56 });
|
||||
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
|
||||
this.saveGame();
|
||||
this.toast('Game saved');
|
||||
this.logMessage('Game saved');
|
||||
close();
|
||||
}, { width: 320, height: 56, variant: 'ghost' });
|
||||
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ export const TILE_H = 64;
|
|||
export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond
|
||||
export const UNIT_FRAME_H = 96; // unit frames get the same 32px headroom as terrain
|
||||
|
||||
// Ground-marker ovals (the colored civ ring under a unit, and the white
|
||||
// selection ring) share the tile's 2:1 width:height ratio so they read as
|
||||
// circles laid flat on the isometric ground rather than face-on circles.
|
||||
const RING_H = 44;
|
||||
const RING_W = RING_H * (TILE_W / TILE_H);
|
||||
const SEL_RING_H = 48;
|
||||
const SEL_RING_W = SEL_RING_H * (TILE_W / TILE_H);
|
||||
|
||||
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
|
||||
|
||||
export class CivilizationMapView {
|
||||
|
|
@ -163,17 +171,25 @@ export class CivilizationMapView {
|
|||
zoomBy(delta, mouseX, mouseY) { this.setZoom(this.zoomIdx + delta, mouseX, mouseY); }
|
||||
|
||||
panBy(dx, dy) {
|
||||
this.panTween?.stop();
|
||||
this.root.x += dx;
|
||||
this.root.y += dy;
|
||||
this.clampPan();
|
||||
}
|
||||
|
||||
clampPan() {
|
||||
// Shared by clampPan (mutates root.x/y in place) and panToTile (needs the
|
||||
// clamped destination up front, to tween straight to it).
|
||||
panBounds() {
|
||||
const s = this.root.scaleX;
|
||||
const minX = GAME_WIDTH - this.worldW * s - 100;
|
||||
const minY = GAME_HEIGHT - this.worldH * s - 100;
|
||||
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100);
|
||||
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 100);
|
||||
return { minX: Math.min(100, minX), maxX: 100, minY: Math.min(100, minY), maxY: 100 };
|
||||
}
|
||||
|
||||
clampPan() {
|
||||
const { minX, maxX, minY, maxY } = this.panBounds();
|
||||
this.root.x = Phaser.Math.Clamp(this.root.x, minX, maxX);
|
||||
this.root.y = Phaser.Math.Clamp(this.root.y, minY, maxY);
|
||||
}
|
||||
|
||||
centerOn(c, r) {
|
||||
|
|
@ -183,6 +199,23 @@ export class CivilizationMapView {
|
|||
this.clampPan();
|
||||
}
|
||||
|
||||
// Same destination as centerOn, but glides there instead of snapping —
|
||||
// used when the camera advances to a different unit (e.g. selectNextUnit)
|
||||
// so the move reads as a deliberate pan rather than a disorienting cut.
|
||||
panToTile(c, r, duration = 450) {
|
||||
const s = this.root.scaleX;
|
||||
const rawX = GAME_WIDTH / 2 - this.isoX(c, r) * s;
|
||||
const rawY = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s;
|
||||
const { minX, maxX, minY, maxY } = this.panBounds();
|
||||
const targetX = Phaser.Math.Clamp(rawX, minX, maxX);
|
||||
const targetY = Phaser.Math.Clamp(rawY, minY, maxY);
|
||||
this.panTween?.stop();
|
||||
if (targetX === this.root.x && targetY === this.root.y) return;
|
||||
this.panTween = this.scene.tweens.add({
|
||||
targets: this.root, x: targetX, y: targetY, duration, ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Terrain baking
|
||||
|
||||
|
|
@ -199,11 +232,20 @@ export class CivilizationMapView {
|
|||
}
|
||||
|
||||
repaintTileAndNeighbors(c, r) {
|
||||
// Must paint back-to-front like bakeTerrain (greater c+r paints later) —
|
||||
// tiles carry 32px of headroom that tall features (mountains, trees,
|
||||
// city skylines) rise into, overlapping the tile "behind" them. Visiting
|
||||
// dy/dx in raster order doesn't guarantee ascending c+r, so a lower-sum
|
||||
// neighbor can get repainted after a higher-sum one and stomp its peak,
|
||||
// which reads as nearby terrain jumping even though no tile's x/y moved.
|
||||
const tiles = [];
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
if (inBounds(this.state.world, c + dx, r + dy)) this.paintTile(c + dx, r + dy);
|
||||
if (inBounds(this.state.world, c + dx, r + dy)) tiles.push([c + dx, r + dy]);
|
||||
}
|
||||
}
|
||||
tiles.sort(([ac, ar], [bc, br]) => (ac + ar) - (bc + br));
|
||||
for (const [tc, tr] of tiles) this.paintTile(tc, tr);
|
||||
}
|
||||
|
||||
paintTile(c, r) {
|
||||
|
|
@ -567,7 +609,7 @@ export class CivilizationMapView {
|
|||
// read as standing on the tile instead of at its front edge.
|
||||
const spriteMode = scene.textures.exists('civilization-units');
|
||||
if (spriteMode) {
|
||||
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
|
||||
const ring = scene.add.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1);
|
||||
const img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
|
||||
container.add([ring, img]);
|
||||
} else {
|
||||
|
|
@ -621,7 +663,7 @@ export class CivilizationMapView {
|
|||
if (!unit) { this.selectedUnitId = null; return; }
|
||||
const x = this.isoX(unit.x, unit.y);
|
||||
const y = this.isoY(unit.x, unit.y) + TILE_H / 2;
|
||||
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
|
||||
const ring = this.scene.add.ellipse(x, y - 2, SEL_RING_W, SEL_RING_H).setStrokeStyle(3, 0xffffff, 1);
|
||||
// Depth below the unit's own y + 2 (see drawUnit) so the unit sprite
|
||||
// renders in front of its own pulsing selection ring instead of the ring
|
||||
// sitting on top of it.
|
||||
|
|
@ -722,7 +764,7 @@ export class CivilizationMapView {
|
|||
let img = null;
|
||||
let roundel = null;
|
||||
if (spriteMode) {
|
||||
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
|
||||
const ring = scene.add.ellipse(0, 0, RING_W, RING_H, color, 0.5).setStrokeStyle(2, color, 1);
|
||||
img = scene.add.image(0, -UNIT_FRAME_H / 2 + 5, 'civilization-units', def.frame);
|
||||
container.add([ring, img]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,8 @@ export function openTechScreen(scene, rules, state, onClose) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Diplomacy
|
||||
|
||||
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose) {
|
||||
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose,
|
||||
focusCivId = null, playIntroOnFocus = false) {
|
||||
const human = state.humanIndex;
|
||||
const civ = state.civs[human];
|
||||
let portrait = null;
|
||||
|
|
@ -152,7 +153,11 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
|
|||
|
||||
let detail = scene.add.container(0, 0);
|
||||
root.add(detail);
|
||||
let selected = contacts[0];
|
||||
let selected = contacts.find((c) => c.id === focusCivId) ?? contacts[0];
|
||||
// Only the very first draw (the auto-focused civ this screen was opened
|
||||
// for) may play the intro clip — cleared immediately so re-selecting or
|
||||
// returning to that same civ later never replays it.
|
||||
let introPending = playIntroOnFocus;
|
||||
|
||||
// Left rail: contacted civs.
|
||||
contacts.forEach((other, i) => {
|
||||
|
|
@ -190,8 +195,10 @@ export function openDiplomacyScreen(scene, rules, state, opponentsData, respondT
|
|||
// Video portrait with the character's current mood.
|
||||
const opData = opponentsData.find((o) => o.id === other.leaderId)
|
||||
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
|
||||
const shouldPlayIntro = introPending && other.id === focusCivId;
|
||||
introPending = false;
|
||||
try {
|
||||
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: false });
|
||||
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: shouldPlayIntro });
|
||||
if (mood !== 'idle') portrait.playEmotion(mood);
|
||||
} catch (_) { /* portrait optional */ }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue