Merge branch 'master' into fix/spine-start-anim
This commit is contained in:
577 files changed
+43650
-10558
No files matched your search
+164
-55
@@ -44,9 +44,10 @@
|
||||
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
|
||||
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
|
||||
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
|
||||
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
|
||||
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
|
||||
// equips that unit.
|
||||
// drops on the tile under the pointer (render/drag.js). A dragged unit stands on that tile while it is a legal target
|
||||
// (a board tile or a bench slot: drag.js dragStandTile; the official deploy drag, the owner's recording of 2026-10-09,
|
||||
// PR #403's report) and is held under the pointer elsewhere — its drawn feet DRAG_HOLD_TILES below it, the pointer on
|
||||
// its body; an item plate stays centred on the pointer. An item dropped on a unit's tile equips that unit.
|
||||
// Direction step (ui/facingWheel.js, research 09 §1.2):
|
||||
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
@@ -117,10 +118,10 @@ import { fxForm } from '../../../shared/protocol.js';
|
||||
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
|
||||
import { SnapshotBuffer, frameTime } from './interp.js';
|
||||
import { TileField } from './tiles.js';
|
||||
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
|
||||
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
|
||||
import { FxSystem, ensureDamageFonts } from './fx.js';
|
||||
import { createDragController, pieceTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||
import { createDragController, pieceTile, dragStandTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, silhouetteTexture } from './textures.js';
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
@@ -148,14 +149,17 @@ export { releaseGl } from './app/host.js';
|
||||
/**
|
||||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
export async function createFieldView(host, options = {}) {
|
||||
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const P = await ensurePixi();
|
||||
const signal = opts.signal;
|
||||
signal?.throwIfAborted();
|
||||
const P = await withTimeout(ensurePixi(), null, signal);
|
||||
signal?.throwIfAborted();
|
||||
const assets = resolveAssets(opts.assets);
|
||||
const data = makeData(opts.data);
|
||||
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
|
||||
@@ -164,19 +168,28 @@ export async function createFieldView(host, options = {}) {
|
||||
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
|
||||
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
|
||||
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
|
||||
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
|
||||
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
|
||||
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
|
||||
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
|
||||
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
|
||||
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
|
||||
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
|
||||
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
|
||||
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
|
||||
signal?.throwIfAborted();
|
||||
// web fonts for the bitmap damage numbers / tier chips (never block long)
|
||||
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
|
||||
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
|
||||
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
|
||||
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
|
||||
const s0 = size();
|
||||
// Construction is synchronous until the complete view can own cancellation. If setup throws,
|
||||
// dispose only the resources acquired by this attempt, never another view's host contents.
|
||||
const setupCleanup = [];
|
||||
let destroyed = false;
|
||||
let view = null;
|
||||
let onAbort;
|
||||
try {
|
||||
const app = new P.Application({
|
||||
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
|
||||
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
|
||||
@@ -184,6 +197,11 @@ export async function createFieldView(host, options = {}) {
|
||||
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
|
||||
});
|
||||
const canvas = app.view;
|
||||
setupCleanup.push(() => {
|
||||
releaseGl(app.renderer);
|
||||
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
|
||||
finally { canvas.remove(); }
|
||||
});
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
@@ -240,7 +258,6 @@ export async function createFieldView(host, options = {}) {
|
||||
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
|
||||
};
|
||||
|
||||
let destroyed = false;
|
||||
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
|
||||
let stageRec = null;
|
||||
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
|
||||
@@ -302,12 +319,14 @@ export async function createFieldView(host, options = {}) {
|
||||
renderer: app.renderer,
|
||||
frameNo: () => frameNo,
|
||||
impostorInterval: () => impInterval,
|
||||
impostorSlot: () => impSlot++,
|
||||
clipAllowed: () => clipAllowed,
|
||||
viewport: () => vp,
|
||||
loadLevel: () => loadLevel,
|
||||
surfaceLayer: (row) => tiles.surfaceLayer(row),
|
||||
};
|
||||
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
|
||||
setupCleanup.push(() => tiles.destroy());
|
||||
const fx = new FxSystem({
|
||||
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
|
||||
timeScale: () => ctx.timeScale(),
|
||||
@@ -322,10 +341,12 @@ export async function createFieldView(host, options = {}) {
|
||||
return Math.max(80, Math.min(size().height * 0.4, p.y));
|
||||
},
|
||||
});
|
||||
setupCleanup.push(() => fx.destroy());
|
||||
ctx.fx = fx;
|
||||
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
|
||||
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
|
||||
const impostors = new ImpostorAtlas(app.renderer);
|
||||
setupCleanup.push(() => impostors.destroy());
|
||||
ctx.impostors = impostors;
|
||||
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
|
||||
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
|
||||
@@ -334,7 +355,6 @@ export async function createFieldView(host, options = {}) {
|
||||
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
|
||||
return art;
|
||||
}, () => null);
|
||||
await withTimeout(artPromise, 2500);
|
||||
|
||||
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
|
||||
let board3d = null; // BoardScene while the 3D board is on
|
||||
@@ -342,6 +362,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let board3dError = null;
|
||||
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
|
||||
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
|
||||
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
|
||||
function enable3d(THREE, pack) {
|
||||
if (destroyed || board3d || !THREE || !pack) return false;
|
||||
recover.THREE = THREE; recover.pack = pack;
|
||||
@@ -411,13 +432,20 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
|
||||
}, delay);
|
||||
}
|
||||
if (want3d) {
|
||||
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
await withTimeout(ready, 6000);
|
||||
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
|
||||
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
let boardRetry = null;
|
||||
function retryBoard() {
|
||||
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
|
||||
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
|
||||
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
|
||||
.finally(() => { boardRetry = null; });
|
||||
}
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
|
||||
// manifest arrives late)
|
||||
let shadowAsked = false;
|
||||
let offShadow = null;
|
||||
setupCleanup.push(() => offShadow?.());
|
||||
function loadShadow() {
|
||||
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
|
||||
if (!shadowUrl) return;
|
||||
@@ -430,13 +458,17 @@ export async function createFieldView(host, options = {}) {
|
||||
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
|
||||
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
|
||||
};
|
||||
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
|
||||
if (t.baseTexture.valid) use();
|
||||
else {
|
||||
t.baseTexture.once('loaded', use);
|
||||
offShadow = () => t.baseTexture.off('loaded', use);
|
||||
}
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
loadShadow();
|
||||
ensureDamageFonts();
|
||||
// web fonts may land after the first chips were drawn
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
|
||||
// web fonts may land after the first chips / redeploy-ring labels were drawn
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {});
|
||||
|
||||
// ---- camera ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -775,8 +807,16 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -784,11 +824,10 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
@@ -1062,23 +1101,33 @@ export async function createFieldView(host, options = {}) {
|
||||
function moveDragVisual(p) {
|
||||
if (!dragState) return;
|
||||
const v = dragState.view;
|
||||
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
|
||||
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
|
||||
// (p.target, highlighted below) is the tile under the pointer
|
||||
const item = v instanceof ItemView;
|
||||
const g = cam.unproject(p.x, p.y, 0);
|
||||
const hold = item ? 0 : DRAG_HOLD_TILES;
|
||||
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
|
||||
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
|
||||
const z = t ? heightAt(t.row, t.col) : 0;
|
||||
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
|
||||
let w = cam.unproject(p.x, p.y + hold * s0, up);
|
||||
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
|
||||
if (w) {
|
||||
v.x = Math.max(-1, Math.min(21, w.x));
|
||||
v.y = Math.max(-1, Math.min(19, w.y));
|
||||
v.z = z;
|
||||
// a unit over a legal drop target — the tile under the pointer, on the board or the bench (render/drag.js
|
||||
// dragStandTile) — stands on it, lifted, as in the official deploy drag (the owner's recording of 2026-10-09): the
|
||||
// world point the drop tween goes to (boardWorld, the prep field transform applied), set at once, no tween
|
||||
const stand = item ? null : dragStandTile(p);
|
||||
if (stand) {
|
||||
const w = boardWorld(stand.row, stand.col, stand.bench);
|
||||
v.x = w.x; v.y = w.y; v.z = w.z;
|
||||
} else {
|
||||
// otherwise held under the pointer (the player still sees what they carry): a unit with its drawn (lifted) feet
|
||||
// DRAG_HOLD_TILES of its own px per tile below it (the scale at the pointer's ground, refined once at the feet), an
|
||||
// item plate centred on it
|
||||
const g = cam.unproject(p.x, p.y, 0);
|
||||
const hold = item ? 0 : DRAG_HOLD_TILES;
|
||||
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
|
||||
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
|
||||
const z = t ? heightAt(t.row, t.col) : 0;
|
||||
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
|
||||
let w = cam.unproject(p.x, p.y + hold * s0, up);
|
||||
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
|
||||
if (w) {
|
||||
v.x = Math.max(-1, Math.min(21, w.x));
|
||||
v.y = Math.max(-1, Math.min(19, w.y));
|
||||
v.z = z;
|
||||
}
|
||||
}
|
||||
// the drop target (p.target, highlighted) is the tile under the pointer
|
||||
if (p.target && p.target.area !== 'temp') {
|
||||
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
|
||||
const c = p.target.area === 'board' ? p.target.col : p.target.idx;
|
||||
@@ -1209,6 +1258,17 @@ export async function createFieldView(host, options = {}) {
|
||||
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
|
||||
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
|
||||
const onTapTarget = () => {};
|
||||
const removeInput = () => {
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||
canvas.removeEventListener('contextmenu', onContext);
|
||||
canvas.removeEventListener('touchend', onTouchEnd);
|
||||
canvas.removeEventListener('click', onTapTarget);
|
||||
};
|
||||
setupCleanup.push(removeInput);
|
||||
canvas.addEventListener('pointerdown', onPointerDown);
|
||||
canvas.addEventListener('pointermove', onPointerMove);
|
||||
canvas.addEventListener('pointerup', onPointerUp);
|
||||
@@ -1415,6 +1475,16 @@ export async function createFieldView(host, options = {}) {
|
||||
consumedIds.add(e[4].id);
|
||||
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
|
||||
}
|
||||
// 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the
|
||||
// destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally
|
||||
// started already by syncBattle's look-ahead, which this repeats harmlessly
|
||||
if (e[1] === 'displace') {
|
||||
const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
|
||||
const v = d && d.id != null ? views.get(d.id) : null;
|
||||
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
|
||||
...(Number(d.dur) > 0 ? { dur: Number(d.dur) } : {}), keepFacing: d.keepFacing === true,
|
||||
});
|
||||
}
|
||||
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
|
||||
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
|
||||
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
|
||||
@@ -1467,10 +1537,27 @@ export async function createFieldView(host, options = {}) {
|
||||
if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); }
|
||||
}
|
||||
|
||||
/** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */
|
||||
let slideAt = 0;
|
||||
function startSlide(e) {
|
||||
if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return;
|
||||
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
|
||||
const v = extra && extra.id != null ? views.get(extra.id) : null;
|
||||
// `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length
|
||||
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), {
|
||||
at: slideAt, ...(Number(extra.dur) > 0 ? { dur: Number(extra.dur) } : {}), keepFacing: extra.keepFacing === true,
|
||||
});
|
||||
}
|
||||
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
|
||||
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
|
||||
function syncBattle(renderT) {
|
||||
if (renderT0Battle == null) renderT0Battle = renderT;
|
||||
// A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the
|
||||
// destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already
|
||||
// lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued
|
||||
// here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position.
|
||||
slideAt = interp.nextSnapT(renderT);
|
||||
if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide);
|
||||
interp.sample(renderT, sample);
|
||||
for (const [id, s] of sample) {
|
||||
let v = views.get(id);
|
||||
@@ -1481,9 +1568,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!v) v = battleView(id) || createUnknown(id, s);
|
||||
if (!v) continue;
|
||||
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
|
||||
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
|
||||
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
|
||||
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
|
||||
syncView(v, s, renderT);
|
||||
}
|
||||
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
|
||||
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
|
||||
@@ -1534,6 +1619,10 @@ export async function createFieldView(host, options = {}) {
|
||||
let cpuMs = 0;
|
||||
let frameNo = 0;
|
||||
let impInterval = 0;
|
||||
// Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when
|
||||
// (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the
|
||||
// busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle.
|
||||
let impSlot = 0;
|
||||
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
|
||||
let culledCount = 0;
|
||||
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
|
||||
@@ -1550,8 +1639,9 @@ export async function createFieldView(host, options = {}) {
|
||||
// Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with
|
||||
// the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated
|
||||
// every 30 frames). Prep and ordinary fields keep full-rate direct rendering.
|
||||
// Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil
|
||||
// render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality
|
||||
// Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on
|
||||
// tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a
|
||||
// closed lid would cut (GitHub #177, spine.js _eyeMaskFallback)
|
||||
let clipAllowed = true;
|
||||
function pickClipping() {
|
||||
let n = 0;
|
||||
@@ -1574,6 +1664,7 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
function frameBody(now) {
|
||||
frameNo++;
|
||||
impSlot = 0;
|
||||
if (frameNo % 30 === 1) {
|
||||
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
|
||||
culledCount = 0;
|
||||
@@ -1658,6 +1749,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let ro = null;
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
ro = new ResizeObserver(() => resize());
|
||||
setupCleanup.push(() => ro.disconnect());
|
||||
ro.observe(host);
|
||||
}
|
||||
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
|
||||
@@ -1672,6 +1764,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
retryBoard();
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
@@ -1680,18 +1773,21 @@ export async function createFieldView(host, options = {}) {
|
||||
leader?.view.retryAssets?.();
|
||||
}
|
||||
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
|
||||
setupCleanup.push(() => offAssets?.());
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
retryBoard();
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
leader?.view.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
|
||||
|
||||
// ---- public API ---------------------------------------------------------------------------------------------
|
||||
|
||||
const view = {
|
||||
view = {
|
||||
setStage,
|
||||
setCamera,
|
||||
setPrep,
|
||||
@@ -1830,14 +1926,9 @@ export async function createFieldView(host, options = {}) {
|
||||
try { ro?.disconnect(); } catch { /* ignore */ }
|
||||
try { offAssets?.(); } catch { /* ignore */ }
|
||||
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
|
||||
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||
canvas.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('pointerup', onPointerUp);
|
||||
canvas.removeEventListener('pointercancel', onPointerCancel);
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||||
canvas.removeEventListener('contextmenu', onContext);
|
||||
canvas.removeEventListener('touchend', onTouchEnd);
|
||||
canvas.removeEventListener('click', onTapTarget);
|
||||
removeInput();
|
||||
offShadow?.();
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
app.ticker.remove(frame);
|
||||
app.ticker.remove(preRender);
|
||||
app.ticker.remove(postRender);
|
||||
@@ -1857,6 +1948,7 @@ export async function createFieldView(host, options = {}) {
|
||||
try { board3dCanvas?.remove(); } catch { /* ignore */ }
|
||||
releaseGl(app.renderer);
|
||||
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
|
||||
canvas.remove();
|
||||
},
|
||||
stats() {
|
||||
return {
|
||||
@@ -1876,5 +1968,22 @@ export async function createFieldView(host, options = {}) {
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
};
|
||||
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
|
||||
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
|
||||
setupCleanup.length = 0;
|
||||
onAbort = () => view.destroy();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
signal?.throwIfAborted();
|
||||
await withTimeout(artPromise, 2500, signal);
|
||||
if (want3d) await withTimeout(boardReady, 6000, signal);
|
||||
signal?.throwIfAborted();
|
||||
return view;
|
||||
} catch (err) {
|
||||
if (view) view.destroy();
|
||||
else {
|
||||
destroyed = true;
|
||||
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { diyRecordOf } from '../../../../shared/diy.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
|
||||
function withTimeout(p, ms, signal) {
|
||||
let timer;
|
||||
let onAbort;
|
||||
const stopped = new Promise((resolve, reject) => {
|
||||
if (ms != null) timer = setTimeout(resolve, ms);
|
||||
if (signal) {
|
||||
onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
}
|
||||
});
|
||||
return Promise.race([p, stopped]).finally(() => {
|
||||
clearTimeout(timer);
|
||||
if (onAbort) signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
|
||||
|
||||
@@ -37,7 +37,12 @@ export function renderInfo(u) {
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
// an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained
|
||||
// skill aura is drawn for it (render/fx/rings.js _aura)
|
||||
ammoSkill: u.ammoSkill === true ? true : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
// Scouted hand and temp units have details but no field attack range.
|
||||
area: typeof u.area === 'string' ? u.area : undefined,
|
||||
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
|
||||
@@ -26,8 +26,9 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
const DROP_PENDING_MS = 1300;
|
||||
|
||||
/**
|
||||
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
|
||||
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
|
||||
* A dragged unit over no legal target is held with its drawn feet this many tiles below the pointer — the pointer on its
|
||||
* body, the model under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike); over a legal
|
||||
* target it stands on that tile (render/drag.js dragStandTile). An item plate is centred on the pointer.
|
||||
*/
|
||||
export const DRAG_HOLD_TILES = 0.45;
|
||||
|
||||
|
||||
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
|
||||
export const THREE_URL = '/vendor/three.module.js';
|
||||
|
||||
let threePromise = null;
|
||||
let threeFailures = 0;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||||
if (threeFailures >= 3) return Promise.resolve(null);
|
||||
const source = new URL(url, import.meta.url);
|
||||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||||
return m;
|
||||
});
|
||||
threePromise = pending;
|
||||
}
|
||||
return threePromise;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ async function fetchJson(url) {
|
||||
*/
|
||||
export function loadBoardPack(assets) {
|
||||
if (cached) return cached;
|
||||
cached = (async () => {
|
||||
const pending = (async () => {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!isObj(manifest)) return null;
|
||||
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
|
||||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||||
const u = url(g, n);
|
||||
if (!u) return;
|
||||
const img = await assets.image(u).catch(() => null);
|
||||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||||
}));
|
||||
if (!images.D) return null;
|
||||
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
|
||||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||||
};
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
|
||||
return cached;
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||||
// An older attempt finishing after reset must not discard a newer cached request.
|
||||
if (!pack && cached === pending) cached = null;
|
||||
return pack;
|
||||
});
|
||||
cached = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
@@ -11,8 +11,10 @@
|
||||
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
|
||||
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
|
||||
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
|
||||
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
|
||||
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
|
||||
// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on
|
||||
// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer
|
||||
// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside'
|
||||
// (mouse and touch alike).
|
||||
//
|
||||
// Events emitted through `emit(name, payload)`:
|
||||
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
|
||||
@@ -80,6 +82,21 @@ export function resolveDrop(p) {
|
||||
return { kind: 'drop', target: slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while
|
||||
* it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise
|
||||
* (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under
|
||||
* the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the
|
||||
* finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate.
|
||||
*/
|
||||
export function dragStandTile(move, geo = GEO) {
|
||||
const t = move && move.legal === true ? move.target : null;
|
||||
if (!t) return null;
|
||||
if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false };
|
||||
if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
|
||||
export function isLegal(canPlace, piece, slot) {
|
||||
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
export const FX_KINDS = Object.freeze({
|
||||
// blasts
|
||||
// [ASSUMED] Clementia's S3 uses the shared blast placeholder until her official effect is ported.
|
||||
blast: { a: 'blast', c: 0xffb35c },
|
||||
aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 },
|
||||
// `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch
|
||||
bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true },
|
||||
@@ -15,6 +17,9 @@ export const FX_KINDS = Object.freeze({
|
||||
frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true },
|
||||
iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 },
|
||||
finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff },
|
||||
// 赤刃明霄陈 S3 赤霄·天喟 的龙剑气: a travelling blade, drawn from the sim's per-interval `chen3Wave` events (its own
|
||||
// position + the previous point + direction); `pt` because it is not at the caster (it roams and passes by her tile)
|
||||
chen3Wave: { a: 'qi', c: 0xdfe8ff, pt: true },
|
||||
quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 },
|
||||
burst: { a: 'element', c: 0xd0a0ff },
|
||||
// areas
|
||||
|
||||
@@ -74,8 +74,13 @@ export class FxRings {
|
||||
this._aura(view, true);
|
||||
}
|
||||
|
||||
/** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */
|
||||
/**
|
||||
* Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo
|
||||
* skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round
|
||||
* — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all.
|
||||
*/
|
||||
_aura(view, on) {
|
||||
if (on && view.info?.ammoSkill) return;
|
||||
const P = this.P;
|
||||
let a = this.auras.get(view.id);
|
||||
if (on) {
|
||||
|
||||
@@ -47,10 +47,11 @@ export class FxSim {
|
||||
/**
|
||||
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
|
||||
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame, qi), except kinds whose
|
||||
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
|
||||
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
|
||||
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs).
|
||||
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs), dr / dc (a
|
||||
* direction in tiles: 'qi' points its blade that way).
|
||||
*/
|
||||
simFx(kind, x, y, extra) {
|
||||
const ex = extra && typeof extra === 'object' ? extra : {};
|
||||
@@ -100,7 +101,12 @@ export class FxSim {
|
||||
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
|
||||
break;
|
||||
}
|
||||
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
|
||||
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
|
||||
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
|
||||
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
|
||||
case 'chill':
|
||||
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
|
||||
break;
|
||||
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
|
||||
case 'healAoe': {
|
||||
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
|
||||
@@ -168,6 +174,27 @@ export class FxSim {
|
||||
}
|
||||
break;
|
||||
}
|
||||
// a travelling blade (赤刃明霄陈 S3's 龙剑气): a short trail from where it was to where it is, plus a crescent head
|
||||
// turned the way it moves — the sim sends one of these every 0.12 s, and the trail outlives that gap so the
|
||||
// events join into one sweeping wave. A procedural stand-in (the official effect is a particle prefab this client
|
||||
// does not extract). Trail, head and heading share one height; the head is projected after streak(), which
|
||||
// reuses this._p / this._q (projected before it, the head was drawn at the trail's start, one event behind)
|
||||
case 'qi': {
|
||||
const z = at.z + 0.35;
|
||||
const fx0 = num(ex.fromX, NaN), fy0 = num(ex.fromY, NaN);
|
||||
if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, z, col, 0.34);
|
||||
const h = cam.project(at.x, at.y, z, this._p), hx = h.x, hy = h.y, hs = h.s;
|
||||
const dr = num(ex.dr, NaN), dc = num(ex.dc, NaN);
|
||||
let rot = 0;
|
||||
if (Number.isFinite(dr) && Number.isFinite(dc) && (dr || dc)) {
|
||||
const q = cam.project(at.x + dc * 0.5, at.y + dr * 0.5, z, this._q);
|
||||
// the `slash` sprite's crescent bulges along its local −y, so this turns that edge onto the travel direction
|
||||
rot = Math.atan2(q.x - hx, -(q.y - hy));
|
||||
}
|
||||
this.particle('slash', hx, hy, { tint: col, life: 0.3, s0: hs / 128 * 1.3, s1: hs / 128 * 2.0, a0: 0.9, a1: 0, rot });
|
||||
this.particle('glow', hx, hy, { tint: col, life: 0.26, s0: hs / 128 * 0.8, s1: hs / 128 * 1.5, a0: 0.55, a1: 0 });
|
||||
break;
|
||||
}
|
||||
case 'wave': {
|
||||
const k = clamp(num(ex.scale, 1), 0.5, 4);
|
||||
const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1));
|
||||
|
||||
@@ -180,11 +180,32 @@ export class FxZones {
|
||||
this.tintSprite.tint = tint;
|
||||
}
|
||||
|
||||
/** A short flurry of snow over the field (cold wind). */
|
||||
snowfall(tint) {
|
||||
/**
|
||||
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
|
||||
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
|
||||
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
|
||||
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
|
||||
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
|
||||
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
|
||||
*/
|
||||
snowfall(tint, gust = false) {
|
||||
const size = this.ctx.screenSize();
|
||||
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
const W = size.width, H = size.height, low = this.quality === 'low';
|
||||
if (!gust) {
|
||||
for (let i = 0; i < (low ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
|
||||
for (let i = 0; i < (low ? 22 : 48); i++) {
|
||||
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
|
||||
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
|
||||
}
|
||||
// gust streaks: thin wind lines racing up through the field along their velocity
|
||||
for (let i = 0; i < (low ? 4 : 10); i++) {
|
||||
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
|
||||
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-20
@@ -8,7 +8,8 @@
|
||||
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
|
||||
// is more than `snapAfter` real seconds off),
|
||||
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
|
||||
// positions are extrapolated along the last velocity for at most that long, then freeze,
|
||||
// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the
|
||||
// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`),
|
||||
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
|
||||
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
|
||||
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
|
||||
@@ -20,18 +21,31 @@
|
||||
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
|
||||
// included — are always delivered, and a full queue sheds only cosmetic ones.
|
||||
//
|
||||
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
|
||||
// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9):
|
||||
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along
|
||||
// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9, `stand` / `standCut` from PR #381):
|
||||
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
|
||||
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
|
||||
// snapshot, like flags);
|
||||
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
|
||||
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
|
||||
// both are integers): downAt(time) returns the list of the snapshot at `time`.
|
||||
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
|
||||
// both are integers): downAt(time) returns the list of the snapshot at `time`;
|
||||
// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample()
|
||||
// from the older snapshot like flags, so they step with the skill flag instead of sliding:
|
||||
// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only)
|
||||
// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null
|
||||
// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none)
|
||||
// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list
|
||||
// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执).
|
||||
// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position
|
||||
// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a
|
||||
// snapshot without it interpolates linearly;
|
||||
// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside
|
||||
// it stays linear.
|
||||
// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares
|
||||
// them with renderT.
|
||||
|
||||
import { fxForm } from '../../../shared/protocol.js';
|
||||
import { ANIM } from '../../../shared/constants.js';
|
||||
import { ANIM, UF } from '../../../shared/constants.js';
|
||||
|
||||
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
|
||||
/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */
|
||||
@@ -43,6 +57,15 @@ const MAX_EVENTS = 6000;
|
||||
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/**
|
||||
* Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it):
|
||||
* the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`.
|
||||
*/
|
||||
function movementStart(a, b, id) {
|
||||
const until = a.stand?.get(id), cutAt = b.standCut?.get(id);
|
||||
return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t;
|
||||
}
|
||||
|
||||
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
|
||||
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
|
||||
/**
|
||||
@@ -66,8 +89,11 @@ export function frameTime(msg) {
|
||||
|
||||
/**
|
||||
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
|
||||
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
|
||||
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
|
||||
* state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, stand: Map<id, until> | null,
|
||||
* standCut: Map<id, at> | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers
|
||||
* default to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` / `ammo` / `wolves` /
|
||||
* `neg` entries are dropped. `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite
|
||||
* times in [0, t] of units in it.
|
||||
*/
|
||||
export function normalizeSnapshot(snap) {
|
||||
if (!snap || typeof snap !== 'object') return null;
|
||||
@@ -90,6 +116,14 @@ export function normalizeSnapshot(snap) {
|
||||
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
|
||||
}
|
||||
}
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
|
||||
let neg = null;
|
||||
if (Array.isArray(snap.neg)) {
|
||||
for (const e of snap.neg) {
|
||||
if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue;
|
||||
(neg || (neg = new Map())).set(e[0], Math.min(1, e[1]));
|
||||
}
|
||||
}
|
||||
let down = null;
|
||||
if (Array.isArray(snap.down)) {
|
||||
for (const d of snap.down) {
|
||||
@@ -99,7 +133,36 @@ export function normalizeSnapshot(snap) {
|
||||
(down || (down = [])).push(e);
|
||||
}
|
||||
}
|
||||
return { t, units, down, raw: snap };
|
||||
let stand = null;
|
||||
if (Array.isArray(snap.stand)) {
|
||||
for (const e of snap.stand) {
|
||||
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue;
|
||||
(stand || (stand = new Map())).set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
let standCut = null;
|
||||
if (Array.isArray(snap.standCut)) {
|
||||
for (const e of snap.standCut) {
|
||||
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue;
|
||||
(standCut || (standCut = new Map())).set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
* `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the
|
||||
* `ammo` and `wolves` lists); null without a valid entry.
|
||||
*/
|
||||
function countList(list, units) {
|
||||
if (!Array.isArray(list)) return null;
|
||||
let m = null;
|
||||
for (const e of list) {
|
||||
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2])
|
||||
|| e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue;
|
||||
(m || (m = new Map())).set(e[0], [e[1], e[2]]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
export class SnapshotBuffer {
|
||||
@@ -252,6 +315,16 @@ export class SnapshotBuffer {
|
||||
return ans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates
|
||||
* — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown.
|
||||
*/
|
||||
nextSnapT(time = this.renderT) {
|
||||
const s = this.snaps;
|
||||
const i = this._indexAt(time) + 1;
|
||||
return Number.isFinite(time) && i < s.length ? s[i].t : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
|
||||
* renderT) — the same snapshot sample() reads flags from — or null.
|
||||
@@ -265,8 +338,9 @@ export class SnapshotBuffer {
|
||||
|
||||
/**
|
||||
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
|
||||
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
|
||||
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
|
||||
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves,
|
||||
* neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0:
|
||||
* none), see header). Samples of units no longer present are deleted from `out`.
|
||||
*/
|
||||
sample(time = this.renderT, out = new Map()) {
|
||||
const s = this.snaps;
|
||||
@@ -282,28 +356,36 @@ export class SnapshotBuffer {
|
||||
const stamp = A.t;
|
||||
for (const [id, a] of A.units) {
|
||||
let o = out.get(id);
|
||||
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
|
||||
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); }
|
||||
const b = B ? B.units.get(id) : null;
|
||||
if (b) {
|
||||
const dx = b[1] - a[1], dy = b[2] - a[2];
|
||||
// (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide)
|
||||
const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b);
|
||||
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
|
||||
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
|
||||
o.vx = !tele && span > 0 ? dx / span : 0;
|
||||
o.vy = !tele && span > 0 ? dy / span : 0;
|
||||
const start = movementStart(A, B, id);
|
||||
const moveSpan = B.t - start;
|
||||
const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0;
|
||||
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha;
|
||||
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha;
|
||||
o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0;
|
||||
o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0;
|
||||
o.hp = a[3] + (b[3] - a[3]) * alpha;
|
||||
o.maxHp = b[4] || a[4];
|
||||
o.sp = a[5] + (b[5] - a[5]) * alpha;
|
||||
o.spMax = b[6] || a[6];
|
||||
} else {
|
||||
let vx = 0, vy = 0;
|
||||
if (P && ext > 0) {
|
||||
// a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes
|
||||
// `until` — the next snapshot confirms the position
|
||||
if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) {
|
||||
const p = P.units.get(id);
|
||||
const dtp = A.t - P.t;
|
||||
if (p && dtp > 0) {
|
||||
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
|
||||
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; }
|
||||
const moveSpan = A.t - movementStart(P, A, id);
|
||||
if (p && dtp > 0 && moveSpan > 0) {
|
||||
const dx = a[1] - p[1], dy = a[2] - p[2];
|
||||
if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) {
|
||||
vx = dx / moveSpan; vy = dy / moveSpan;
|
||||
}
|
||||
}
|
||||
}
|
||||
o.x = a[1] + vx * ext;
|
||||
@@ -317,6 +399,11 @@ export class SnapshotBuffer {
|
||||
o.flags = a[7];
|
||||
o.anim = a[8];
|
||||
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
|
||||
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
|
||||
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
|
||||
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
|
||||
o.coins = A.coins?.get(id) ?? null;
|
||||
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
|
||||
o.seen = stamp;
|
||||
}
|
||||
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
|
||||
|
||||
+160
-10
@@ -31,6 +31,8 @@
|
||||
// closing clip timed to end `in` s from now (a 重生's last clip ends with the
|
||||
// 重生), landing in `roles`
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
|
||||
// redraws the model, only advances `clock`)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
|
||||
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
|
||||
@@ -62,20 +64,53 @@ export function windUpPlan(loopDur, hit, interval, lead) {
|
||||
return { ts, tsWind, start };
|
||||
}
|
||||
|
||||
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
|
||||
/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
|
||||
const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)));
|
||||
|
||||
/** Whether any skin of the skeleton data has a clipping attachment. */
|
||||
export function hasClipping(data) {
|
||||
try {
|
||||
for (const skin of data?.skins || []) {
|
||||
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
|
||||
for (const e of list) {
|
||||
const a = e && e.attachment;
|
||||
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
|
||||
}
|
||||
for (const e of list) if (isClip(e && e.attachment)) return true;
|
||||
}
|
||||
} catch { /* unknown runtime shape: assume none */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123).
|
||||
*/
|
||||
export function pointInPolygon(x, y, pts, n) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its
|
||||
* world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four
|
||||
* corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1).
|
||||
*/
|
||||
function slotCentre(slot, att, buf, out) {
|
||||
const n = att.worldVerticesLength | 0;
|
||||
if (n >= 2 && typeof att.computeWorldVertices === 'function') {
|
||||
if (buf.v.length < n) buf.v = new Float32Array(n);
|
||||
const v = buf.v;
|
||||
att.computeWorldVertices(slot, 0, n, v, 0, 2);
|
||||
let cx = 0, cy = 0;
|
||||
for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; }
|
||||
out[0] = cx / (n >> 1); out[1] = cy / (n >> 1);
|
||||
} else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) {
|
||||
const b = slot.bone;
|
||||
out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY;
|
||||
} else return false;
|
||||
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
|
||||
}
|
||||
|
||||
export class SpineActor {
|
||||
/**
|
||||
* @param {any} spineData PIXI.spine skeleton data
|
||||
@@ -91,11 +126,12 @@ export class SpineActor {
|
||||
this.names = new Set((spineData.animations || []).map((a) => a.name));
|
||||
/**
|
||||
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
|
||||
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
|
||||
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
|
||||
* are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them
|
||||
* share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177).
|
||||
*/
|
||||
this.clipped = hasClipping(spineData);
|
||||
this.clipOn = true;
|
||||
this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback)
|
||||
if (this.clipped) {
|
||||
const sp = this.spine;
|
||||
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
|
||||
@@ -126,6 +162,8 @@ export class SpineActor {
|
||||
*/
|
||||
this.clipPerAttack = false;
|
||||
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
|
||||
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
|
||||
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
|
||||
this._play(this._idleName(), true);
|
||||
}
|
||||
|
||||
@@ -134,6 +172,7 @@ export class SpineActor {
|
||||
on = !!on;
|
||||
if (!this.clipped || on === this.clipOn) return;
|
||||
this.clipOn = on;
|
||||
if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback
|
||||
for (const slot of this.spine?.skeleton?.slots || []) {
|
||||
if (!slot.clippingContainer) continue;
|
||||
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
|
||||
@@ -200,6 +239,41 @@ export class SpineActor {
|
||||
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's
|
||||
* stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its
|
||||
* strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's
|
||||
* clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`).
|
||||
*/
|
||||
syncFormPose() {
|
||||
if (this.dead) return;
|
||||
if (this.mode === 'attack') {
|
||||
const track = this.spine.state.tracks[0];
|
||||
const clip = this._attackClip();
|
||||
if (!track || !clip) return;
|
||||
const dur = this.dur(clip.loop);
|
||||
const oldHit = this._hitTime(this.current, this.dur(this.current));
|
||||
const hit = this._hitTime(clip.loop, dur);
|
||||
const start = clampN(track.trackTime + hit - oldHit, 0, dur);
|
||||
const lead = this.wound && this.windUntil != null
|
||||
? Math.max(0, this.windUntil - this.clock) : 0;
|
||||
const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale;
|
||||
const tailTs = lead > 0 ? this.windTs : ts;
|
||||
this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 });
|
||||
this.attackUntil = this.clock + lead +
|
||||
Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs;
|
||||
} else if (this.mode === 'stun') {
|
||||
const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName();
|
||||
this._play(name, true, { mix: 0 });
|
||||
} else if (this.mode === 'base') {
|
||||
this._play(this._baseName(), true, { mix: 0 });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Apply the new attachments even when normal updates are frozen.
|
||||
this.spine.update(0);
|
||||
}
|
||||
|
||||
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
|
||||
_change(clip) {
|
||||
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
|
||||
@@ -240,6 +314,7 @@ export class SpineActor {
|
||||
if (start) e.trackTime = start;
|
||||
}
|
||||
this.current = name;
|
||||
this._applied = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -247,6 +322,7 @@ export class SpineActor {
|
||||
if (!this.has(name)) return false;
|
||||
const e = this.spine.state.addAnimation(0, name, loop, 0);
|
||||
if (e) e.timeScale = timeScale;
|
||||
this._applied = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -332,9 +408,13 @@ export class SpineActor {
|
||||
this.windUntil = null;
|
||||
this.wound = false;
|
||||
if (!wasAttacking) {
|
||||
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
|
||||
// the strike frame now
|
||||
this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 });
|
||||
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the
|
||||
// strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip):
|
||||
// then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball
|
||||
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
|
||||
const whole = per && this.interval >= loopDur;
|
||||
if (whole) this.attackUntil = this.clock + loopDur / ts;
|
||||
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else if (single) {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) e.timeScale = ts;
|
||||
@@ -474,6 +554,24 @@ export class SpineActor {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while an update would draw the same pixels: a frozen model, or a dead one whose only clip — track 0, not
|
||||
* looping, not mixing — has played out (the held end of a death clip; a skeleton without one holds its idle at
|
||||
* timeScale 0). Only once an update has posed the current clip and put the current tint on the slots (pixi-spine applies
|
||||
* `tint` in update: the grey of a knocked-out operator, a hit flash). Never while a form's closing clip or a wind-up is
|
||||
* pending: those act on the clock.
|
||||
*/
|
||||
poseHeld() {
|
||||
if (!this._applied || this.spine.tint !== this._appliedTint || this.endClip || this.windUntil != null) return false;
|
||||
if (this.frozen) return true;
|
||||
if (this.mode !== 'die') return false;
|
||||
const tracks = this.spine.state.tracks;
|
||||
for (let i = 1; i < tracks.length; i++) if (tracks[i]) return false;
|
||||
const e = tracks[0];
|
||||
if (!e || e.loop || e.mixingFrom) return false;
|
||||
return e.timeScale === 0 || e.trackTime >= e.animationEnd - e.animationStart;
|
||||
}
|
||||
|
||||
/** Revive (redeploy after death). */
|
||||
revive() {
|
||||
this.dead = false;
|
||||
@@ -546,6 +644,58 @@ export class SpineActor {
|
||||
if (!this.frozen) {
|
||||
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
|
||||
}
|
||||
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
|
||||
this._applied = true;
|
||||
this._appliedTint = this.spine.tint;
|
||||
}
|
||||
|
||||
/**
|
||||
* The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the
|
||||
* clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after
|
||||
* it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks
|
||||
* are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through
|
||||
* the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw
|
||||
* order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is
|
||||
* hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per
|
||||
* slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update.
|
||||
*/
|
||||
_eyeMaskFallback() {
|
||||
const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers;
|
||||
if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return;
|
||||
this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not)
|
||||
try {
|
||||
const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] });
|
||||
let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing)
|
||||
for (const slot of skel.drawOrder) {
|
||||
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
|
||||
if (isClip(att)) {
|
||||
const n = att.worldVerticesLength | 0;
|
||||
if (buf.poly.length < n) buf.poly = new Float32Array(n);
|
||||
if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2);
|
||||
pn = n >> 1;
|
||||
end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null;
|
||||
continue;
|
||||
}
|
||||
if (pn < 0) continue;
|
||||
const box = boxes[slot.data.index];
|
||||
if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) {
|
||||
box.visible = false;
|
||||
this._clipHidden.add(slot.data.index);
|
||||
}
|
||||
if (end && slot.data === end) { pn = -1; end = null; }
|
||||
}
|
||||
} catch { /* an unknown runtime shape must never stop the frame */ }
|
||||
}
|
||||
|
||||
/** Show the slot containers the fallback hid (those still drawing an attachment). */
|
||||
_showClipHidden() {
|
||||
if (!this._clipHidden.size) return;
|
||||
const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers;
|
||||
for (const i of this._clipHidden) {
|
||||
const slot = slots && slots[i];
|
||||
if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true;
|
||||
}
|
||||
this._clipHidden.clear();
|
||||
}
|
||||
|
||||
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
|
||||
|
||||
@@ -60,9 +60,14 @@ export const COLORS = Object.freeze({
|
||||
hpBoss: 0xff2d55,
|
||||
hpGhost: 0xfff0c8,
|
||||
hpBack: 0x0c0f0e,
|
||||
// the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP
|
||||
hpNeg: 0xff4b3e,
|
||||
sp: 0x6fd3ff,
|
||||
spReady: 0xffe066,
|
||||
spActive: 0xffb347,
|
||||
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
|
||||
ammo: 0xffd04a,
|
||||
wolf: 0xe8f0ff,
|
||||
shield: 0xdfe8ff,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
|
||||
// * status icons, tier chips, backdrop gradients,
|
||||
// * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc),
|
||||
// and the ring's labels (downLabel: one rasterised Text per label, shared by every ring),
|
||||
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
|
||||
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
|
||||
// globalThis.PIXI and a DOM canvas at call time (never at import time).
|
||||
@@ -1424,3 +1425,33 @@ export function ringArc(frac) {
|
||||
if (k === 0 && f > 0) k = 1;
|
||||
return r.arcs[k];
|
||||
}
|
||||
|
||||
/** Redeploy-ring label style (the seconds left, "DP", "!"); `fill` is set per label. */
|
||||
export const DOWN_LABEL_STYLE = Object.freeze({ fontFamily: 'Bender, Oxanium, "Noto Sans SC", sans-serif', fontSize: 32, fontWeight: '700', fill: '#ffffff', stroke: '#0b0f0e', strokeThickness: 6 });
|
||||
const _downLabels = new Map();
|
||||
|
||||
/**
|
||||
* The texture of a redeploy-ring label: one rasterised PIXI.Text per (label, fill, resolution), shared by every ring
|
||||
* that shows it (render/units.js _updateDownRing draws it as a Sprite, anchor 0.5). A ring's own Text re-rasterised its
|
||||
* canvas and re-uploaded its texture on every change, once a second per knocked-out operator, though rings mostly show the
|
||||
* same few numbers; the shared one is drawn once and kept, the same pixels. At most 80 counts (the longest respawn in
|
||||
* chess.json) + "DP" + "!" per resolution, ≈ 2.5 MB at resolution 2.
|
||||
*/
|
||||
export function downLabel(label, fill, resolution = 1) {
|
||||
const key = `${label}|${fill}|${resolution}`;
|
||||
let t = _downLabels.get(key);
|
||||
if (!t) {
|
||||
t = new (PIXI().Text)(label, { ...DOWN_LABEL_STYLE, fill });
|
||||
t.resolution = resolution;
|
||||
t.updateText(false);
|
||||
_downLabels.set(key, t);
|
||||
}
|
||||
return t.texture;
|
||||
}
|
||||
|
||||
/** Redraw the shared ring labels in place (web fonts that landed after some were drawn); their textures stay the same. */
|
||||
export function refreshDownLabels() {
|
||||
for (const t of _downLabels.values()) {
|
||||
try { t.updateText(false); } catch { /* keep the old glyphs */ }
|
||||
}
|
||||
}
|
||||
+306
-32
@@ -67,7 +67,7 @@
|
||||
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, downLabel, HUD_DISC, ELEMENT_RING } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
|
||||
@@ -110,6 +110,16 @@ export function dieClipDur(entry) {
|
||||
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
|
||||
return Number.isFinite(d) && d > 0 ? d : 0;
|
||||
}
|
||||
/**
|
||||
* render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a
|
||||
* device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive
|
||||
* again (redeployed without an event) stands up first.
|
||||
*/
|
||||
export function syncView(v, s, t) {
|
||||
if (v.alive || v.info?.kind === 'device') v.sync(s, t);
|
||||
else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } }
|
||||
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); }
|
||||
}
|
||||
/** 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());
|
||||
@@ -117,6 +127,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat
|
||||
* 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a
|
||||
* frozen gate. */
|
||||
const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']);
|
||||
/**
|
||||
* Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽"
|
||||
* (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the
|
||||
* damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable.
|
||||
*/
|
||||
const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']);
|
||||
const ICE_FRAME = 0x9fe6ff;
|
||||
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
|
||||
const PIC_WAIT_MS = 400;
|
||||
@@ -161,6 +177,34 @@ const RAISED_Z = 0.12;
|
||||
* ride the body (`hover`); range highlights are tiles.
|
||||
*/
|
||||
export const FLY_HOVER = 1.3;
|
||||
/**
|
||||
* A push / pull slide (推拉, PR #380 by @xcdoge). The sim displaces at once — server/sim/battle/displacement.js walks its
|
||||
* 0.1-tile steps inside one call — and then holds the enemy in its 失衡 (UNBALANCE) state (PR #392's idea): a push for the
|
||||
* 位移时间 of its 受力等级 (PRTS 游戏数据基础 推力-位移近似对应表: 0.2 … 35/30 game s), a pull for its force window (推与拉:
|
||||
* 0.5 / 1 s). The snapshots only ever show the destination, so the view slides there under a constant deceleration
|
||||
* (UnitView.slideTo) for that same span: the `displace` fx carries it (`dur`, game seconds) and the view divides it by the
|
||||
* playback rate (ctx.animRate — 2 game s per real second by default: a 0.8 s push slides 0.4 real s) — it lands as the
|
||||
* sim's state ends. v0 = 2D/T, a = v0/T then run the distance in exactly T. DISPLACE_SLIDE (real s per √tile, kept in
|
||||
* 0.12–0.45 s) is only the fallback for an fx without `dur` (a recording made before 0.2.2) [ASSUMED].
|
||||
*/
|
||||
export const DISPLACE_SLIDE = 0.14;
|
||||
/**
|
||||
* Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode
|
||||
* is normal land (its 11 maps have no ice or slime), so it is always 1.
|
||||
*/
|
||||
export const SLIDE_FRICTION = 1;
|
||||
/**
|
||||
* Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps
|
||||
* its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in
|
||||
* `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md).
|
||||
*/
|
||||
const DIE_FADE_TAIL = 0.55;
|
||||
/**
|
||||
* Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234
|
||||
* enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip
|
||||
* out (PR #380). It only bounds a view whose data lies.
|
||||
*/
|
||||
export const DIE_CLIP_MAX = 8;
|
||||
|
||||
/** 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 });
|
||||
@@ -184,12 +228,26 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
|
||||
* between `pulse` and 1 at `pulseHz` (real time).
|
||||
*/
|
||||
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
|
||||
/**
|
||||
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
|
||||
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
|
||||
* (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide;
|
||||
* - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px
|
||||
* below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha).
|
||||
* [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the
|
||||
* official HUD art of neither is in the game data.
|
||||
*/
|
||||
export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 });
|
||||
export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 });
|
||||
|
||||
/**
|
||||
* Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm —
|
||||
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
|
||||
* once first:
|
||||
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
|
||||
* - 假想敌:骨刺 (GitHub #296, PR #365): not an fx — its form follows the snapshot's stealth bit (STEALTH_FORMS): the
|
||||
* manifest's *_A (the three-headed snake) while its 隐匿 is on, 'revealed' *_B the moment it is blocked or revealed, *_A
|
||||
* again when the 隐匿 is back; no change clip, a running attack / stun pose carried over (SpineActor.syncFormPose);
|
||||
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
|
||||
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
|
||||
* Attack clip);
|
||||
@@ -224,6 +282,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
|
||||
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
|
||||
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
|
||||
*/
|
||||
/**
|
||||
* Enemy forms that follow the snapshot's stealth bit (UF.STEALTH: the sim sends it only while the enemy's 隐匿 is on —
|
||||
* not blocked, not revealed) instead of a sim fx, per Spine id: the FORMS kind drawn while the bit is OFF; the manifest's
|
||||
* clips while it is on. Switched at once (UnitView.sync, and for a model loaded later the load callback).
|
||||
*/
|
||||
export const STEALTH_FORMS = Object.freeze({ enemy_9008_acbunn: 'revealed' });
|
||||
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
|
||||
const clipSet = (idle, move, die, attack = null) => Object.freeze({
|
||||
idle, deploy: idle, die, move: loop(move),
|
||||
@@ -274,6 +338,9 @@ export const FORMS = Object.freeze({
|
||||
enemy_2025_syufo: Object.freeze({
|
||||
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
|
||||
}),
|
||||
enemy_9008_acbunn: Object.freeze({
|
||||
revealed: Object.freeze({ change: null, roles: clipSet('Idle_B', 'Move_B', 'Die_B', 'Attack_B') }),
|
||||
}),
|
||||
enemy_10081_mpplai: Object.freeze({
|
||||
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
|
||||
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
|
||||
@@ -393,6 +460,7 @@ export class UnitView {
|
||||
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; 0 for enemies)
|
||||
this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update)
|
||||
/** @type {number|null} */
|
||||
this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z
|
||||
/** @type {number|null} */
|
||||
@@ -407,6 +475,11 @@ export class UnitView {
|
||||
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.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
|
||||
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
|
||||
this.coins = null; // [balance, cap] of 琳琅诗怀雅's skill purse, including zero
|
||||
this.coinHud = null;
|
||||
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
|
||||
this.flags = 0; this.anim = ANIM.IDLE;
|
||||
this.statuses = new Set();
|
||||
this.alive = true;
|
||||
@@ -417,7 +490,9 @@ export class UnitView {
|
||||
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
|
||||
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
|
||||
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
|
||||
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
|
||||
this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use
|
||||
this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts)
|
||||
this._downRing = null; // { disc, track, arc, text } sprites (text: the shared label, textures.js downLabel), 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;
|
||||
@@ -457,6 +532,7 @@ export class UnitView {
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
|
||||
this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start
|
||||
this._spineBusy = false; // a Spine load of this view is in flight
|
||||
this._deployPending = false; // deploy() was requested before the Spine model finished loading
|
||||
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
|
||||
@@ -578,7 +654,8 @@ export class UnitView {
|
||||
// no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP
|
||||
// redeployed: its Back model takes over from the Front model it lay down with)
|
||||
const swap = !!this.actor;
|
||||
const deployed = swap && this.alive ? this.actor.deployElapsed() : null;
|
||||
const deployed = swap && this.alive ? this.actor.deployElapsed() : this._pendingDeployElapsed;
|
||||
this._pendingDeployElapsed = null;
|
||||
if (swap) this._dropActor();
|
||||
this.actor = actor;
|
||||
this._actorEntry = entry;
|
||||
@@ -657,6 +734,8 @@ export class UnitView {
|
||||
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);
|
||||
// a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar
|
||||
this.negFill = bar(P, h, COLORS.hpNeg);
|
||||
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);
|
||||
@@ -666,7 +745,7 @@ export class UnitView {
|
||||
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;
|
||||
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, 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') {
|
||||
@@ -734,13 +813,14 @@ export class UnitView {
|
||||
// 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.followSample(s, t);
|
||||
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
|
||||
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
|
||||
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
|
||||
// constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one
|
||||
// high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it.
|
||||
const floor = groundZ(this.ctx, s.x, s.y);
|
||||
// While sliding, the floor is read under the view's own (mid-flight) position, not the destination's.
|
||||
const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y);
|
||||
const gz = this.isEnemy ? 0 : floor;
|
||||
if (this.zTarget == null) this.z = gz;
|
||||
this.zTarget = gz;
|
||||
@@ -752,10 +832,23 @@ export class UnitView {
|
||||
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
|
||||
this.hp = hp;
|
||||
this.sp = s.sp; this.spMax = s.spMax;
|
||||
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
|
||||
this.coins = s.coins || null;
|
||||
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;
|
||||
// a stealth-driven form (STEALTH_FORMS: 假想敌:骨刺) follows this snapshot's stealth bit at once — before a DIE in the
|
||||
// same snapshot, so it dies in the form it had
|
||||
const stealthForm = this.isEnemy && this.alive ? STEALTH_FORMS[this.info.spine || this.info.defId] : null;
|
||||
if (stealthForm) {
|
||||
const form = this.flags & UF.STEALTH ? null : stealthForm;
|
||||
if (form !== this.form) {
|
||||
this.setForm(form);
|
||||
this.actor?.syncFormPose();
|
||||
if (this.imp) this.imp.dirty = true;
|
||||
}
|
||||
}
|
||||
if (this.isEnemy && !this.slide?.keepFacing && 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());
|
||||
@@ -763,6 +856,52 @@ export class UnitView {
|
||||
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
|
||||
/**
|
||||
* The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases
|
||||
* to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or
|
||||
* snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots),
|
||||
* so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render).
|
||||
*/
|
||||
followSample(s, t) {
|
||||
const sl = this.slide;
|
||||
if (!sl) { this.x = s.x; this.y = s.y; }
|
||||
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there: `opts.dur` = the
|
||||
* fx's 失衡 time in game seconds (real seconds = dur / ctx.animRate), else the DISPLACE_SLIDE·√D fallback.
|
||||
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
|
||||
* interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood
|
||||
* (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the
|
||||
* sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without
|
||||
* `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push
|
||||
* put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown.
|
||||
*/
|
||||
slideTo(x, y, opts = {}) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
// already on the way there: app.js starts the slide ahead of the event, then the event itself asks again
|
||||
if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return;
|
||||
if (this.down) { this.slide = null; return; }
|
||||
const dx = x - this.x, dy = y - this.y;
|
||||
const D = Math.hypot(dx, dy);
|
||||
if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; }
|
||||
const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION;
|
||||
// the sim's 失衡 time (game s) at the playback rate; a slippery tile (f < 1) slides longer
|
||||
const rate = this.ctx.animRate?.() || 1;
|
||||
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f);
|
||||
// v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update().
|
||||
// Push and pull fx carry keepFacing by default: the victim retains its pre-hit facing through the slide. Raw
|
||||
// displacements have no flag and still face their travel; snapshots take over when the slide ends.
|
||||
if (this.isEnemy && !opts.keepFacing) this.visFacing = dx < 0 ? -1 : 1;
|
||||
const v0 = 2 * D / T;
|
||||
const at = Number.isFinite(opts.at) ? opts.at : -Infinity;
|
||||
this.slide = {
|
||||
x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T,
|
||||
at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0, keepFacing: opts.keepFacing === true,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -833,6 +972,7 @@ export class UnitView {
|
||||
/** An attack was made (b.ev 'atk'). `target` = view or null. */
|
||||
onAttack(target, now, kind) {
|
||||
if (!this.alive) return;
|
||||
this._pendingDeployElapsed = null;
|
||||
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
|
||||
const once = !!PROJ[kind]?.once;
|
||||
if (!once) {
|
||||
@@ -930,6 +1070,7 @@ export class UnitView {
|
||||
*/
|
||||
die(instant = false) {
|
||||
if (!this.alive) return;
|
||||
this._pendingDeployElapsed = null;
|
||||
this.alive = false;
|
||||
this._modelDirty = true;
|
||||
this._dieForm = this._formSpec();
|
||||
@@ -937,7 +1078,7 @@ export class UnitView {
|
||||
if (this.actor && !d) d = this._fallDur();
|
||||
const rate = this.ctx.animRate?.() || 1;
|
||||
this.dieT = 0;
|
||||
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
|
||||
this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL;
|
||||
this.dieDur = this.dying;
|
||||
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
|
||||
}
|
||||
@@ -976,7 +1117,7 @@ export class UnitView {
|
||||
if (!d) {
|
||||
if (!this.down) return;
|
||||
this.down = null;
|
||||
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
|
||||
if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; }
|
||||
return;
|
||||
}
|
||||
if (this.alive) this.die(instant);
|
||||
@@ -997,6 +1138,33 @@ export class UnitView {
|
||||
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
|
||||
update(dt, cam, t) {
|
||||
if (this.destroyed) return;
|
||||
if (this._pendingDeployElapsed != null) this._pendingDeployElapsed += dt * (this.ctx.animRate?.() || 1);
|
||||
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
|
||||
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
|
||||
if (this.slide) {
|
||||
const sl = this.slide;
|
||||
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
|
||||
else if (!sl.live) {
|
||||
this.x = sl.sx; this.y = sl.sy;
|
||||
// the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the
|
||||
// destination the fx named
|
||||
if ((sl.wait += dt) > 0.5) sl.live = true;
|
||||
} else {
|
||||
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
|
||||
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
|
||||
const v0 = sl.v;
|
||||
sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame
|
||||
// the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short:
|
||||
// 9 % of a 0.18 s slide at 60 fps, then a jump onto the end)
|
||||
sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step);
|
||||
sl.t += step;
|
||||
if (this.isEnemy && !sl.keepFacing) this.visFacing = sl.ux < 0 ? -1 : 1;
|
||||
const k = 1 - sl.done / sl.D; // the share of the way still to go
|
||||
this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k;
|
||||
// the clock or the spent velocity lands it on the sampled position
|
||||
if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; }
|
||||
}
|
||||
}
|
||||
const P = this.P;
|
||||
// the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive)
|
||||
if (this._modelDirty) { this._modelDirty = false; this._syncModel(); }
|
||||
@@ -1015,7 +1183,9 @@ export class UnitView {
|
||||
const d = this.shadowZTarget - this.shadowZ;
|
||||
this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12);
|
||||
}
|
||||
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
|
||||
// a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in
|
||||
// CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground
|
||||
const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? 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;
|
||||
@@ -1029,7 +1199,7 @@ export class UnitView {
|
||||
} else if (this.dying > 0) {
|
||||
this.dieT += dt;
|
||||
this.dying -= dt;
|
||||
const tail = 0.55;
|
||||
const tail = DIE_FADE_TAIL;
|
||||
if (this.dying < tail) alpha *= Math.max(0, this.dying / tail);
|
||||
if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; }
|
||||
}
|
||||
@@ -1083,18 +1253,26 @@ export class UnitView {
|
||||
this._far = this._far ? s < 60 : s < 52;
|
||||
if (lvl >= 2 || this._far) interval = Math.max(interval, 2);
|
||||
}
|
||||
let clipFlip = false;
|
||||
if (this.actor.clipped) {
|
||||
const clip = this.ctx.clipAllowed ? this.ctx.clipAllowed() : true;
|
||||
const was = this.actor.clipOn;
|
||||
this.actor.setClipping(clip);
|
||||
clipFlip = was !== this.actor.clipOn;
|
||||
if (clip && this.ctx.impostors) interval = Math.max(1, interval);
|
||||
}
|
||||
// a pose that cannot change (a knocked-out operator lying in its held Die pose, a frozen model) is neither re-posed
|
||||
// nor redrawn — it would draw the same pixels again; the actor's clock still runs
|
||||
const held = !clipFlip && this.actor.poseHeld();
|
||||
if (interval > 0 && this.ctx.renderer) {
|
||||
this._updateImpostor(sc, flip, tint, animDt, interval);
|
||||
if (clipFlip && this.imp) this.imp.dirty = true; // masks on / off: the slot is redrawn either way
|
||||
this._updateImpostor(sc, flip, tint, animDt, interval, held);
|
||||
} else {
|
||||
if (this.imp) this._leaveImpostor();
|
||||
this.actor.spine.alpha = this.swapT;
|
||||
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
|
||||
this.actor.update(animDt);
|
||||
if (held) this.actor.clock += animDt;
|
||||
else this.actor.update(animDt);
|
||||
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
|
||||
}
|
||||
}
|
||||
@@ -1181,7 +1359,7 @@ export class UnitView {
|
||||
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 showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId);
|
||||
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
|
||||
@@ -1202,24 +1380,32 @@ export class UnitView {
|
||||
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;
|
||||
}
|
||||
// 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one
|
||||
const neg = showHp ? this.neg : 0;
|
||||
this.negFill.visible = neg > 0;
|
||||
if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; }
|
||||
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;
|
||||
// SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`;
|
||||
// the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar.
|
||||
const ammo = !this.isEnemy ? this.ammo : null;
|
||||
const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo);
|
||||
this.spBg.visible = this.spFill.visible = showSp;
|
||||
const spH = Math.max(2, bh * 0.6);
|
||||
const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6));
|
||||
let ready = false;
|
||||
let sy = 0;
|
||||
if (showSp) {
|
||||
const active = !!(this.flags & UF.SKILL);
|
||||
const k = clamp(this.sp / this.spMax, 0, 1);
|
||||
const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1);
|
||||
ready = !active && k >= 0.999;
|
||||
const sy = cy + bh / 2 + spH / 2 + 1.5;
|
||||
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.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
|
||||
this._spY = sy;
|
||||
}
|
||||
this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH);
|
||||
this.spGlow.visible = ready;
|
||||
if (ready) {
|
||||
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
|
||||
@@ -1250,8 +1436,12 @@ export class UnitView {
|
||||
ic.width = ic.height = isz;
|
||||
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
|
||||
}
|
||||
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
|
||||
// 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a
|
||||
// knocked-down operator (b.snap `down`)
|
||||
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
|
||||
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
|
||||
const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t);
|
||||
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);
|
||||
@@ -1276,6 +1466,82 @@ export class UnitView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round
|
||||
* magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px
|
||||
* (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use.
|
||||
*/
|
||||
_updateAmmoCuts(show, ammo, x0, y, bw, h) {
|
||||
const cuts = this.ammoCuts;
|
||||
const n = show ? ammo[1] : 0;
|
||||
const step = n > 1 ? bw / n : 0;
|
||||
const want = step >= AMMO_BAR.minCell ? n - 1 : 0;
|
||||
while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9));
|
||||
for (let i = 0; i < cuts.length; i++) {
|
||||
const c = cuts[i];
|
||||
c.visible = i < want;
|
||||
if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the
|
||||
* talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row
|
||||
* takes (0 while hidden) so the element gauge row can sit below it.
|
||||
*/
|
||||
_updateWolfPips(show, x0, top, s) {
|
||||
let r = this._wolfPips;
|
||||
if (!show) { if (r) r.root.visible = false; return 0; }
|
||||
const P = this.P;
|
||||
const [left, max] = this.wolves;
|
||||
if (!r) {
|
||||
r = this._wolfPips = { root: new P.Container(), back: [], lit: [] };
|
||||
this.hud.addChild(r.root);
|
||||
}
|
||||
const mk = (list, color, alpha) => {
|
||||
const d = new P.Sprite(white());
|
||||
d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha;
|
||||
r.root.addChild(d);
|
||||
list.push(d);
|
||||
};
|
||||
while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); }
|
||||
const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal
|
||||
const side = d / Math.SQRT2;
|
||||
const cy = top + WOLF_PIPS.pad + d / 2 + 1;
|
||||
for (let i = 0; i < r.lit.length; i++) {
|
||||
const on = i < max;
|
||||
const b = r.back[i], l = r.lit[i];
|
||||
b.visible = l.visible = on;
|
||||
if (!on) continue;
|
||||
const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap);
|
||||
b.position.set(cx, cy); b.width = b.height = side + 2;
|
||||
l.position.set(cx, cy); l.width = l.height = side;
|
||||
l.alpha = i < left ? 1 : WOLF_PIPS.dim;
|
||||
}
|
||||
r.root.visible = true;
|
||||
return d + 2 + WOLF_PIPS.pad;
|
||||
}
|
||||
|
||||
/** Coin icon and balance/cap, read from the current snapshot rather than transient FX. */
|
||||
_updateCoins(show, x, top, s) {
|
||||
let r = this.coinHud;
|
||||
if (!show) { if (r) r.root.visible = false; return 0; }
|
||||
if (!r) {
|
||||
const P = this.P, root = new P.Container(), icon = new P.Sprite(fxAtlas().tex.coin);
|
||||
icon.width = icon.height = 16;
|
||||
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 });
|
||||
text.position.set(19, -2); root.addChild(icon, text); this.hud.addChild(root);
|
||||
r = this.coinHud = { root, text, label: '' };
|
||||
}
|
||||
const label = `${this.coins[0]}/${this.coins[1]}`;
|
||||
if (r.label !== label) { r.label = label; r.text.text = label; }
|
||||
const size = clamp(s * 0.2, 10, 16);
|
||||
r.root.scale.set(size / 16); r.root.position.set(x, top + 3); r.root.visible = true;
|
||||
// Text includes font metrics and stroke beyond its nominal 16 px size; reserve the measured row so the next
|
||||
// elemental gauge cannot overlap it (especially with a substituted browser font).
|
||||
return Math.max(16, r.root.getLocalBounds?.().height || 24) * (size / 16) + 8;
|
||||
}
|
||||
|
||||
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
|
||||
elementCooling() {
|
||||
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
||||
@@ -1345,10 +1611,8 @@ export class UnitView {
|
||||
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 };
|
||||
const text = mk(P.Texture.EMPTY);
|
||||
r = this._downRing = { root, disc, track, arc, text, label: null, res: 0 };
|
||||
this.hud.addChild(root);
|
||||
}
|
||||
const dn = this.down;
|
||||
@@ -1358,10 +1622,11 @@ export class UnitView {
|
||||
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) {
|
||||
const res = this.ctx.renderer?.resolution || 1;
|
||||
if (r.label !== label || r.res !== res) {
|
||||
r.label = label;
|
||||
r.text.text = label;
|
||||
r.text.style.fill = counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0');
|
||||
r.res = res;
|
||||
r.text.texture = downLabel(label, counting ? '#ffffff' : '#' + color.toString(16).padStart(6, '0'), res);
|
||||
}
|
||||
const d = clamp(s * DOWN_LOOK.size, 22, 52);
|
||||
const k = d / tex.size;
|
||||
@@ -1391,7 +1656,7 @@ export class UnitView {
|
||||
return this._box;
|
||||
}
|
||||
|
||||
_updateImpostor(sc, flip, tint, animDt, interval) {
|
||||
_updateImpostor(sc, flip, tint, animDt, interval, held = false) {
|
||||
const P = this.P;
|
||||
const atlas = this.ctx.impostors || null;
|
||||
if (!this.imp) {
|
||||
@@ -1399,16 +1664,25 @@ export class UnitView {
|
||||
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 };
|
||||
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
|
||||
}
|
||||
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;
|
||||
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
|
||||
// a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it
|
||||
// culled on and off in step) is still refreshed after 2 intervals at the latest.
|
||||
const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
|
||||
const rescale = Math.abs(sc - imp.sc) > imp.sc * 0.12;
|
||||
// a held pose (SpineActor.poseHeld) keeps the image its slot already shows; only its clock moves on
|
||||
const still = held && !imp.dirty && !rescale;
|
||||
if (still) { this.actor.clock += imp.acc; imp.acc = 0; }
|
||||
const due = !still && (imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval || rescale);
|
||||
if (due) {
|
||||
this.actor.update(imp.acc);
|
||||
imp.acc = 0;
|
||||
imp.dirty = false;
|
||||
imp.last = frame;
|
||||
this._renderImpostor(sc, atlas);
|
||||
}
|
||||
const k = imp.sc > 0 ? sc / imp.sc : 1;
|
||||
|
||||
Reference in new issue
Block a user