595 lines
27 KiB
JavaScript
595 lines
27 KiB
JavaScript
// 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')}`; }
|