Files
ark-stronghold-protocol/public/js/render/units.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

1238 lines
58 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.
// render/units.js — per-unit views for battle units and prep pieces (DESIGN §9).
//
// UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or
// the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar
// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and
// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
//
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. Operators and summons face their deploy direction `dir`
// (research 09 §1.2, DESIGN §3: UP|RIGHT|DOWN|LEFT, chosen with the direction wheel; UnitInfo / piece `dir`, else
// the legacy `facing` ±1): the Back model for UP (when one exists, research 07 §5.5), the Front model for RIGHT and
// DOWN, mirrored for LEFT; the orange ground wedge "›" of prep board pieces points along `dir` (rotated on the ground
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis).
//
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
//
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
// with an HP bar once damaged.
//
// Cost control (low-end devices): a unit whose body is outside the viewport (`ctx.viewport()`, with a margin) is not
// animated or drawn at all (its clock catches up, ≤ 0.5 s, when it comes back); `opts.lod: 'idle'` (the enemy preview
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does.
//
// Picking is by tile (render/pick.js, user playtest #4 item 1): views carry no hit shapes; `bounds()` is the drawn body's
// screen rect for tooltips and overlays (view.pieceScreenRect). A dragged (lifted) item plate is drawn centred on its
// ground point, i.e. on the pointer (render/app.js).
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
/** Deploy direction of an ally from a UnitInfo / piece: `dir` (any case), else the legacy `facing` sign. */
export function unitDir(info) {
const d = typeof info?.dir === 'string' ? info.dir.toUpperCase() : null;
if (d && DIRS.includes(d)) return d;
return info?.facing === -1 ? 'LEFT' : 'RIGHT';
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
const RAISED_Z = 0.12;
/** Flying units hover this many tiles above the ground they cross. */
export const FLY_HOVER = 0.32;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/** Knocked-down look: model tint and alpha; redeploy ring colours per state; ring size (tiles) and height. */
export const DOWN_LOOK = Object.freeze({
tint: 0xb4b4b4, alpha: 0.92,
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
*/
export function placeOnGround(ctx, obj, fallback, y, z) {
let layer = fallback;
if (z > RAISED_Z && ctx.surfaceLayer) layer = ctx.surfaceLayer(Math.round(y)) || fallback;
if (obj.parent !== layer) layer.addChild(obj);
}
/** Standing height of a world point (tile top incl. raised devices); 0 off-grid / without a stage. */
export function groundZ(ctx, x, y) {
if (!ctx.heightAt) return 0;
const h = ctx.heightAt(Math.round(y), Math.round(x));
return Number.isFinite(h) && h > 0 ? h : 0;
}
/** Enemy Spine models face right by default like operators (verified by eye on Ark-Models skeletons). */
export const ENEMY_MODEL_FACES_LEFT = false;
let _whiteTex = null;
const white = () => (_whiteTex || (_whiteTex = globalThis.PIXI.Texture.WHITE));
function bar(P, parent, color, alpha = 1) {
const s = new P.Sprite(white());
s.tint = color;
s.alpha = alpha;
s.anchor.set(0, 0.5);
parent.addChild(s);
return s;
}
/**
* Shared context handed to every view by app.js.
* @typedef {{ P: any, layers: { shadow: any, units: any, bars: any, groundFx: any },
* assets: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number,
* settings: { damageNumbers: boolean, quality: string } }} ViewCtx
*/
export class UnitView {
/**
* @param {ViewCtx} ctx
* @param {object} info UnitInfo-like: { id, uid?, kind, side, defId, spine, avatar, tier, golden, facing, maxHp, boss?, motion?, name? }
* @param {{ prep?: boolean }} [opts]
*/
constructor(ctx, info, opts = {}) {
const P = ctx.P;
this.ctx = ctx;
this.P = P;
this.info = { ...info };
this.id = info.id;
this.uid = info.uid ?? null;
this.prep = !!opts.prep;
this.lodIdle = opts.lod === 'idle';
this.culled = false; // outside the viewport this frame (not animated, not drawn)
this._offDt = 0; // animation time skipped while culled
this._far = false; // small on screen (adaptive LOD, with hysteresis)
this.isEnemy = info.side === 'enemy';
this.isBoss = !!info.boss;
this.isToken = info.kind === 'token';
this.golden = !!info.golden;
this.tier = clamp(Number(info.tier) || 1, 1, 6);
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit)
this.flying = info.motion === 'FLY';
this.hover = 0; // flying: body height above the ground under it
this.dir = this.isEnemy ? null : unitDir(info);
// whether the direction is known (UnitInfo / piece `dir`), not just the legacy ±1: battle and scouting views show
// the ground wedge only then (a derived RIGHT would mislabel an UP / DOWN operator)
this.hasDir = !this.isEnemy && typeof info.dir === 'string' && DIRS.includes(info.dir.toUpperCase());
this.facing = this.dir === 'LEFT' ? -1 : 1;
this.visFacing = this.isEnemy ? -1 : this.facing;
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
this.sp = 0; this.spMax = 0;
this.flags = 0; this.anim = ANIM.IDLE;
this.statuses = new Set();
this.alive = true;
this.dying = 0; // seconds left of the death fade (0 = not dying)
this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
this.remove = false; // set when the death fade ended (owner removes the view)
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elRing = null; // { track, disc, arc } sprites, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
this.flash = 0;
this.bob = Math.random() * Math.PI * 2;
this.hovered = false; this.dimmed = false; this.lift = 0;
this.lastAtk = -1; this.atkInterval = defaultInterval(ctx, info);
this.shake = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.destroyed = false;
// --- display objects
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
this.shadow.anchor.set(0.5);
this.shadow.alpha = 0.55;
ctx.layers.shadow.addChild(this.shadow);
this.root = new P.Container();
this.root.sortableChildren = false;
ctx.layers.units.addChild(this.root);
const fx = fxAtlas();
this.aura = null;
if (this.golden) {
this.aura = new P.Sprite(fx.tex.glow);
this.aura.anchor.set(0.5, 0.62);
this.aura.tint = 0xffc94a;
this.aura.blendMode = P.BLEND_MODES.ADD;
this.root.addChild(this.aura);
}
this.body = new P.Container();
this.root.addChild(this.body);
this.fallback = new P.Sprite(P.Texture.EMPTY);
this.fallback.anchor.set(0.5, 1);
this.body.addChild(this.fallback);
this.actor = null;
this.spineReady = false;
this.hud = new P.Container();
ctx.layers.bars.addChild(this.hud);
this._buildHud();
this.facingArrow = null;
this._loadPicture();
this._loadSpine();
}
// ---- loading ---------------------------------------------------------------------------------------------
_frameColor() {
if (this.isEnemy) return this.isBoss ? ENEMY_FRAME.boss : this.tier >= 2 ? ENEMY_FRAME.elite : ENEMY_FRAME.normal;
if (this.golden) return 0xffc600;
return TIER_COLORS[this.tier] || TIER_COLORS[1];
}
// The fallback portrait (avatar in a rarity diamond, a 160×160 canvas cached by textures.js) is built lazily, on
// the first frame it is actually visible: never for a unit whose Spine model is ready before that, and never as
// an image-less placeholder that the avatar replaces a moment later (the placeholder only shows when the avatar
// is missing or still loading after PIC_WAIT_MS).
_loadPicture() {
const a = this.ctx.assets;
const url = a && (a.picture ? a.picture(this.info.avatar) || a.picture(this.info.defId) || a.picture(this.info.spine) : null);
this._pic = { key: String(this.info.avatar || this.info.defId || 'unknown'), color: this._frameColor(), img: null, state: 'none', shown: null, t0: nowMs() };
if (!url || !a.image) return;
const cached = typeof a.imageNow === 'function' ? a.imageNow(url) : null;
if (cached) { this._pic.img = cached; this._pic.state = 'img'; return; }
this._pic.state = 'wait';
const pic = this._pic;
Promise.resolve().then(() => a.image(url)).then((img) => {
if (img) { pic.img = img; pic.state = 'img'; } else pic.state = 'none';
}, () => { pic.state = 'none'; });
}
/** Put the right diamond on the fallback sprite (called while the fallback is visible). */
_ensurePicture() {
const pic = this._pic;
if (!pic || this.destroyed) return;
let want = pic.state === 'img' ? 'img' : 'placeholder';
if (pic.state === 'wait' && nowMs() - pic.t0 < PIC_WAIT_MS) want = null;
if (!want || pic.shown === want) return;
this.fallback.texture = diamondTexture(pic.key, want === 'img' ? pic.img : null, pic.color, { enemy: this.isEnemy, golden: this.golden });
pic.shown = want;
}
_loadSpine() {
const a = this.ctx.assets;
if (!a || !a.spineEntry || !a.spine) return;
const id = this.info.spine || this.info.defId;
// Front/Back rule (research 07 §5.5 / 09 §1.2): Front facing right/down (mirrored for left), Back facing up.
const back = this._wantsBack();
const entry = id ? a.spineEntry(id, { back }) : null;
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
// every acquire is paired with exactly one release: a superseded / failed / post-destroy load releases its own
// entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or destroyed
this.entry = entry;
this.entryBack = back;
const req = this._spineReq = (this._spineReq || 0) + 1;
a.spine.acquire(entry).then((data) => {
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
let actor = null;
try {
actor = new SpineActor(data, entry);
actor.setSkillIndex(this.info.skillIndex);
} catch (err) {
console.warn('[render] spine build failed', id, err?.message || err);
this._releaseEntry(entry);
return;
}
// a Front ⇄ Back swap (setDir) replaces the previous model in place: no fallback diamond in between
const swap = !!this.actor;
if (swap) this._dropActor();
this.actor = actor;
this._actorEntry = entry;
this.body.addChild(this.actor.spine);
this.spineReady = true;
this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0;
// replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
if (!this.alive) {
const d = this.actor.die();
const at = Math.min(d, this.dieT * (this.ctx.animRate?.() || 1));
if (at > 0) this.actor.update(at);
} else {
if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim());
}
}, () => { this._releaseEntry(entry); /* keep the fallback */ });
}
// ---- HUD -------------------------------------------------------------------------------------------------
_buildHud() {
const P = this.P, h = this.hud;
this.hpBg = bar(P, h, COLORS.hpBack, 0.85);
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly);
this.shieldBar = bar(P, h, COLORS.shield, 0.95);
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
this.spFill = bar(P, h, COLORS.sp);
this.spGlow = new P.Sprite(fxAtlas().tex.glow);
this.spGlow.anchor.set(0.5);
this.spGlow.tint = COLORS.spReady;
this.spGlow.blendMode = P.BLEND_MODES.ADD;
h.addChild(this.spGlow);
// nothing shows until the first HUD update decides (a culled or prep view never draws bars)
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
this.chip = null;
// operators only: summon tokens have no tier (hand and field alike)
if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') {
this.chip = new P.Sprite(tierChip(this.tier, this.golden));
this.chip.anchor.set(0.5);
h.addChild(this.chip);
}
this.icons = [];
for (let i = 0; i < 4; i++) {
const s = new P.Sprite(P.Texture.EMPTY);
s.anchor.set(0.5);
s.visible = false;
h.addChild(s);
this.icons.push(s);
}
this.blockIcon = new P.Sprite(statusTexture('blocked'));
this.blockIcon.anchor.set(0.5);
this.blockIcon.visible = false;
this.ctx.layers.groundFx.addChild(this.blockIcon);
this.countText = null;
this.itemPips = [];
}
/** Show a stack count (token stacks in the hand) — BitmapText-free: a tiny canvas-less chip via Text. */
setCount(n) {
const P = this.P;
if (!(n > 1)) { if (this.countText) this.countText.visible = false; return; }
if (!this.countText) {
this.countText = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 22, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 5 });
this.countText.anchor.set(0.5);
this.hud.addChild(this.countText);
}
this.countText.text = `×${n}`;
this.countText.visible = true;
}
/** Equipped item pips (prep): list of item icon URLs. */
setItems(urls) {
const P = this.P;
const list = Array.isArray(urls) ? urls.slice(0, 2) : [];
while (this.itemPips.length > list.length) this.itemPips.pop().destroy();
list.forEach((u, i) => {
let s = this.itemPips[i];
if (!s) { s = new P.Sprite(P.Texture.EMPTY); s.anchor.set(0.5); this.hud.addChild(s); this.itemPips[i] = s; }
if (s._url === u) return;
s._url = u;
const a = this.ctx.assets;
s.texture = itemTexture(u || 'none', null, 0x4ed8af);
if (u && a?.image) a.image(u).then((img) => { if (!s.destroyed && s._url === u) s.texture = itemTexture(u, img, 0x4ed8af); }, () => {});
});
}
// ---- state input ---------------------------------------------------------------------------------------
_baseFromAnim() {
if (this.anim === ANIM.STUN || (this.flags & (UF.STUNNED | UF.FROZEN | UF.SLEEP))) return 'stun';
if (this.anim === ANIM.MOVE) return 'move';
return 'idle';
}
/** Apply an interpolated sample (render/interp.js); `t` = the render clock's game time (ring countdowns). */
sync(s, t) {
if (!s) return;
if (Number.isFinite(t)) this.gameT = t;
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y;
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up)
const gz = this.isEnemy && !this.flying ? 0 : groundZ(this.ctx, s.x, s.y);
if (this.zTarget == null) this.z = gz;
this.zTarget = gz;
if (s.maxHp > 0) this.maxHp = s.maxHp;
const hp = clamp(s.hp, 0, this.maxHp);
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
this.hp = hp;
this.sp = s.sp; this.spMax = s.spMax;
const prevFlags = this.flags;
this.flags = s.flags | 0;
this.anim = s.anim | 0;
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
if (this.anim === ANIM.DIE && this.alive) this.die();
if (this.actor && this.alive) this.actor.setBase(this._baseFromAnim());
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
setFacing(f) {
if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; }
// the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction
if (f === -1) this.setDir('LEFT');
else if (this.dir === 'LEFT') this.setDir('RIGHT');
}
/** Whether this unit should show its Back model (facing UP and a Back model exists). */
_wantsBack() {
const a = this.ctx.assets;
const id = this.info.spine || this.info.defId;
return !this.isEnemy && this.dir === 'UP' && !!id && !!a && typeof a.hasBack === 'function' && !!a.hasBack(id);
}
/**
* Re-orient an operator / summon (UP|RIGHT|DOWN|LEFT, any case; the direction wheel's live preview and the stored
* m.private `dir`). Mirrors for LEFT, swaps to the Back model for UP (and back) when the model differs.
*/
setDir(dir) {
if (this.isEnemy || this.destroyed) return;
const d = unitDir({ dir, facing: this.facing });
if (typeof dir !== 'string' || d === this.dir) return;
this.dir = d;
this.info.dir = d;
this.hasDir = true;
this.facing = d === 'LEFT' ? -1 : 1;
this.visFacing = this.facing;
if (this.imp) this.imp.dirty = true;
if (this.entry && this._wantsBack() !== !!this.entryBack) this._loadSpine();
}
_releaseEntry(entry) {
try { this.ctx.assets?.spine?.release(entry); } catch { /* ignore */ }
}
/** Destroy the current Spine actor (and its impostor) and release its asset entry (model swap / destroy). */
_dropActor() {
if (this.imp) {
try { if (this.imp.slot) this.ctx.impostors?.free(this.imp.slot); this.imp.sprite.destroy(); if (this.imp.rt) this.imp.rt.destroy(true); } catch { /* ignore */ }
this.imp = null;
}
this._box = null;
const old = this.actor;
this.actor = null;
this.spineReady = false;
if (old) { try { old.destroy(); } catch { /* ignore */ } }
if (this._actorEntry) this._releaseEntry(this._actorEntry);
this._actorEntry = null;
}
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now) {
if (!this.alive) return;
if (this.lastAtk >= 0) {
const d = now - this.lastAtk;
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
}
this.lastAtk = now;
if (target && !this.isEnemy && this.info.kind !== 'device') {
// operators keep their deploy direction (research 09 §1.2); enemies may turn towards their target
} else if (target && this.isEnemy) {
const dx = target.x - this.x;
if (Math.abs(dx) > 0.1) this.visFacing = dx < 0 ? -1 : 1;
}
if (target) {
const dx = target.x - this.x, dy = target.y - this.y, len = Math.hypot(dx, dy) || 1;
this.lungeDir.x = dx / len; this.lungeDir.y = dy / len;
}
this.lunge = 1;
if (this.actor) this.actor.attack(this.atkInterval); // game seconds: the actor's clock runs in game time
if (this.imp) this.imp.dirty = true;
}
/**
* An attack by this unit is `lead` game seconds ahead in the snapshot buffer: start the Spine attack wind-up now
* so the strike frame lines up with the attack. True once started (then stop calling for that attack).
*/
windUp(lead) {
if (!this.alive || !this.actor || !this.spineReady) return false;
const ok = this.actor.windUp(this.atkInterval, lead);
if (ok && this.imp) this.imp.dirty = true;
return ok;
}
onHit() { this.flash = 1; }
setSkill(on) {
if (on) this.statuses.add('skill'); else this.statuses.delete('skill');
if (this.actor) this.actor.setSkill(on);
}
onDeploy() {
this.fadeIn = 0;
if (!this.alive) this.revive();
if (this.actor) this.actor.deploy();
}
onStatus(key, on) {
if (typeof key !== 'string') return;
if (on) this.statuses.add(key); else this.statuses.delete(key);
}
die() {
if (!this.alive) return;
this.alive = false;
const d = this.actor ? this.actor.die() : 0;
const rate = this.ctx.animRate?.() || 1;
this.dieT = 0;
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
this.dieDur = this.dying;
}
revive() {
this.alive = true;
this.dying = 0; this.remove = false; this.alpha = 1;
this.down = null;
this.hp = this.maxHp; this.ghostHp = this.hp;
if (this.actor) this.actor.revive();
}
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
return;
}
if (this.alive) {
this.die();
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
dn.until = Number(d[1]) || 0;
dn.total = Math.max(0, Number(d[2]) || 0);
dn.state = d[3] | 0;
}
// ---- per frame -------------------------------------------------------------------------------------------
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
update(dt, cam, t) {
if (this.destroyed) return;
const P = this.P;
if (this.zTarget != null && this.z !== this.zTarget) {
const d = this.zTarget - this.z;
this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12);
}
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
const s = p.s;
// fades
if (this.fadeIn < 1) this.fadeIn = Math.min(1, this.fadeIn + dt * 4);
let alpha = this.fadeIn;
if (this.down) {
// knocked down: the Die clip ends and holds its last frame (the actor keeps its clock), no fade
this.dieT += dt;
alpha *= DOWN_LOOK.alpha;
} else if (this.dying > 0) {
this.dieT += dt;
this.dying -= dt;
const tail = 0.55;
if (this.dying < tail) alpha *= Math.max(0, this.dying / tail);
if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; }
}
if (this.flags & UF.STEALTH) alpha *= 0.45;
if (this.dimmed) alpha *= 0.35;
this.alpha = alpha;
// body placement
const lungeK = this.lunge > 0 ? Math.sin(this.lunge * Math.PI) * 0.12 : 0;
this.lunge = Math.max(0, this.lunge - dt * 5);
const lx = this.lungeDir.x * lungeK, ly = this.lungeDir.y * lungeK;
let bx = p.x, by = p.y;
if (lungeK) { const q = cam.project(this.x + lx, this.y + ly, this.z + this.hover + this.lift, LG_P); bx = q.x; by = q.y; }
this.root.position.set(bx, by);
this.root.alpha = alpha;
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
if (this._cull(bx, by, s, dt)) return;
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
const sh = cam.project(this.x, this.y, this.z, SH_P);
this.shadow.position.set(sh.x, sh.y);
const shw = s * (this.isBoss ? 1.6 : 0.95) / this.shadow.texture.width;
this.shadow.scale.set(shw, shw * (this.shadow.texture === shadowTexture() ? 1 : 1.05));
this.shadow.alpha = 0.5 * alpha * (this.lift > 0 ? 0.6 : 1);
// model / fallback
const spineShown = this.actor && this.spineReady;
if (spineShown) {
if (this.swapT < 1) this.swapT = Math.min(1, this.swapT + dt * 5);
this.fallback.alpha = 1 - this.swapT;
this.fallback.visible = this.swapT < 1;
const sc = s * UNIT.modelScale;
const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff;
if (this.down) tint = DOWN_LOOK.tint;
else if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
else if (this.flags & UF.COLD) tint = 0xcfe6ff;
if (flashK > 0) tint = mixTint(tint, 0xff8a80, flashK * 0.8);
let animDt = dt * (this.ctx.animRate?.() || 1);
if (this._offDt > 0) { animDt += Math.min(0.5, this._offDt); this._offDt = 0; }
let interval = this.ctx.impostorInterval ? this.ctx.impostorInterval() : 0;
if (this.lodIdle) interval = Math.max(interval, 3);
const lvl = this.ctx.loadLevel ? this.ctx.loadLevel() : 0;
if (lvl > 0) {
this._far = this._far ? s < 60 : s < 52;
if (lvl >= 2 || this._far) interval = Math.max(interval, 2);
}
if (this.actor.clipped) {
const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true;
this.actor.setClipping(clip);
if (clip && this.ctx.impostors) interval = Math.max(1, interval);
}
if (interval > 0 && this.ctx.renderer) {
this._updateImpostor(sc, flip, tint, animDt, interval);
} else {
if (this.imp) this._leaveImpostor();
this.actor.spine.alpha = this.swapT;
this.actor.spine.scale.set(sc * flip, sc);
this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
}
}
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
if (!spineShown) this._ensurePicture();
if (!spineShown || this.swapT < 1) {
const size = s * UNIT.diamond * (this.isBoss ? 1.5 : 1);
const bob = this.alive ? Math.sin(t * 2.4 + this.bob) * s * 0.03 : 0;
this.fallback.scale.set(size / 160);
this.fallback.position.set(0, -s * 0.08 + bob);
this.fallback.tint = this.down ? DOWN_LOOK.tint : this.flash > 0 ? mixTint(0xffffff, 0xff8a80, this.flash) : (this.flags & UF.FROZEN ? 0x9fd4ff : 0xffffff);
if (!this.alive) this.fallback.alpha = Math.max(0, this.fallback.alpha);
}
this.flash = Math.max(0, this.flash - dt * 6);
// facing chevron on the ground, like the original's orange › (research 09 §1.2: prep and combat): own prep pieces
// on the board (app.js sets _showFacing; bench pieces have none); battle / scouting allies whose dir is known
const wedge = this._showFacing !== undefined ? this._showFacing && this.prep : this.hasDir && this.info.kind !== 'device';
if (wedge && this.alive) {
if (!this.facingArrow) {
this.facingArrow = new P.Sprite(fxAtlas().tex.chevron);
this.facingArrow.anchor.set(0.5);
this.facingArrow.tint = 0xff9c33;
this.ctx.layers.groundFx.addChild(this.facingArrow);
}
placeOnGround(this.ctx, this.facingArrow, this.ctx.layers.groundFx, this.y, this.z);
// on the ground ring, 0.36 tile out along the deploy direction; rotated to the projected direction and
// foreshortened along it (the chevron texture points right: local x = the pointing axis)
const [sx, sy] = DIR_STEP[this.dir] || DIR_STEP.RIGHT;
const c0 = cam.project(this.x, this.y, this.z + 0.01, FA_C);
const fa = cam.project(this.x + 0.36 * sx, this.y + 0.36 * sy, this.z + 0.01, FA_P);
const vx = fa.x - c0.x, vy = fa.y - c0.y;
const len = Math.hypot(vx, vy) || 1;
this.facingArrow.visible = this.lift <= 0;
this.facingArrow.position.set(fa.x, fa.y);
this.facingArrow.rotation = Math.atan2(vy, vx);
const fs = (s * 0.22) / 64;
const along = clamp(len / (s * 0.36 || 1), 0.35, 1.2);
this.facingArrow.scale.set(fs * along, fs * (sy ? 1 : 0.8));
this.facingArrow.alpha = 0.9 * alpha;
} else if (this.facingArrow) this.facingArrow.visible = false;
// elite aura
if (this.aura) {
const k = 0.35 + 0.12 * Math.sin(t * 2.2 + this.bob);
this.aura.alpha = k;
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
}
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known)
let headTiles = UNIT.headroom;
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
this._headTiles = headTiles;
this.screen.top = by - headTiles * s;
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
}
/**
* Viewport culling: true (and everything hidden) while the unit's body lies outside the viewport with a margin.
* Dying units are never culled (their fade must finish and remove the view).
*/
_cull(bx, by, s, dt) {
const vp = this.ctx.viewport ? this.ctx.viewport() : null;
let off = false;
if (vp && !(this.dying > 0) && !this.lift) {
const m = s * 1.3;
off = bx < -m || bx > vp.width + m || by < -m * 0.5 || by - s * 3.6 > vp.height;
}
if (off !== this.culled) {
this.culled = off;
this.root.visible = !off;
this.hud.visible = !off;
this.shadow.visible = !off;
if (off) { if (this.facingArrow) this.facingArrow.visible = false; this.blockIcon.visible = false; }
}
if (off) this._offDt += dt * (this.ctx.animRate?.() || 1);
return off;
}
_updateHud(dt, s, x, y, alpha, t) {
const prep = this.prep;
const showBars = !prep && this.alive && this.info.kind !== 'item';
const damaged = this.hp < this.maxHp - 0.5;
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
this.hud.alpha = this.down ? this.fadeIn : this.dying > 0 ? 0 : alpha;
let sx = this.shake > 0 ? Math.sin(t * 90) * this.shake * 10 : 0;
this.shake = Math.max(0, this.shake - dt);
const x0 = x - bw / 2 + sx;
let cy = y - 4;
// HP
this.hpBg.visible = this.hpFill.visible = this.hpGhost.visible = showHp;
if (showHp) {
const k = this.maxHp > 0 ? clamp(this.hp / this.maxHp, 0, 1) : 0;
if (this.ghostHp < this.hp) this.ghostHp = this.hp;
else this.ghostHp = Math.max(this.hp, this.ghostHp - this.maxHp * dt * 0.9);
const g = this.maxHp > 0 ? clamp(this.ghostHp / this.maxHp, 0, 1) : 0;
this.hpBg.position.set(x0 - 1, cy); this.hpBg.width = bw + 2; this.hpBg.height = bh + 2;
this.hpGhost.position.set(x0, cy); this.hpGhost.width = bw * g; this.hpGhost.height = bh;
this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh;
if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly;
}
const shielded = showHp && (this.flags & UF.SHIELD);
this.shieldBar.visible = !!shielded;
if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); }
// SP
const showSp = showBars && !this.isEnemy && this.spMax > 0;
this.spBg.visible = this.spFill.visible = showSp;
const spH = Math.max(2, bh * 0.6);
let ready = false;
if (showSp) {
const active = !!(this.flags & UF.SKILL);
const k = clamp(this.sp / this.spMax, 0, 1);
ready = !active && k >= 0.999;
const sy = cy + bh / 2 + spH / 2 + 1.5;
this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2;
this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH;
this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this._spY = sy;
}
this.spGlow.visible = ready;
if (ready) {
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
this.spGlow.position.set(x0 + bw, this._spY);
this.spGlow.scale.set((spH * 5) / 128);
this.spGlow.alpha = pulse;
}
// tier chip (left of the bars in battle; above the head in prep)
if (this.chip) {
const cs = clamp(s * (prep ? 0.24 : 0.19), 11, 28) / 44;
this.chip.scale.set(cs);
this.chip.visible = this.alive;
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
}
// status icons row above the bars
const icons = this._iconKeys();
const isz = clamp(s * 0.26, 12, 26);
const iy = cy - bh / 2 - isz / 2 - 3;
for (let i = 0; i < this.icons.length; i++) {
const ic = this.icons[i];
const key = icons[i];
if (!key || !this.alive || prep) { ic.visible = false; continue; }
const tex = statusTexture(key);
if (!tex) { ic.visible = false; continue; }
ic.texture = tex;
ic.visible = true;
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
this.blockIcon.visible = !!blocked;
if (blocked) {
const f = this.screen;
this.blockIcon.position.set(f.x + (this.visFacing < 0 ? -1 : 1) * s * 0.34, SH_P.y - s * 0.04);
this.blockIcon.scale.set(clamp(s * 0.22, 9, 22) / 32 * (this.visFacing < 0 ? -1 : 1), clamp(s * 0.22, 9, 22) / 32);
this.blockIcon.alpha = 0.85 * alpha;
}
// count badge & item pips (prep)
if (this.countText && this.countText.visible) {
this.countText.scale.set(clamp(s / 90, 0.5, 1.2));
this.countText.position.set(x + s * 0.32, this.screen.y - s * 0.12);
}
for (let i = 0; i < this.itemPips.length; i++) {
const pip = this.itemPips[i];
const ps = clamp(s * 0.26, 12, 30);
pip.width = pip.height = ps;
pip.position.set(this.screen.x - s * 0.36 + i * (ps + 1), this.screen.y - s * 0.05);
pip.visible = this.alive;
}
}
/**
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
*/
_updateElementRing(show, xl, cy, s) {
let r = this._elRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
this.hud.addChild(root);
}
const cooling = this.elDur > 0 && this.elUntil > 0;
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
r.arc.texture = ringArc(frac);
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
r.root.scale.set(d / tex.size);
r.root.position.set(xl + d / 2 + 2, cy);
r.root.visible = true;
}
/**
* Redeploy ring above a knocked-down operator (see header), centred on (cx, cy): a mint arc filling as its respawn
* timer runs with the seconds left; a full pulsing amber ring with "DP" / red ring with "!" once it only waits.
*/
_updateDownRing(show, cx, cy, s, t) {
let r = this._downRing;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
const disc = mk(tex.downDisc), track = mk(tex.track), arc = mk(tex.arcs[0]);
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
text.anchor.set(0.5);
root.addChild(text);
r = this._downRing = { root, disc, track, arc, text, label: null };
this.hud.addChild(root);
}
const dn = this.down;
const counting = dn.state === DOWN_STATE.COUNTING;
const left = Math.max(0, dn.until - this.gameT);
r.arc.texture = ringArc(counting ? (dn.total > 0 ? clamp(1 - left / dn.total, 0, 1) : 1) : 1);
const color = DOWN_LOOK.ring[dn.state] ?? DOWN_LOOK.ring[DOWN_STATE.COUNTING];
r.arc.tint = color;
const label = counting ? String(Math.ceil(left - 1e-6)) : dn.state === DOWN_STATE.WAIT_DP ? 'DP' : '!';
if (r.label !== label) {
r.label = label;
r.text.text = label;
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
}
const d = clamp(s * DOWN_LOOK.size, 22, 52);
const k = d / tex.size;
r.disc.scale.set(k); r.track.scale.set(k); r.arc.scale.set(k);
r.text.scale.set((d * (label.length > 2 ? 0.34 : 0.44)) / 32);
r.root.position.set(cx, cy);
r.root.alpha = counting ? 1 : 0.72 + 0.28 * Math.sin(t * 5);
r.root.visible = true;
}
// ---- impostor mode (crowded fields, clipped skeletons): the skeleton is rendered into a slot of the shared
// impostor atlas (render/impostor.js) every `interval` frames (staggered per unit; every frame for clipped
// skeletons, whose stencil masks must stay out of the main pass) and shown as one sprite. Without a free atlas
// slot the unit keeps a private RenderTexture (same visuals, one extra framebuffer switch).
_impBox() {
if (this._box) return this._box;
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
let x0 = -220, y0 = -420, x1 = 220, y1 = 40;
if (b && Number.isFinite(b.width) && b.width > 10 && b.height > 10) {
const padX = Math.max(60, b.width * 0.3), padY = Math.max(50, b.height * 0.22);
x0 = Math.min(b.x - padX, -140); x1 = Math.max(b.x + b.width + padX, 140);
y0 = Math.min(b.y - padY, -260); y1 = Math.max(b.y + b.height + padY * 0.4, 30);
}
this._box = { x0, y0, w: x1 - x0, h: y1 - y0 };
return this._box;
}
_updateImpostor(sc, flip, tint, animDt, interval) {
const P = this.P;
const atlas = this.ctx.impostors || null;
if (!this.imp) {
const sprite = new P.Sprite(P.Texture.EMPTY);
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true };
}
const imp = this.imp;
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12;
if (due) {
this.actor.update(imp.acc);
imp.acc = 0;
imp.dirty = false;
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;
imp.sprite.scale.set(k * flip, k);
imp.sprite.alpha = this.swapT;
imp.sprite.tint = tint;
}
_renderImpostor(sc, atlas) {
const P = this.P, R = this.ctx.renderer, imp = this.imp;
const box = this._impBox();
const w = Math.max(8, Math.ceil(box.w * sc)), h = Math.max(8, Math.ceil(box.h * sc));
const sp = this.actor.spine;
sp.alpha = 1;
if (this._tint !== 0xffffff) { this._tint = 0xffffff; sp.tint = 0xffffff; }
const ox = -box.x0 * sc, oy = -box.y0 * sc;
if (atlas) {
let slot = imp.slot;
const clip = !!(this.actor.clipped && this.actor.clipOn);
if (!slot || w > slot.w || h > slot.h || w < slot.w * 0.6 || h < slot.h * 0.6 || slot.clip !== clip) {
if (slot) atlas.free(slot);
slot = imp.slot = atlas.alloc(w, h, { clip });
if (slot && imp.rt) { imp.rt.destroy(true); imp.rt = null; }
}
if (slot) {
atlas.draw(slot, sp, { a: sc, d: sc, tx: ox, ty: oy });
if (imp.sprite.texture !== slot.tex) imp.sprite.texture = slot.tex;
imp.sprite.anchor.set(ox / slot.w, oy / slot.h);
imp.sc = sc;
return;
}
}
// private render target (no atlas / atlas full)
let rt = imp.rt;
if (!rt || w > rt.width || h > rt.height || w < rt.width * 0.6 || h < rt.height * 0.6) {
if (rt) rt.destroy(true);
rt = imp.rt = P.RenderTexture.create({ width: Math.ceil(w / 8) * 8, height: Math.ceil(h / 8) * 8, resolution: R.resolution });
imp.sprite.texture = rt;
}
const parent = sp.parent;
sp.position.set(0, 0);
sp.scale.set(1, 1);
sp.visible = true;
const m = this._m || (this._m = new P.Matrix());
m.set(sc, 0, 0, sc, ox, oy);
try { R.render(sp, { renderTexture: rt, clear: true, transform: m }); } catch { /* lost context etc. */ }
if (parent === atlas?.parked) sp.visible = false;
imp.sprite.anchor.set(ox / rt.width, oy / rt.height);
imp.sc = sc;
}
_leaveImpostor() {
const imp = this.imp;
this.imp = null;
if (!imp) return;
const atlas = this.ctx.impostors || null;
if (imp.slot && atlas) atlas.free(imp.slot);
imp.sprite.destroy();
if (imp.rt) imp.rt.destroy(true);
if (this.actor && this.actor.spine) {
if (atlas) atlas.unpark(this.actor.spine);
this.actor.spine.visible = true;
this.actor.spine.position.set(0, 0);
this.body.addChild(this.actor.spine);
}
}
_iconKeys() {
const out = ICON_TMP;
out.length = 0;
const f = this.flags;
const push = (k) => { if (k && !out.includes(k) && out.length < 4) out.push(k); };
if (f & UF.FROZEN) push('freeze');
else if (f & UF.STUNNED) push('stun');
if (f & UF.SLEEP) push('sleep');
if (f & UF.COLD && !(f & UF.FROZEN)) push('cold');
if (f & UF.INVULN) push('invuln');
if (f & UF.STEALTH) push('stealth');
for (const k of this.statuses) {
if (out.length >= 4) break;
if (k === 'skill') continue;
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
const icon = statusIconKey(k);
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
if (icon === 'cold' && (f & UF.FROZEN)) continue;
push(icon);
}
return out;
}
/** Canvas-space bounds (CSS px) of the body: 0.7 tile wide, from its head (UNIT.headroom; enemies: their model) down
* to just below the feet — tooltips and overlays (view.pieceScreenRect), not picking (render/pick.js is by tile). */
bounds() {
const s = this.screen.s || 1;
const h = (this._headTiles || UNIT.headroom) * s;
const w = s * 0.7;
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
}
setHover(on) { this.hovered = !!on; }
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._dropActor();
this.shadow.destroy();
this.blockIcon.destroy();
if (this.facingArrow) this.facingArrow.destroy();
this.hud.destroy({ children: true });
this.root.destroy({ children: true });
}
}
const SH_P = { x: 0, y: 0, s: 0, depth: 0 };
const FA_P = { x: 0, y: 0, s: 0, depth: 0 };
const FA_C = { x: 0, y: 0, s: 0, depth: 0 };
const LG_P = { x: 0, y: 0, s: 0, depth: 0 };
const ICON_TMP = [];
/**
* Unit-layer zIndex of a unit / piece whose feet are at (x, y): farther rows first (so raised block rows, keyed by
* tiles.rowDepthKey, hide units behind them); lifted (dragged) pieces on top; ties broken by column.
*/
export function unitDepthKey(cam, x, y, lift = 0) {
// +40 (< one row of depth at the official 30° pitch, 100·sin 30° = 50): a lifted piece never ties with the unit one
// row in front of it (a tie flickers with the column tie-break)
return -cam.depthOf(x, y, 0) * 100 + (lift > 0 ? 40 : 0) + x * 0.001;
}
function mixTint(a, b, k) {
const t = clamp(k, 0, 1);
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
return ((ar + (br - ar) * t) << 16) | ((ag + (bg - ag) * t) << 8) | ((ab + (bb - ab) * t) | 0);
}
/** Attack interval from data (bat × 100 / aspd), game seconds. */
function defaultInterval(ctx, info) {
try {
const rec = ctx.lookupDef ? ctx.lookupDef(info) : null;
const st = rec?.stats;
if (st && st.bat > 0) return clamp((st.bat * 100) / (st.aspd > 0 ? st.aspd : 100), 0.2, 6);
} catch { /* ignore */ }
return 1.2;
}
// =============================================================================================================
/**
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
* the pointer (render/app.js moveDragVisual); it drops on (and equips the unit on) the tile under the pointer.
*/
export class ItemView {
constructor(ctx, info) {
const P = ctx.P;
this.ctx = ctx; this.P = P;
this.info = { ...info };
this.id = info.id; this.uid = info.uid ?? null;
this.x = info.x || 0; this.y = info.y || 0; this.z = 0;
this.alive = true; this.remove = false; this.lift = 0; this.dimmed = false;
this.bob = Math.random() * 6;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
this.shadow.anchor.set(0.5);
ctx.layers.shadow.addChild(this.shadow);
this.root = new P.Container();
ctx.layers.units.addChild(this.root);
this.plate = new P.Sprite(itemTexture(String(info.defId || 'item'), null, info.color || 0x9aa5a0));
this.plate.anchor.set(0.5, 1);
this.root.addChild(this.plate);
const url = info.icon;
if (url && ctx.assets?.image) ctx.assets.image(url).then((img) => { if (!this.destroyed && img) this.plate.texture = itemTexture(String(info.defId), img, info.color || 0x9aa5a0); }, () => {});
this.hud = null;
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
update(dt, cam, t) {
const lifted = this.lift > 0;
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
this.root.position.set(p.x, p.y);
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
const size = p.s * 0.62;
this.plate.scale.set(size / 128);
this.plate.anchor.set(0.5, lifted ? 0.5 : 1);
this.root.alpha = this.dimmed ? 0.35 : 1;
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
const sh = cam.project(this.x, this.y, this.z, SH_P);
this.shadow.position.set(sh.x, sh.y);
this.shadow.scale.set((p.s * 0.55) / this.shadow.texture.width);
this.shadow.alpha = 0.35;
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
}
/** Canvas-space bounds (CSS px) of the plate (centred on the pointer while dragged). */
bounds() {
const w = (this.screen.s || 1) * 0.62;
return { x: this.screen.x - w / 2, y: this.lift > 0 ? this.screen.y - w / 2 : this.screen.y - w, width: w, height: w };
}
setHover() {}
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
}
// =============================================================================================================
/**
* Battle device unit (kind 'device': crates, “双眼皮” turrets, unknown stage devices): a textured 3D box (the tile
* atlas, render/tiles.js BoxMesh — the real crate / device plates when the board art is installed), a turret head
* that turns to its target and flashes on attack, HP bar once damaged, and a break-apart on death.
*/
const GENERIC_DEVICE = Object.freeze({ size: 0.7, height: 0.36, top: 'platformTop', side: 'forbidSide' });
export class DeviceView {
constructor(ctx, info) {
const P = ctx.P;
this.ctx = ctx; this.P = P;
this.info = { ...info };
this.id = info.id; this.uid = null;
this.isEnemy = false;
this.spec = deviceBoxOf(info.defId) || GENERIC_DEVICE;
this.turret = this.spec === DEVICE_BOX.turret;
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
this.hp = Number(info.maxHp) || 100; this.maxHp = this.hp;
this.alive = true; this.remove = false; this.dying = 0; this.flags = 0;
this.facing = info.facing === -1 ? -1 : 1;
this.aim = this.facing > 0 ? 0 : Math.PI; this.aimTo = this.aim; this.fire = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this._headTiles = this.spec.height + 0.2;
this.box = ctx.createBox ? ctx.createBox() : null;
this.gfx = new P.Graphics(); // fallback body (no tile field) and the turret head
ctx.layers.units.addChild(this.gfx);
this.hud = new P.Container();
ctx.layers.bars.addChild(this.hud);
this.hpBg = bar(P, this.hud, COLORS.hpBack, 0.85);
this.hpFill = bar(P, this.hud, this.turret ? COLORS.hpAlly : 0xe0b877);
this.camVersion = -1;
}
sync(s) {
if (!s) return;
this.x = s.x; this.y = s.y; this.z = groundZ(this.ctx, s.x, s.y);
if (s.maxHp > 0) this.maxHp = s.maxHp;
this.hp = clamp(s.hp, 0, this.maxHp); this.flags = s.flags | 0;
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
onAttack(target) {
if (!this.alive) return;
if (target) this.aimTo = Math.atan2(target.y - this.y, target.x - this.x);
this.fire = 1;
}
windUp() { return false; }
onHit() { this.shake = 0.2; }
onDeploy() {}
onStatus() {}
setSkill() {}
setFacing() {}
die() { if (!this.alive) return; this.alive = false; this.dying = 0.45; this.ctx.fx?.crateBreak?.(this.x, this.y, this.z, this.turret ? 0x6c777d : null); }
revive() { this.alive = true; this.dying = 0; this.remove = false; }
update(dt, cam, t) {
const shake = this.shake > 0 ? Math.sin(t * 80) * this.shake * 0.05 : 0;
this.shake = Math.max(0, (this.shake || 0) - dt);
const k = this.dying > 0 ? Math.max(0, this.dying / 0.45) : 1;
if (this.dying > 0) { this.dying -= dt; if (this.dying <= 0) this.remove = true; }
const zKey = rowDepthKey(cam, Math.round(this.y)) + ROW_KEY.devices;
const size = this.spec.size * (0.6 + 0.4 * k), height = this.spec.height * k;
const g = this.gfx;
if (this.box) {
this.box.update(cam, { x: this.x + shake, y: this.y, z: this.z, size, height, top: this.spec.top, side: this.spec.side, alpha: k });
if (this.box.mesh) {
if (!this.box.mesh.parent) this.ctx.layers.units.addChild(this.box.mesh);
this.box.mesh.zIndex = zKey;
}
}
const redraw = !this.box || this.turret || cam.version !== this.camVersion || shake || this.dying > 0 || this._lastShake;
if (redraw) {
this.camVersion = cam.version;
this._lastShake = !!shake;
g.clear();
if (!this.box) drawCrate(g, cam, this.x + shake, this.y, this.z, size, height, 1);
if (this.turret && height > 0.05) this._drawHead(g, cam, dt, height, k);
}
g.zIndex = zKey + 0.001;
g.alpha = k;
const p = cam.project(this.x, this.y, this.z + this.spec.height + 0.35, this.screen);
const show = this.alive && this.hp < this.maxHp - 0.5;
this.hud.visible = show;
if (show) {
const bw = clamp(p.s * 0.7, 24, 90), bh = clamp(p.s * 0.06, 3, 6);
this.hpBg.position.set(p.x - bw / 2 - 1, p.y); this.hpBg.width = bw + 2; this.hpBg.height = bh + 2;
this.hpFill.position.set(p.x - bw / 2, p.y); this.hpFill.width = bw * clamp(this.hp / this.maxHp, 0, 1); this.hpFill.height = bh;
}
this.screen.top = p.y;
}
/** Turret head: a squat dome with a barrel aimed at the last target; muzzle flash on attack. */
_drawHead(g, cam, dt, height, k) {
let d = this.aimTo - this.aim;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
this.aim += d * Math.min(1, dt * 10);
this.fire = Math.max(0, this.fire - dt * 5);
const z = this.z + height;
const c = cam.project(this.x, this.y, z + 0.08);
const s = c.s;
const ex = Math.cos(this.aim), ey = Math.sin(this.aim);
const recoil = this.fire * 0.06;
const tip = cam.project(this.x + ex * (0.42 - recoil), this.y + ey * (0.42 - recoil), z + 0.12);
g.lineStyle(0);
g.beginFill(0x1c2124, 0.95 * k); g.drawEllipse(c.x, c.y, s * 0.2, s * 0.13); g.endFill();
g.lineStyle(Math.max(2, s * 0.07), 0x39444a, k);
g.moveTo(c.x, c.y - s * 0.05); g.lineTo(tip.x, tip.y - s * 0.03);
g.lineStyle(Math.max(1, s * 0.03), 0x9aa7ad, k);
g.moveTo(c.x, c.y - s * 0.06); g.lineTo(tip.x, tip.y - s * 0.04);
g.lineStyle(0);
g.beginFill(0x4b565c, k); g.drawEllipse(c.x, c.y - s * 0.07, s * 0.14, s * 0.09); g.endFill();
g.beginFill(this.fire > 0 ? 0xffd27a : 0xff5a4a, (0.6 + 0.4 * this.fire) * k); g.drawCircle(c.x, c.y - s * 0.09, s * 0.035); g.endFill();
if (this.fire > 0.2) { g.beginFill(0xfff0c0, this.fire * 0.8 * k); g.drawCircle(tip.x, tip.y - s * 0.03, s * 0.08 * this.fire); g.endFill(); }
}
bounds() { const s = this.screen.s; return { x: this.screen.x - s * 0.45, y: this.screen.y, width: s * 0.9, height: s }; }
hitTest(x, y) { const b = this.bounds(); return this.alive && x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height; }
setHover() {}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
if (this.box) this.box.destroy();
this.gfx.destroy();
this.hud.destroy({ children: true });
}
}