// 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; }