fertig-classic-games/src/games/civilization/CivilizationMapView.js

1023 lines
38 KiB
JavaScript

// Civilization — isometric map renderer.
//
// The engine works on a square grid; this view draws it as Civ II-style
// diamonds. Terrain (plus improvements/specials/huts) is baked into one
// RenderTexture and repainted per-tile when the world changes; fog is a second
// RenderTexture of black diamonds erased as the human explores. Units and
// cities are lightweight display objects rebuilt on refresh() — the game is
// turn-based, so refreshes happen on actions, not per frame.
//
// Art: uses the optional civilization-* sheets when loaded (see sprites.md),
// otherwise draws flat-shaded diamonds, glyphs and roundels procedurally.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import {
IMP, tileIndex, inBounds, cityAt, unitsAt, civUnits, civCities,
computeVisible, isUnitVisibleTo, shieldGrassAt,
} from './CivilizationLogic.js';
export const TILE_W = 128;
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 {
constructor(scene, rules, state, callbacks = {}) {
this.scene = scene;
this.rules = rules;
this.state = state;
this.cb = callbacks;
this.humanIdx = state.humanIndex;
this.zoomIdx = 1;
this.selectedUnitId = null;
this.exploredDrawn = null;
this.unitContainers = new Map();
const { world } = state;
this.originX = world.rows * (TILE_W / 2); // keeps iso x positive
this.worldW = (world.cols + world.rows) * (TILE_W / 2);
this.worldH = (world.cols + world.rows) * (TILE_H / 2) + FRAME_H;
this.root = scene.add.container(0, 0).setDepth(1);
// Chunked RenderTextures: a Large map is ~8200px wide — beyond one safe
// GPU texture — so terrain and fog are tiled into <=2048px chunks.
const CHUNK = 2048;
this.chunkSize = CHUNK;
this.terrainChunks = [];
this.fogChunks = [];
for (let oy = 0; oy < this.worldH; oy += CHUNK) {
for (let ox = 0; ox < this.worldW; ox += CHUNK) {
const w = Math.min(CHUNK, this.worldW - ox);
const h = Math.min(CHUNK, this.worldH - oy);
const trt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0);
const frt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0);
this.terrainChunks.push({ rt: trt, ox, oy, w, h });
this.fogChunks.push({ rt: frt, ox, oy, w, h });
}
}
this.pathGfx = scene.add.graphics();
this.dynamic = scene.add.container(0, 0);
this.root.add([
...this.terrainChunks.map((c) => c.rt),
this.pathGfx, this.dynamic,
...this.fogChunks.map((c) => c.rt),
]);
this.stamp = scene.add.graphics().setVisible(false);
this.miniGfx = null;
this.bakeTerrain();
this.initFog();
this.setZoom(1);
}
// Run fn(rt, ox, oy) on every chunk overlapping the given world-space box.
forEachChunk(chunks, x0, y0, x1, y1, fn) {
for (const c of chunks) {
if (x1 < c.ox || x0 > c.ox + c.w || y1 < c.oy || y0 > c.oy + c.h) continue;
fn(c.rt, c.ox, c.oy);
}
}
// Tile bounding box in world space (frame headroom included).
tileBounds(x, y) {
return [x - TILE_W / 2 - 4, y - (FRAME_H - TILE_H) - 4, x + TILE_W / 2 + 4, y + FRAME_H + 4];
}
drawStampAt(gfx, x, y) {
const [x0, y0, x1, y1] = this.tileBounds(x, y);
this.forEachChunk(this.terrainChunks, x0, y0, x1, y1,
(rt, ox, oy) => rt.draw(gfx, x - ox, y - oy));
}
drawFrameAt(key, frame, x, y) {
const [x0, y0, x1, y1] = this.tileBounds(x + 64, y);
this.forEachChunk(this.terrainChunks, x0, y0, x1, y1,
(rt, ox, oy) => rt.drawFrame(key, frame, x - ox, y - oy));
}
destroy() {
this.root.destroy(true);
this.stamp.destroy();
this.fogStamp?.destroy();
this.miniRoot?.destroy(true);
}
// -------------------------------------------------------------------------
// Coordinates
isoX(c, r) { return this.originX + (c - r) * (TILE_W / 2); }
isoY(c, r) { return (c + r) * (TILE_H / 2); }
// Screen -> tile (or null). Checks the two diamond candidates around the
// inverse transform.
screenToTile(px, py) {
const wx = (px - this.root.x) / this.root.scaleX;
const wy = (py - this.root.y) / this.root.scaleY;
const a = (wx - this.originX) / (TILE_W / 2);
const b = (wy - TILE_H / 2) / (TILE_H / 2); // diamond centre offset
const cf = (a + b) / 2;
const rf = (b - a) / 2;
let best = null;
let bestDist = Infinity;
for (const [c, r] of [
[Math.floor(cf), Math.floor(rf)], [Math.ceil(cf), Math.floor(rf)],
[Math.floor(cf), Math.ceil(rf)], [Math.ceil(cf), Math.ceil(rf)],
[Math.round(cf), Math.round(rf)],
]) {
if (!inBounds(this.state.world, c, r)) continue;
const cx = this.isoX(c, r);
const cy = this.isoY(c, r) + TILE_H / 2;
// Diamond containment via L1 distance in tile units.
const dx = Math.abs(wx - cx) / (TILE_W / 2);
const dy = Math.abs(wy - cy) / (TILE_H / 2);
if (dx + dy <= 1.02) {
const d = dx + dy;
if (d < bestDist) { bestDist = d; best = [c, r]; }
}
}
return best;
}
setZoom(idx, mouseX, mouseY) {
const oldScale = this.root.scaleX;
this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1);
this.root.setScale(ZOOMS[this.zoomIdx]);
const newScale = this.root.scaleX;
// Zoom toward mouse position if provided.
if (mouseX != null && mouseY != null) {
// Convert screen mouse position to world space using old scale.
const worldX = (mouseX - this.root.x) / oldScale;
const worldY = (mouseY - this.root.y) / oldScale;
// Adjust root position so that world point stays under the mouse.
this.root.x += worldX * (oldScale - newScale);
this.root.y += worldY * (oldScale - newScale);
}
this.clampPan();
}
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();
}
// 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;
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) {
const s = this.root.scaleX;
this.root.x = GAME_WIDTH / 2 - this.isoX(c, r) * s;
this.root.y = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s;
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
bakeTerrain() {
for (const c of this.terrainChunks) c.rt.clear();
const { world } = this.state;
// Paint back-to-front (row-major works: greater c+r paints later).
for (let sum = 0; sum <= world.cols + world.rows - 2; sum += 1) {
for (let c = Math.max(0, sum - world.rows + 1); c <= Math.min(sum, world.cols - 1); c += 1) {
const r = sum - c;
this.paintTile(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 dx = -1; dx <= 1; dx += 1) {
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) {
const { world } = this.state;
const idx = tileIndex(world, c, r);
const terr = this.rules.terrainList[world.terrain[idx]];
const x = this.isoX(c, r);
const y = this.isoY(c, r);
const useSheet = this.scene.textures.exists('civilization-terrain');
if (useSheet) {
let frame = terr.frame;
if (terr.id === 'grassland' && shieldGrassAt(c, r) && world.special[idx] < 0) {
frame = this.rules.grasslandShieldFrame;
}
this.drawFrameAt('civilization-terrain', frame, x - TILE_W / 2, y + TILE_H - FRAME_H);
} else {
this.paintProceduralTile(c, r, terr, x, y);
}
this.paintImprovements(c, r, x, y);
this.paintSpecial(c, r, x, y);
}
paintProceduralTile(c, r, terr, x, y) {
const g = this.stamp;
g.clear();
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
const cy = TILE_H / 2; // stamp-local diamond centre
g.fillStyle(base, 1);
g.beginPath();
g.moveTo(0, cy - TILE_H / 2);
g.lineTo(TILE_W / 2, cy);
g.lineTo(0, cy + TILE_H / 2);
g.lineTo(-TILE_W / 2, cy);
g.closePath();
g.fillPath();
g.lineStyle(1, 0x000000, 0.18);
g.strokePath();
// Simple per-terrain glyphs.
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
if (terr.id === 'forest' || terr.id === 'jungle') {
g.fillStyle(darker, 1);
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
}
} else if (terr.id === 'mountains') {
g.fillStyle(darker, 1);
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
g.fillStyle(0xffffff, 0.9);
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
} else if (terr.id === 'hills') {
g.fillStyle(darker, 1);
g.fillEllipse(-16, cy + 4, 34, 16);
g.fillEllipse(14, cy + 8, 38, 18);
} else if (terr.id === 'ocean') {
g.lineStyle(2, lighter, 0.7);
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx + 14, cy + ty);
g.strokePath();
}
} else if (terr.id === 'swamp') {
g.lineStyle(2, darker, 0.8);
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx, cy + ty - 8);
g.strokePath();
}
} else if (terr.id === 'desert') {
g.fillStyle(darker, 0.6);
g.fillEllipse(-14, cy + 4, 10, 4);
g.fillEllipse(12, cy - 4, 12, 4);
} else if (terr.id === 'grassland') {
const idx = tileIndex(this.state.world, c, r);
if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) {
g.fillStyle(lighter, 1);
g.fillCircle(18, cy - 6, 5);
}
}
this.drawStampAt(g, x, y);
}
paintImprovements(c, r, x, y) {
const { world } = this.state;
const idx = tileIndex(world, c, r);
const bits = world.improvements[idx];
const g = this.stamp;
const cy = TILE_H / 2;
const hasCity = !!cityAt(this.state, c, r);
// Roads/rails connect toward neighbours that also have them (or cities).
if (bits & (IMP.ROAD | IMP.RAILROAD)) {
g.clear();
const rail = !!(bits & IMP.RAILROAD);
const segments = [];
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx === 0 && dy === 0) continue;
const nc = c + dx;
const nr = r + dy;
if (!inBounds(world, nc, nr)) continue;
const nBits = world.improvements[tileIndex(world, nc, nr)];
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;
segments.push([ex, cy + ey]);
}
}
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);
}
const useSheet = this.scene.textures.exists('civilization-improvements');
const drawBadge = (frame, fallback) => {
if (useSheet) {
this.drawFrameAt('civilization-improvements', frame, x - 32, y);
} else {
fallback();
}
};
if ((bits & IMP.IRRIGATION) && !hasCity) {
drawBadge(bits & IMP.FARMLAND ? 1 : 0, () => {
g.clear();
g.lineStyle(2, 0x2f8fbf, 0.8);
for (let i = -1; i <= 1; i += 1) {
g.beginPath();
g.moveTo(-18, cy + i * 7);
g.lineTo(18, cy + i * 7);
g.strokePath();
}
this.drawStampAt(g, x, y);
});
}
if (bits & IMP.MINE) {
drawBadge(2, () => {
g.clear();
g.fillStyle(0x3a3a3a, 1);
g.fillTriangle(-8, cy + 6, 8, cy + 6, 0, cy - 8);
g.fillStyle(0x111111, 1);
g.fillRect(-2, cy - 2, 4, 8);
this.drawStampAt(g, x, y);
});
}
if (bits & IMP.FORTRESS) {
drawBadge(3, () => {
g.clear();
g.lineStyle(3, 0x9a8866, 1);
g.strokeRect(-20, cy - 12, 40, 24);
this.drawStampAt(g, x, y);
});
}
if (world.huts[idx]) {
drawBadge(4, () => {
g.clear();
g.fillStyle(0x8a5a2a, 1);
g.fillRect(-8, cy - 4, 16, 10);
g.fillStyle(0xb08040, 1);
g.fillTriangle(-11, cy - 4, 11, cy - 4, 0, cy - 14);
this.drawStampAt(g, x, y);
});
}
}
paintSpecial(c, r, x, y) {
const { world } = this.state;
const idx = tileIndex(world, c, r);
if (world.special[idx] < 0) return;
const spec = this.rules.specialList[world.special[idx]];
if (this.scene.textures.exists('civilization-resources')) {
this.drawFrameAt('civilization-resources', spec.frame, x - 32, y);
return;
}
const g = this.stamp;
const cy = TILE_H / 2;
g.clear();
g.fillStyle(0xffffff, 0.85);
g.fillCircle(0, cy, 9);
g.fillStyle(specialColor(spec.id), 1);
g.fillCircle(0, cy, 7);
this.drawStampAt(g, x, y);
}
// -------------------------------------------------------------------------
// Fog
initFog() {
const { world } = this.state;
for (const c of this.fogChunks) {
c.rt.clear();
c.rt.fill(0x05060a, 1);
}
// Erase diamonds that are already explored; remember what we've erased.
// The stamp is oversized by a pixel so adjacent erases leave no seams.
this.exploredDrawn = new Array(world.cols * world.rows).fill(0);
const g = this.scene.add.graphics().setVisible(false);
const cy = TILE_H / 2;
g.fillStyle(0xffffff, 1);
g.beginPath();
g.moveTo(TILE_W / 2, cy - TILE_H / 2 - 2);
g.lineTo(TILE_W + 3, cy);
g.lineTo(TILE_W / 2, cy + TILE_H / 2 + 2);
g.lineTo(-3, cy);
g.closePath();
g.fillPath();
this.fogStamp = g;
this.updateFog();
}
updateFog() {
if (this.humanIdx < 0) {
for (const c of this.fogChunks) c.rt.setVisible(false);
return;
}
const { world } = this.state;
const explored = this.state.explored[this.humanIdx];
for (let r = 0; r < world.rows; r += 1) {
for (let c = 0; c < world.cols; c += 1) {
const idx = tileIndex(world, c, r);
if (explored[idx] && !this.exploredDrawn[idx]) {
this.exploredDrawn[idx] = 1;
const x = this.isoX(c, r) - TILE_W / 2;
const y = this.isoY(c, r);
this.forEachChunk(this.fogChunks, x - 4, y - 4, x + TILE_W + 4, y + TILE_H + 4,
(rt, ox, oy) => rt.erase(this.fogStamp, x - ox, y - oy));
}
}
}
}
// -------------------------------------------------------------------------
// Dynamic layer (units + cities)
refresh() {
this.updateFog();
this.dynamic.removeAll(true);
this.unitContainers.clear();
const { state, rules } = this;
const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current;
const visible = computeVisible(state, povIdx);
const explored = this.humanIdx >= 0 ? state.explored[povIdx] : null;
const cityTiles = new Set();
for (const city of state.cities) {
const idx = tileIndex(state.world, city.x, city.y);
cityTiles.add(idx);
if (explored && !explored[idx]) continue;
this.drawCity(city);
}
// One marker per occupied tile (top defender), with a stack badge.
// Own units fortified inside a city are tucked away instead of drawn —
// they no longer overlap the city graphic; see them via onCityClick.
const byTile = new Map();
for (const u of state.units) {
if (u.carriedBy) continue;
const idx = tileIndex(state.world, u.x, u.y);
if (u.fortified && u.civ === povIdx && cityTiles.has(idx)) continue;
if (u.civ !== povIdx) {
if (!visible.has(idx)) continue;
if (!isUnitVisibleTo(rules, state, u, povIdx)) continue;
}
(byTile.get(idx) ?? byTile.set(idx, []).get(idx)).push(u);
}
for (const [idx, units] of byTile) {
const c = idx % state.world.cols;
const r = (idx / state.world.cols) | 0;
const selected = units.find((u) => u.id === this.selectedUnitId);
const top = selected ?? units[0];
this.drawUnit(top, units.length, c, r);
}
this.drawSelection();
this.refreshMinimap();
}
drawCity(city) {
const { scene } = this;
const x = this.isoX(city.x, city.y);
const y = this.isoY(city.x, city.y);
const civ = this.state.civs[city.civ];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
// Depth uses the same isoY + TILE_H/2 "ground" base as units/ghosts so
// cities and units sort correctly against each other; the container's
// actual (x, y) position stays at the bare isoY so the city art doesn't
// shift on screen.
const depthY = y + TILE_H / 2;
const container = scene.add.container(x, y).setDepth(depthY);
const walled = !!city.buildings.citywalls;
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);
container.add(img);
} else {
const g = scene.add.graphics();
const cy = TILE_H / 2;
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
g.fillStyle(0x6b6255, 1);
for (let i = 0; i <= tier; i += 1) {
const bw = 26 - i * 3;
const bh = 16 + i * 8;
const bx = -24 + i * 16;
g.fillRect(bx, cy - bh + 4, bw, bh);
g.fillStyle(0x7d7466, 1);
}
g.fillStyle(0x332f28, 1);
for (let i = 0; i <= tier; i += 1) g.fillRect(-18 + i * 16, cy - 6, 5, 6);
if (walled) {
g.lineStyle(3, 0x9a8866, 1);
g.strokeRect(-34, cy - 14, 68, 22);
}
container.add(g);
}
const banner = scene.add.rectangle(0, TILE_H + 10, 0, 22, 0x000000, 0.65)
.setStrokeStyle(1, color, 1);
const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color,
}).setOrigin(0.5);
// Direct `.width =` leaves the cached display origin stale (it's only
// recomputed by setSize/setOrigin), so the box renders left-anchored
// instead of staying centered under the label — use setSize instead.
banner.setSize(label.width + 16, 22);
container.add([banner, label]);
container.setDepth(depthY + 1);
banner.setInteractive({ useHandCursor: true });
banner.on('pointerdown', (pointer, lx, ly, event) => {
event.stopPropagation();
this.cb.onCityClick?.(city);
});
this.dynamic.add(container);
}
drawUnit(unit, stackCount, c, r) {
const { scene, rules } = this;
const def = rules.units[unit.type];
const civ = this.state.civs[unit.civ];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
const x = this.isoX(c, r);
const y = this.isoY(c, r) + TILE_H / 2;
const container = scene.add.container(x, y);
// Sprite mode: lift the unit frame so its bottom edge lands just below
// the tile's vertical middle (roughly the middle of the colored civ
// ring, nudged 5px down) rather than the diamond's lower point, so units
// 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.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 {
const g = scene.add.graphics();
g.fillStyle(0x000000, 0.35);
g.fillEllipse(0, 12, 40, 14);
g.fillStyle(color, 1);
g.fillCircle(0, -2, 17);
g.lineStyle(2, 0xffffff, unit.fortified ? 1 : 0.5);
g.strokeCircle(0, -2, 17);
container.add(g);
const label = scene.add.text(0, -2, def.abbr, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
container.add(label);
}
// Badges ride at shoulder height: low against the flat procedural
// roundel, higher up against the taller sprite frame.
const badgeY = spriteMode ? -60 : -14;
if (unit.vet) {
container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
}
if (stackCount > 1) {
const badge = scene.add.circle(-16, badgeY, 8, 0x000000, 0.8);
const num = scene.add.text(-16, badgeY, `${stackCount}`, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
}).setOrigin(0.5);
container.add([badge, num]);
}
// Only the currently-selected unit is clickable — clicking any other
// unit's tile still goes through the normal tile-click select/move flow
// in CivilizationGame (see bindPointer/onTileClick).
if (unit.id === this.selectedUnitId) {
container.setSize(64, 64);
container.setInteractive({ useHandCursor: true });
container.on('pointerdown', (pointer, lx, ly, event) => {
event.stopPropagation();
this.cb.onUnitClick?.(unit);
});
}
container.setDepth(y + 2);
this.dynamic.add(container);
this.unitContainers.set(unit.id, container);
if (unit.id === this.selectedUnitId) this.selectedContainer = container;
}
drawSelection() {
this.selectionRing = null;
if (!this.selectedUnitId) return;
const unit = this.state.units.find((u) => u.id === this.selectedUnitId);
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.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.
ring.setDepth(y + 1);
this.dynamic.add(ring);
this.selectionRing = ring;
this.scene.tweens.add({
targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1,
});
}
// Glides the currently-selected unit's marker (and its selection ring)
// through `tiles` ([[c,r], ...]) — state is already at the final tile by
// the time this runs, so we snap the marker back to the first point and
// tween forward, splitting `duration` evenly across the hops. Pauses
// `pauseMs` on arrival before calling `onComplete`.
animateUnitAlong(tiles, duration, pauseMs, onComplete) {
const container = this.selectedContainer;
const points = tiles.map(([c, r]) => ({
x: this.isoX(c, r), y: this.isoY(c, r) + TILE_H / 2,
}));
if (!container || points.length < 2) { onComplete?.(); return; }
const ring = this.selectionRing;
const place = (p) => {
container.setPosition(p.x, p.y);
container.setDepth(p.y + 2);
if (ring) { ring.setPosition(p.x, p.y - 2); ring.setDepth(p.y + 1); }
};
place(points[0]);
const segDuration = duration / (points.length - 1);
let idx = 0;
const nextSeg = () => {
idx += 1;
const target = points[idx];
this.scene.tweens.add({
targets: container,
x: target.x,
y: target.y,
duration: segDuration,
ease: 'Sine.easeInOut',
onUpdate: () => place({ x: container.x, y: container.y }),
onComplete: () => {
if (idx < points.length - 1) nextSeg();
else this.scene.time.delayedCall(pauseMs, () => onComplete?.());
},
});
};
nextSeg();
}
// Glides any number of already-drawn units' markers from their turn-start
// tile to their turn-end tile simultaneously — used for AI civs, whose
// moves are only known as a before/after snapshot (no waypoint list like
// the human's click-to-move path), so each glide is a straight line.
// `moves` is [{ unitId, from: [c,r], to: [c,r] }, ...]. Units with no
// on-screen marker (not currently visible, stacked under another unit,
// etc.) are silently skipped. Fires `onComplete` once, after `pauseMs`.
animateUnitsAlong(moves, duration, pauseMs, onComplete) {
const live = moves.filter((m) => this.unitContainers.has(m.unitId));
if (!live.length) { onComplete?.(); return; }
for (const { unitId, from, to } of live) {
const container = this.unitContainers.get(unitId);
const start = { x: this.isoX(from[0], from[1]), y: this.isoY(from[0], from[1]) + TILE_H / 2 };
const end = { x: this.isoX(to[0], to[1]), y: this.isoY(to[0], to[1]) + TILE_H / 2 };
container.setPosition(start.x, start.y);
container.setDepth(start.y + 2);
this.scene.tweens.add({
targets: container,
x: end.x,
y: end.y,
duration,
ease: 'Sine.easeInOut',
onUpdate: () => container.setDepth(container.y + 2),
});
}
this.scene.time.delayedCall(duration + pauseMs, () => onComplete?.());
}
isTileVisible(c, r) {
const povIdx = this.humanIdx >= 0 ? this.humanIdx : this.state.current;
return computeVisible(this.state, povIdx).has(tileIndex(this.state.world, c, r));
}
// Standalone unit visual for combat animation. Not tracked in
// unitContainers and not tied to live state — combat has already resolved
// by the time this plays (the loser may already be gone, the winner may
// already have moved), so ghosts are built fresh from the event's
// civ/type/tile snapshot and discarded when the sequence finishes.
buildCombatGhost(civIdx, unitType, c, r) {
const { scene, rules } = this;
const def = rules.units[unitType];
const civ = this.state.civs[civIdx];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
const x = this.isoX(c, r);
const y = this.isoY(c, r) + TILE_H / 2;
const container = scene.add.container(x, y);
const spriteMode = scene.textures.exists('civilization-units');
let img = null;
let roundel = null;
if (spriteMode) {
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 {
roundel = scene.add.graphics();
roundel.fillStyle(0x000000, 0.35);
roundel.fillEllipse(0, 12, 40, 14);
roundel.fillStyle(color, 1);
roundel.fillCircle(0, -2, 17);
roundel.lineStyle(2, 0xffffff, 0.5);
roundel.strokeCircle(0, -2, 17);
container.add(roundel);
const label = scene.add.text(0, -2, def.abbr, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
container.add(label);
}
container.setDepth(y + 2);
this.dynamic.add(container);
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.
playDeathFlash(ghost, onDone) {
const { container, img, roundel, color } = ghost;
const setRed = (on) => {
if (img) { if (on) img.setTint(0xff0000); else img.clearTint(); return; }
if (!roundel) return;
roundel.clear();
roundel.fillStyle(0x000000, 0.35);
roundel.fillEllipse(0, 12, 40, 14);
roundel.fillStyle(on ? 0xff0000 : color, 1);
roundel.fillCircle(0, -2, 17);
roundel.lineStyle(2, 0xffffff, 0.5);
roundel.strokeCircle(0, -2, 17);
};
const FLASH_MS = 110;
const FLASHES = 3;
let n = 0;
const flash = () => {
setRed(true);
this.scene.time.delayedCall(FLASH_MS, () => {
setRed(false);
n += 1;
if (n < FLASHES) { this.scene.time.delayedCall(FLASH_MS, flash); return; }
this.scene.tweens.add({
targets: container, scale: 0, alpha: 0, duration: 300, ease: 'Cubic.easeIn',
onComplete: () => { container.destroy(); onDone?.(); },
});
});
};
flash();
}
// Plays a queue of combat events sequentially: attacker lunges onto the
// defender's tile and back, the loser tints/flashes/shrinks away, then —
// only if the attacker won and the tile ended up empty — the attacker
// glides into it, followed by a short pause. `events` come from the
// engine's `combat`-type entries in state.events (already fully resolved;
// this just replays what happened for the player to see).
animateCombat(events, onComplete) {
const queue = events.slice();
const playNext = () => {
const e = queue.shift();
if (!e) { onComplete?.(); return; }
this.playCombatEvent(e, playNext);
};
playNext();
}
playCombatEvent(e, onDone) {
// Defends against stale-shape combat events — e.g. a save (or a live
// session carrying pre-existing state.events) written before this
// snapshot shape existed — rather than throwing and wedging the turn.
const civOk = (idx) => Number.isInteger(idx) && !!this.state.civs[idx];
const typeOk = (t) => !!this.rules.units[t];
if (!civOk(e.attackerCiv) || !civOk(e.defenderCiv) || !typeOk(e.attackerType) || !typeOk(e.defenderType)
|| !Number.isInteger(e.ax) || !Number.isInteger(e.ay) || !Number.isInteger(e.x) || !Number.isInteger(e.y)) {
onDone?.();
return;
}
this.unitContainers.get(e.attackerId)?.setVisible(false);
this.unitContainers.get(e.defenderId)?.setVisible(false);
const attackerGhost = this.buildCombatGhost(e.attackerCiv, e.attackerType, e.ax, e.ay);
const defenderGhost = this.buildCombatGhost(e.defenderCiv, e.defenderType, e.x, e.y);
const home = { x: this.isoX(e.ax, e.ay), y: this.isoY(e.ax, e.ay) + TILE_H / 2 };
const target = { x: this.isoX(e.x, e.y), y: this.isoY(e.x, e.y) + TILE_H / 2 };
const LUNGE_MS = 250;
// Lunge onto the defender's tile...
this.scene.tweens.add({
targets: attackerGhost.container,
x: target.x,
y: target.y,
duration: LUNGE_MS,
ease: 'Sine.easeIn',
onUpdate: () => attackerGhost.container.setDepth(attackerGhost.container.y + 3),
onComplete: () => {
// ...then retreat back to where the attack came from.
this.scene.tweens.add({
targets: attackerGhost.container,
x: home.x,
y: home.y,
duration: LUNGE_MS,
ease: 'Sine.easeOut',
onUpdate: () => attackerGhost.container.setDepth(attackerGhost.container.y + 2),
onComplete: () => this.finishCombatEvent(e, attackerGhost, defenderGhost, home, target, onDone),
});
},
});
}
finishCombatEvent(e, attackerGhost, defenderGhost, home, target, onDone) {
const loser = e.attackerWon ? defenderGhost : attackerGhost;
const winner = e.attackerWon ? attackerGhost : defenderGhost;
this.playDeathFlash(loser, () => {
if (e.attackerWon && e.advanced) {
this.scene.tweens.add({
targets: winner.container,
x: target.x,
y: target.y,
duration: 1000,
ease: 'Sine.easeInOut',
onUpdate: () => winner.container.setDepth(winner.container.y + 2),
onComplete: () => {
winner.container.destroy();
this.scene.time.delayedCall(250, onDone);
},
});
return;
}
winner.container.destroy();
this.scene.time.delayedCall(250, onDone);
});
}
showPath(path) {
this.pathGfx.clear();
if (!path || !path.length) return;
this.pathGfx.lineStyle(3, 0xffffff, 0.6);
for (const [c, r] of path) {
const x = this.isoX(c, r);
const y = this.isoY(c, r) + TILE_H / 2;
this.pathGfx.strokeCircle(x, y, 7);
}
}
// -------------------------------------------------------------------------
// Minimap
buildMinimap(x, y, width) {
const { world } = this.state;
const scale = width / (world.cols + world.rows);
this.miniScale = scale;
this.miniRoot = this.scene.add.container(x, y).setDepth(40);
const h = (world.cols + world.rows) * (scale / 2) + 8;
const bg = this.scene.add.rectangle(0, 0, width + 12, h + 12, COLORS.panel, 0.92)
.setOrigin(0, 0).setStrokeStyle(2, COLORS.accent);
this.miniGfx = this.scene.add.graphics();
this.miniGfx.setPosition(6, 6);
this.miniRoot.add([bg, this.miniGfx]);
this.miniW = width;
this.miniH = h;
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', (pointer, lx, ly) => {
// Invert the mini iso transform.
const mx = lx - 6;
const my = ly - 6;
const a = (mx - world.rows * (scale / 2)) / (scale / 2);
const b = my / (scale / 4);
const c = Math.round((a + b / 2) / 2);
const r = Math.round((b / 2 - a) / 2);
if (inBounds(world, c, r)) {
this.centerOn(c, r);
this.cb.onMinimapJump?.();
}
});
this.refreshMinimap();
}
refreshMinimap() {
if (!this.miniGfx) return;
const { world } = this.state;
const scale = this.miniScale;
const explored = this.humanIdx >= 0 ? this.state.explored[this.humanIdx] : null;
const g = this.miniGfx;
g.clear();
for (let r = 0; r < world.rows; r += 1) {
for (let c = 0; c < world.cols; c += 1) {
const idx = tileIndex(world, c, r);
const px = (world.rows + c - r) * (scale / 2);
const py = (c + r) * (scale / 4);
if (explored && !explored[idx]) {
g.fillStyle(0x05060a, 1);
} else {
const terr = this.rules.terrainList[world.terrain[idx]];
g.fillStyle(Phaser.Display.Color.HexStringToColor(terr.color).color, 1);
}
g.fillRect(px, py, Math.max(1.5, scale / 2), Math.max(1.5, scale / 2));
}
}
for (const city of this.state.cities) {
const idx = tileIndex(world, city.x, city.y);
if (explored && !explored[idx]) continue;
const px = (world.rows + city.x - city.y) * (scale / 2);
const py = (city.x + city.y) * (scale / 4);
g.fillStyle(Phaser.Display.Color.HexStringToColor(this.state.civs[city.civ].color).color, 1);
g.fillRect(px - 1, py - 1, 4, 4);
}
}
}
function specialColor(id) {
const map = {
buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0,
coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92,
oasis: 0x30a060, oil: 0x1a1a1a, game: 0x9a6a3a, furs: 0xb0885a,
ivory: 0xf0ead8, glacieroil: 0x1a1a1a, peat: 0x5a4a2a, spice: 0xd07030,
gems: 0x30c0c0, fruit: 0xe07040, fish: 0x60a0e0, whales: 0x4060a0,
};
return map[id] ?? 0xffffff;
}