// Advance Wars board renderer: terrain (RenderTexture), buildings (live // tinted images), units (containers with 2-frame flip + HP digits), fog, // move/attack overlays, path arrow and cursor. // // Art comes from FOUR drop-in sheets (data/advancewars-artwork.json → // terrainSheet 48×48, buildingSheet 48×64 tintable, unitSheet 48×64 // tintable, uiSheet 48×48; spec: src/games/advancewars/sprites.md). Any // sheet not painted yet gets a procedural stand-in with the same frame map. // Rivers and roads are NOT sprites — they're drawn with Graphics from the // tile connection masks. import * as Phaser from 'phaser'; import * as Logic from './AdvanceWarsLogic.js'; const CELL = 48; const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom // Units and buildings share one Y-sorted depth band (actors + row), so a // unit two rows down still draws over a building one row up, etc. This bias // only breaks a tie — same row, same depth — in the unit's favor, so a unit // standing on the same tile as a building always renders above it, without // disturbing the row-vs-row ordering (it's well under the 1-per-row step). const UNIT_DEPTH_BIAS = 0.5; // building strength number: anchored just outside the building's left edge, // sinking back in by this many pixels so it reads as sitting on/against the // building rather than floating disconnected from it. const PROP_NUMBER_OVERLAP = 5; // Painted drop-in sheets and the layout their procedural stand-ins mirror. export const SHEETS = { terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 }, buildings: { key: 'advancewars-buildings', cellH: TALL, cols: 6, frames: 5 }, units: { key: 'advancewars-units', cellH: TALL, cols: 12, frames: 36 }, ui: { key: 'advancewars-ui', cellH: CELL, cols: 8, frames: 7 }, }; export const UI_FRAMES = { cursor: 0, explosion1: 1, explosion2: 2, capture: 3, fuel: 4, ammo: 5, star: 6, }; export function armyColorInt(rules, army) { const hex = army < 0 ? rules.constants.neutralColor : rules.constants.armyColors[army]; return parseInt(hex.slice(1), 16); } // --------------------------------------------------------------------------- // Procedural stand-in sheets // Returns { terrain, buildings, units, ui } texture keys — the painted sheet // when it's loaded, otherwise a generated stand-in. export function ensureSheets(scene, rules) { const keys = {}; for (const [name, spec] of Object.entries(SHEETS)) { if (scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; } const procKey = `${spec.key}-proc`; if (!scene.textures.exists(procKey)) PROC_PAINTERS[name](scene, procKey, spec, rules); keys[name] = procKey; } return keys; } function mkCanvasSheet(scene, key, spec, count) { const rows = Math.ceil(count / spec.cols); const tex = scene.textures.createCanvas(key, spec.cols * CELL, rows * spec.cellH); const ctx = tex.getContext(); const at = (frame, draw) => { const fx = (frame % spec.cols) * CELL; const fy = Math.floor(frame / spec.cols) * spec.cellH; ctx.save(); // origin at the top-left of the 48×48 footprint (tall cells have 16px // of headroom above it) ctx.translate(fx, fy + (spec.cellH - CELL)); draw(ctx); ctx.restore(); }; const finish = () => { tex.refresh(); for (let f = 0; f < count; f++) { tex.add(f, 0, (f % spec.cols) * CELL, Math.floor(f / spec.cols) * spec.cellH, CELL, spec.cellH); } }; return { at, finish }; } const ground = (c, color) => { c.fillStyle = color; c.fillRect(0, 0, CELL, CELL); }; const PROC_PAINTERS = { terrain(scene, key, spec) { const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames); at(0, (c) => { ground(c, '#a8d060'); c.fillStyle = '#b8dc74'; for (let i = 0; i < 5; i++) c.fillRect(6 + i * 8, 10 + (i % 3) * 12, 3, 2); }); at(1, (c) => { ground(c, '#8cc456'); c.fillStyle = '#2f6b22'; for (const [tx, ty] of [[12, 8], [30, 4], [21, 18]]) { c.beginPath(); c.moveTo(tx, ty + 22); c.lineTo(tx + 10, ty + 22); c.lineTo(tx + 5, ty + 2); c.fill(); c.fillRect(tx + 3.5, ty + 22, 3, 5); } }); at(2, (c) => { ground(c, '#b5d16b'); c.fillStyle = '#8a6a3c'; c.beginPath(); c.moveTo(3, 45); c.lineTo(24, 4); c.lineTo(45, 45); c.fill(); c.fillStyle = '#f0ede2'; c.beginPath(); c.moveTo(19, 14); c.lineTo(24, 4); c.lineTo(29, 14); c.lineTo(24, 19); c.fill(); }); at(3, (c) => { ground(c, '#3d6fd6'); c.strokeStyle = '#6f9ae8'; c.lineWidth = 2; for (let i = 0; i < 3; i++) { c.beginPath(); c.moveTo(4 + i * 6, 12 + i * 12); c.quadraticCurveTo(14 + i * 6, 8 + i * 12, 24 + i * 6, 12 + i * 12); c.stroke(); } }); at(4, (c) => { ground(c, '#3d6fd6'); c.fillStyle = '#24488f'; for (const [px, py] of [[10, 12], [26, 8], [18, 26], [34, 30], [30, 18]]) { c.beginPath(); c.arc(px, py, 4, 0, 7); c.fill(); } }); at(5, (c) => { ground(c, '#e8d9a0'); c.fillStyle = '#8fb8e8'; c.fillRect(0, 0, CELL, 8); c.fillStyle = '#f4e9c0'; c.fillRect(0, 8, CELL, 6); }); const bridge = (vert) => (c) => { ground(c, '#3d6fd6'); c.fillStyle = '#b0a89a'; if (vert) c.fillRect(12, 0, 24, CELL); else c.fillRect(0, 12, CELL, 24); c.fillStyle = '#8a8276'; if (vert) { c.fillRect(12, 0, 4, CELL); c.fillRect(32, 0, 4, CELL); } else { c.fillRect(0, 12, CELL, 4); c.fillRect(0, 32, CELL, 4); } }; at(6, bridge(false)); at(7, bridge(true)); finish(); }, buildings(scene, key, spec) { const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames); const prop = (drawIcon) => (c) => { ground(c, '#a8d060'); c.fillStyle = '#ececec'; c.strokeStyle = '#3a3a3a'; c.lineWidth = 2; c.fillRect(6, -6, 36, 42); c.strokeRect(6, -6, 36, 42); drawIcon(c); }; at(0, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(12, 2, 10, 26); c.fillRect(26, 10, 10, 18); c.fillStyle = '#3a3a3a'; for (let i = 0; i < 3; i++) { c.fillRect(14, 5 + i * 7, 6, 3); c.fillRect(28, 13 + i * 6, 6, 3); } })); at(1, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(10, 12, 28, 16); c.beginPath(); c.moveTo(10, 12); c.lineTo(17, 2); c.lineTo(24, 12); c.lineTo(31, 2); c.lineTo(38, 12); c.fill(); c.fillStyle = '#3a3a3a'; c.fillRect(20, 18, 8, 10); })); at(2, prop((c) => { c.fillStyle = '#8a8a8a'; c.beginPath(); c.moveTo(24, 0); c.lineTo(28, 12); c.lineTo(40, 16); c.lineTo(28, 20); c.lineTo(24, 32); c.lineTo(20, 20); c.lineTo(8, 16); c.lineTo(20, 12); c.fill(); })); at(3, prop((c) => { c.strokeStyle = '#8a8a8a'; c.lineWidth = 4; c.beginPath(); c.arc(24, 14, 9, 0.5, Math.PI - 0.5, false); c.stroke(); c.beginPath(); c.moveTo(24, 0); c.lineTo(24, 20); c.stroke(); c.beginPath(); c.moveTo(16, 6); c.lineTo(32, 6); c.stroke(); })); at(4, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(21, -4, 4, 34); c.fillStyle = '#c8c8c8'; c.beginPath(); c.moveTo(25, -2); c.lineTo(41, 4); c.lineTo(25, 10); c.fill(); })); finish(); }, units(scene, key, spec, rules) { const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames); const unitGlyph = (abbr, domain) => (c, bob) => { const y0 = -bob * 3; c.fillStyle = '#f0f0f0'; c.strokeStyle = '#26262e'; c.lineWidth = 2.5; if (domain === 'air') { c.beginPath(); c.ellipse(24, 22 + y0, 18, 11, 0, 0, 7); c.fill(); c.stroke(); c.beginPath(); c.moveTo(6, 22 + y0); c.lineTo(0, 34 + y0); c.lineTo(14, 28 + y0); c.fill(); } else if (domain === 'sea') { c.beginPath(); c.moveTo(2, 26 + y0); c.lineTo(46, 26 + y0); c.lineTo(38, 40 + y0); c.lineTo(10, 40 + y0); c.fill(); c.stroke(); c.fillRect(16, 14 + y0, 16, 12); } else { c.beginPath(); c.roundRect(6, 12 + y0, 36, 26, 7); c.fill(); c.stroke(); } c.fillStyle = '#26262e'; c.font = 'bold 15px monospace'; c.textAlign = 'center'; c.fillText(abbr, 24, 31 + y0); }; for (const u of rules.units) { const draw = unitGlyph(u.abbr, u.domain); at(u.frame, (c) => draw(c, 0)); at(u.frame + 1, (c) => draw(c, 1)); } finish(); }, ui(scene, key, spec) { const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames); at(UI_FRAMES.cursor, (c) => { c.strokeStyle = '#ffffff'; c.lineWidth = 4; for (const [sx, sy, dx, dy] of [[3, 3, 1, 1], [45, 3, -1, 1], [3, 45, 1, -1], [45, 45, -1, -1]]) { c.beginPath(); c.moveTo(sx + dx * 12, sy); c.lineTo(sx, sy); c.lineTo(sx, sy + dy * 12); c.stroke(); } }); const boom = (r) => (c) => { c.fillStyle = '#ffb02f'; for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; c.beginPath(); c.arc(24 + Math.cos(a) * r, 24 + Math.sin(a) * r, r * 0.55, 0, 7); c.fill(); } c.fillStyle = '#ff5a2f'; c.beginPath(); c.arc(24, 24, r * 0.8, 0, 7); c.fill(); }; at(UI_FRAMES.explosion1, boom(8)); at(UI_FRAMES.explosion2, boom(15)); at(UI_FRAMES.capture, (c) => { c.fillStyle = '#fff'; c.fillRect(10, 8, 4, 32); c.fillStyle = '#ff4040'; c.beginPath(); c.moveTo(14, 8); c.lineTo(38, 15); c.lineTo(14, 22); c.fill(); }); at(UI_FRAMES.fuel, (c) => { c.fillStyle = '#ffce4d'; c.beginPath(); c.moveTo(26, 2); c.lineTo(12, 26); c.lineTo(22, 26); c.lineTo(18, 46); c.lineTo(36, 20); c.lineTo(25, 20); c.fill(); }); at(UI_FRAMES.ammo, (c) => { c.fillStyle = '#e05050'; c.fillRect(18, 14, 12, 24); c.beginPath(); c.moveTo(18, 14); c.lineTo(24, 2); c.lineTo(30, 14); c.fill(); }); at(UI_FRAMES.star, (c) => { c.fillStyle = '#ffd94d'; c.strokeStyle = '#8a6d00'; c.lineWidth = 2; c.beginPath(); for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + (i * Math.PI) / 5; const r = i % 2 ? 9 : 20; c[i ? 'lineTo' : 'moveTo'](24 + Math.cos(a) * r, 24 + Math.sin(a) * r); } c.closePath(); c.fill(); c.stroke(); }); finish(); }, }; // --------------------------------------------------------------------------- // Board view export class AdvanceWarsMapView { constructor(scene, rules, state, opts) { this.scene = scene; this.rules = rules; this.state = state; this.tex = ensureSheets(scene, rules); const { areaX, areaY, areaW, areaH } = opts; this.tile = Math.max(24, Math.min(84, Math.floor(areaW / state.w), Math.floor(areaH / state.h))); this.ox = areaX + Math.floor((areaW - this.tile * state.w) / 2); this.oy = areaY + Math.floor((areaH - this.tile * state.h) / 2); // props (buildings) and units share one Y-sorted "actors" band — depth is // actors + row, so anything lower on screen (higher row) always draws in // front of anything above it, regardless of whether it's a unit or a // building. moveOv/atkOv sit below the actor band so the tinted-tile // overlay reads as a ground decal instead of washing over sprites. this.depths = { terrain: 1, moveOv: 2, atkOv: 3, actors: 4, fog: 20, path: 21, cursor: 22 }; this.unitViews = new Map(); // unitId -> { c, sprite, hp, badge } this.propViews = new Map(); // tileKey -> { img, strength } this.animStep = 0; this.buildTerrain(); this.buildProps(); this.moveG = scene.add.graphics().setDepth(this.depths.moveOv); this.atkG = scene.add.graphics().setDepth(this.depths.atkOv); this.fogG = scene.add.graphics().setDepth(this.depths.fog); this.pathG = scene.add.graphics().setDepth(this.depths.path); this.cursor = scene.add.image(0, 0, this.tex.ui, UI_FRAMES.cursor) .setDisplaySize(this.tile, this.tile) .setDepth(this.depths.cursor).setVisible(false); this.syncUnits(); this.flipTimer = scene.time.addEvent({ delay: 450, loop: true, callback: () => { this.animStep = 1 - this.animStep; for (const [id, v] of this.unitViews) { const u = Logic.unitById(this.state, id); if (u) v.sprite.setFrame(this.rules.unitById[u.type].frame + this.animStep); } }, }); } destroy() { this.flipTimer?.remove(); this.rt?.destroy(); this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy(); this.pathG?.destroy(); this.cursor?.destroy(); for (const v of this.unitViews.values()) v.c.destroy(); for (const v of this.propViews.values()) { v.img.destroy(); v.strength.destroy(); } } // pixel center of a tile px(x) { return this.ox + x * this.tile + this.tile / 2; } py(y) { return this.oy + y * this.tile + this.tile / 2; } tileAt(worldX, worldY) { const x = Math.floor((worldX - this.ox) / this.tile); const y = Math.floor((worldY - this.oy) / this.tile); return Logic.inBounds(this.state, x, y) ? { x, y } : null; } spriteScaleFor() { return this.tile / CELL; } buildTerrain() { const { scene, state, rules } = this; const w = state.w * this.tile, h = state.h * this.tile; this.rt = scene.add.renderTexture(this.ox, this.oy, w, h).setOrigin(0, 0).setDepth(this.depths.terrain); this.rt.beginDraw(); for (let y = 0; y < state.h; y++) { for (let x = 0; x < state.w; x++) { const t = rules.terrains[state.terrain[y * state.w + x]]; // rivers/roads/buildings sit on plain ground; the channels are drawn // by Graphics below and buildings are live tinted images const frame = (t.property || t.id === 'river' || t.id === 'road') ? rules.terrainById.plain.frame : t.frame; this.drawCell(frame, x, y); } } this.rt.endDraw(); this.drawChannels(); this.drawShores(); } drawCell(frame, x, y) { const scale = this.tile / CELL; const img = this.scene.make.image({ key: this.tex.terrain, frame, add: false }); img.setOrigin(0, 0).setScale(scale); this.rt.batchDraw(img, x * this.tile, y * this.tile); img.destroy(); } // Graphics-drawn rivers and roads (no sprite frames): each tile strokes // from its center toward every connected edge. drawChannels() { const { state, rules } = this; const g = this.scene.add.graphics(); const draw = (kind, color, width) => { g.lineStyle(width, color, 1); g.fillStyle(color, 1); for (let y = 0; y < state.h; y++) { for (let x = 0; x < state.w; x++) { const t = rules.terrains[state.terrain[y * state.w + x]]; if (t.id !== kind) continue; const cx = x * this.tile + this.tile / 2; const cy = y * this.tile + this.tile / 2; let any = false; for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { if (!this.channelJoins(kind, x + dx, y + dy)) continue; any = true; g.beginPath(); g.moveTo(cx, cy); g.lineTo(cx + dx * this.tile / 2, cy + dy * this.tile / 2); g.strokePath(); } if (!any) { // isolated stub renders as an east-west segment g.beginPath(); g.moveTo(cx - this.tile / 2, cy); g.lineTo(cx + this.tile / 2, cy); g.strokePath(); } g.fillRect(cx - width / 2, cy - width / 2, width, width); } } }; draw('river', 0x7db4e8, Math.max(5, this.tile * 0.34)); draw('road', 0xd8cfc0, Math.max(6, this.tile * 0.42)); this.rt.draw(g, 0, 0); g.destroy(); } channelJoins(kind, nx, ny) { const { state, rules } = this; if (!Logic.inBounds(state, nx, ny)) return false; const n = rules.terrains[state.terrain[ny * state.w + nx]]; if (kind === 'river') return n.id === 'river' || n.id === 'sea' || n.id === 'bridgeh' || n.id === 'bridgev'; return n.id === 'road' || n.id === 'bridgeh' || n.id === 'bridgev' || n.property; } drawShores() { const { state, rules } = this; const g = this.scene.add.graphics(); g.lineStyle(Math.max(2, this.tile * 0.08), 0xf4e9c0, 0.9); for (let y = 0; y < state.h; y++) { for (let x = 0; x < state.w; x++) { const t = rules.terrains[state.terrain[y * state.w + x]]; if (t.id !== 'sea' && t.id !== 'reef') continue; const px = x * this.tile, py = y * this.tile; const land = (nx, ny) => Logic.inBounds(state, nx, ny) && !['sea', 'reef', 'bridgeh', 'bridgev'].includes(rules.terrains[state.terrain[ny * state.w + nx]].id); if (land(x, y - 1)) { g.beginPath(); g.moveTo(px, py + 1); g.lineTo(px + this.tile, py + 1); g.strokePath(); } if (land(x, y + 1)) { g.beginPath(); g.moveTo(px, py + this.tile - 1); g.lineTo(px + this.tile, py + this.tile - 1); g.strokePath(); } if (land(x - 1, y)) { g.beginPath(); g.moveTo(px + 1, py); g.lineTo(px + 1, py + this.tile); g.strokePath(); } if (land(x + 1, y)) { g.beginPath(); g.moveTo(px + this.tile - 1, py); g.lineTo(px + this.tile - 1, py + this.tile); g.strokePath(); } } } this.rt.draw(g, 0, 0); g.destroy(); } buildProps() { const { state, rules } = this; for (let k = 0; k < state.terrain.length; k++) { const t = rules.terrains[state.terrain[k]]; if (!t.property) continue; const x = k % state.w, y = Math.floor(k / state.w); const img = this.scene.add.image(this.px(x), this.oy + (y + 1) * this.tile, this.tex.buildings, t.frame) .setOrigin(0.5, 1) .setScale(this.spriteScaleFor()) .setDepth(this.depths.actors + y); // strength readout, sitting just left of the building itself (right- // anchored so it grows leftward with digit count, always overlapping // the building's left edge by the same few pixels regardless of // whether it's showing "5" or "20") — mirrors units' HP digit on the // opposite side so the two never collide on a garrisoned property. const strength = this.scene.add.text( this.px(x) - this.tile / 2 + PROP_NUMBER_OVERLAP, this.oy + (y + 1) * this.tile - 2, '', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`, color: '#ffffff', stroke: '#000000', strokeThickness: 3, }).setOrigin(1, 1).setDepth(this.depths.actors + y + 0.25); this.propViews.set(k, { img, strength }); } this.refreshProps(); } refreshProps() { const goal = this.rules.constants.captureGoal; for (const [k, v] of this.propViews) { const owner = this.state.owner[k]; v.img.setTint(armyColorInt(this.rules, owner)); const hp = this.state.captureHp[k]; // a building under partial capture stays visibly squished at rest — // height is remaining-capture-points / captureGoal, animated live by // playCaptureAnim while a capture action is actively resolving. v.img.scaleY = this.spriteScaleFor() * (hp / goal); // strength number only shows below full — tinted toward the owner's // color (or light gray if neutral) but blended well toward white so // it stays legible over the black stroke outline. if (hp < goal) { v.strength.setText(String(hp)).setColor(toHexColor(lighten(armyColorInt(this.rules, owner), 0.6))).setVisible(true); } else { v.strength.setVisible(false); } } } // ── units ──────────────────────────────────────────────────────────────── // onlyIds, when given, restricts this pass to just those unit ids — any // other unit (known or not-yet-created) is left exactly as last shown. // Used by replayEvents to reveal a turn's units one action at a time // instead of snapping everyone straight to the turn's final result. Pass // nothing (or null) for a full sync — creates/updates/prunes everyone. syncUnits(onlyIds = null) { const { state, rules, scene } = this; const present = new Set(); for (const u of state.units) { present.add(u.id); const known = this.unitViews.has(u.id); if (!known && onlyIds && !onlyIds.has(u.id)) continue; // not revealed yet let v = this.unitViews.get(u.id); const justCreated = !v; if (justCreated) { const c = scene.add.container(0, 0); const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame) .setOrigin(0.5, 1).setScale(this.spriteScaleFor()); const hp = scene.add.text(this.tile * 0.30, -2, '', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`, color: '#ffffff', stroke: '#000000', strokeThickness: 3, }).setOrigin(0.5, 1); // sits directly above where the building's strength number renders // on this same tile (see refreshProps) — aligned to that number's // fixed right-edge anchor and a few pixels of padding above its top. const numberFontH = Math.max(13, this.tile * 0.34); const badgeH = this.tile * 0.4; const badge = scene.add.image( -this.tile / 2 + PROP_NUMBER_OVERLAP, -2 - numberFontH - 3 - badgeH / 2, this.tex.ui, UI_FRAMES.capture) .setScale(this.spriteScaleFor() * 0.4).setVisible(false); c.add([sprite, hp, badge]); v = { c, sprite, hp, badge }; this.unitViews.set(u.id, v); } if (justCreated || !onlyIds || onlyIds.has(u.id)) this.placeUnit(u, v); } // Stale-view cleanup only runs on a full sync — a scoped pass can't // tell a genuinely-dead unit from one whose own (not yet replayed) // death is later in this same turn's log; the 'destroyed'/'crashed' // event handlers already remove views at the right moment for that. if (!onlyIds) { for (const [id, v] of [...this.unitViews]) { if (!present.has(id)) { v.c.destroy(); this.unitViews.delete(id); } } } this.refreshProps(); } placeUnit(u, v = this.unitViews.get(u.id)) { if (!v) return; const { rules } = this; v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile).setDepth(this.depths.actors + u.y + UNIT_DEPTH_BIAS); v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep); v.sprite.setFlipX(u.army !== 0); const base = armyColorInt(rules, u.army); v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base); v.sprite.setAlpha(u.dived ? 0.5 : 1); const hpd = Logic.hpDisplay(u); v.hp.setText(hpd < 10 ? String(hpd) : ''); const spec = rules.unitById[u.type]; const lowFuel = u.fuel <= spec.fuel * 0.25 && (spec.dailyFuel ?? 0) > 0; const lowAmmo = spec.ammo > 0 && u.ammo <= 1; v.badge.setVisible(!!(u.capturing || lowFuel || lowAmmo || u.cargo.length)); if (u.capturing) v.badge.setFrame(UI_FRAMES.capture); else if (u.cargo.length) v.badge.setFrame(UI_FRAMES.star); else if (lowFuel) v.badge.setFrame(UI_FRAMES.fuel); else if (lowAmmo) v.badge.setFrame(UI_FRAMES.ammo); } setUnitVisibility(visibleIds) { for (const [id, v] of this.unitViews) { v.c.setVisible(visibleIds.has(id)); } } // ── overlays ───────────────────────────────────────────────────────────── showMoveRange(keys) { this.moveG.clear(); this.moveG.fillStyle(0x3d8bff, 0.38); for (const key of keys) { const x = key % this.state.w, y = Math.floor(key / this.state.w); this.moveG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2); } } showAttackTiles(tiles) { this.atkG.clear(); this.atkG.fillStyle(0xff3d3d, 0.42); for (const { x, y } of tiles) { this.atkG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2); } } clearOverlays() { this.moveG.clear(); this.atkG.clear(); this.pathG.clear(); } showPath(unit, path) { this.pathG.clear(); if (!path?.length) return; this.pathG.lineStyle(Math.max(4, this.tile * 0.16), 0xffe14d, 0.95); this.pathG.beginPath(); this.pathG.moveTo(this.px(unit.x), this.py(unit.y)); for (const s of path) this.pathG.lineTo(this.px(s.x), this.py(s.y)); this.pathG.strokePath(); const last = path[path.length - 1]; const prev = path.length > 1 ? path[path.length - 2] : { x: unit.x, y: unit.y }; const ang = Math.atan2(last.y - prev.y, last.x - prev.x); const r = this.tile * 0.24; this.pathG.fillStyle(0xffe14d, 0.95); this.pathG.beginPath(); this.pathG.moveTo(this.px(last.x) + Math.cos(ang) * r, this.py(last.y) + Math.sin(ang) * r); this.pathG.lineTo(this.px(last.x) + Math.cos(ang + 2.5) * r, this.py(last.y) + Math.sin(ang + 2.5) * r); this.pathG.lineTo(this.px(last.x) + Math.cos(ang - 2.5) * r, this.py(last.y) + Math.sin(ang - 2.5) * r); this.pathG.closePath(); this.pathG.fillPath(); } setCursor(tile) { if (!tile) { this.cursor.setVisible(false); return; } this.cursor.setVisible(true).setPosition(this.px(tile.x), this.py(tile.y)); } refreshFog(army) { this.fogG.clear(); if (!this.state.fog) { this.setUnitVisibility(new Set(this.state.units.map((u) => u.id))); return; } const vis = Logic.computeVision(this.rules, this.state, army); this.fogG.fillStyle(0x0a0c18, 0.45); for (let k = 0; k < this.state.terrain.length; k++) { if (vis.has(k)) continue; const x = k % this.state.w, y = Math.floor(k / this.state.w); this.fogG.fillRect(this.ox + x * this.tile, this.oy + y * this.tile, this.tile, this.tile); } const spotted = new Set(Logic.visibleUnits(this.rules, this.state, army).map((u) => u.id)); this.setUnitVisibility(spotted); } // quick explosion puff at a tile boom(x, y, onDone) { const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1) .setScale(this.spriteScaleFor()).setDepth(this.depths.cursor); this.scene.time.delayedCall(200, () => img.setFrame(UI_FRAMES.explosion2)); this.scene.time.delayedCall(450, () => { img.destroy(); onDone?.(); }); } // animate one unit view along a path (state already updated) animateMove(unitId, path, onDone) { const v = this.unitViews.get(unitId); if (!v || !path?.length) { onDone?.(); return; } const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile, row: s.y })); let i = 0; const step = () => { if (i >= points.length) { onDone?.(); return; } const p = points[i++]; v.c.setDepth(this.depths.actors + p.row + UNIT_DEPTH_BIAS); this.scene.tweens.add({ targets: v.c, x: p.x, y: p.y, duration: 180, onComplete: step, }); }; step(); } } function darken(color, f) { const r = Math.floor(((color >> 16) & 0xff) * f); const g = Math.floor(((color >> 8) & 0xff) * f); const b = Math.floor((color & 0xff) * f); return (r << 16) | (g << 8) | b; } // blend a color toward white by f (0 = unchanged, 1 = pure white) — used to // keep team-colored text pale enough to stay legible over its stroke. function lighten(color, f) { const r = Math.round(((color >> 16) & 0xff) + (255 - ((color >> 16) & 0xff)) * f); const g = Math.round(((color >> 8) & 0xff) + (255 - ((color >> 8) & 0xff)) * f); const b = Math.round((color & 0xff) + (255 - (color & 0xff)) * f); return (r << 16) | (g << 8) | b; } function toHexColor(n) { return `#${n.toString(16).padStart(6, '0')}`; }