fertig-classic-games/src/games/advancewars/AdvanceWarsMapView.js

595 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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')}`; }