feedback1 integrate: WN 灼热/炽焰源石虫 models (local overlay, tinted alias); 暴鸰 bomb drop

# Conflicts:
#	docs/SIM.md
#	public/js/render/app.js
#	public/js/render/spine.js
#	public/js/render/units.js
#	server/sim/content/enemies.js
#	server/sim/snapshot.js
This commit is contained in:
sganggs committed 2026-10-03 06:45:59 +08:00
commit 4b15919185
26 files changed
+1383 -90

No files matched your search

+39 -5
View File
@@ -11,6 +11,8 @@
// assets.spine.release(entry);
// await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
// assets.spineEntry('enemy_1305_mhslim') // its official model from the local client once local() listed
// // it (manifest spineLocal), else the web model; `.fallback`
//
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
@@ -116,6 +118,10 @@ export function subProfIconUrl(m, sub) {
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null.
* `opts.local` (the data/local-assets.json manifest): an enemy whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3) gets that model when the manifest lists every one of
* its files; the returned entry's `fallback` is the web model (aliased), for a load failure (DESIGN §13: local art is
* optional, everything works without it).
*/
export function spineEntry(m, id, opts) {
const s = str(id);
@@ -129,14 +135,42 @@ export function spineEntry(m, id, opts) {
if (tk) return validSpine(tk.spine) ? tk.spine : null;
const en = get(get(m, 'enemies'), s);
if (en) {
if (validSpine(en.spine)) return en.spine;
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
return al && validSpine(al.spine) ? al.spine : null;
let web = null;
if (validSpine(en.spine)) web = en.spine;
else {
const alias = str(en.spineAliasOf);
const al = alias ? get(get(m, 'enemies'), alias) : null;
web = al && validSpine(al.spine) ? al.spine : null;
}
return (opts && opts.local && localSpineEntry(en.spineLocal, opts.local, web)) || web;
}
return null;
}
const localSpines = new WeakMap(); // spineLocal record → { local, web, entry } (one entry object per manifest pair)
/**
* A manifest `spineLocal` (`{ group, skel, atlas, textures[], pma, anims, … }`, file names in the data/local-assets.json
* group) as a Spine entry with URLs, when `local` lists every file — skeleton and atlas side by side (pixi-spine finds
* the atlas by the skeleton's name); else null. `fallback` = `web`.
*/
export function localSpineEntry(sl, local, web) {
if (!isObj(sl) || !isObj(local)) return null;
const c = localSpines.get(sl);
if (c && c.local === local && c.web === web) return c.entry;
const g = str(sl.group);
const url = (name) => (g ? localAssetUrl(local, g, name) : null);
const skel = url(sl.skel), atlas = url(sl.atlas);
const textures = Array.isArray(sl.textures) ? sl.textures.map(url) : [];
let entry = null;
if (skel && atlas && skel.replace(/\.skel$/, '.atlas') === atlas && textures.length && textures.every(Boolean)) {
entry = { skel, atlas, textures, pma: !!sl.pma, anims: sl.anims, animations: sl.animations, events: sl.events, hits: sl.hits, bounds: sl.bounds, local: true, fallback: web || null };
if (!validSpine(entry)) entry = null;
}
localSpines.set(sl, { local, web, entry });
return entry;
}
/** Whether an operator/token/enemy has a Back model. */
export function hasBackSpine(m, id) {
const ch = get(get(m, 'chars'), str(id) || '') || get(get(m, 'chars'), baseCharId(id) || '');
@@ -651,7 +685,7 @@ export function createAssets(options) {
subProfIcon: (s) => subProfIconUrl(m(), s),
ui: (name) => uiUrl(m(), name),
picture: (id) => unitPictureUrl(m(), id),
spineEntry: (id, o) => spineEntry(m(), id, o),
spineEntry: (id, o) => spineEntry(m(), id, localManifest ? { ...o, local: localManifest } : o),
hasBack: (id) => hasBackSpine(m(), id),
audio: {
bgm: (kind) => bgmEntry(m(), kind),
+10 -5
View File
@@ -79,8 +79,10 @@
// three.js PerspectiveCamera is synced from it every change. Missing art / three / WebGL2, a failed init or a lost
// context fall back to the 2D atlas board. `opts.board`: 'auto' (default) | '3d' | '2d'; `?board=2d|3d` in the URL
// overrides (dev; '3d' also accepts a slow / software GPU). stats().board3d → { on, calls, triangles, cpuMs }.
// three.js is only downloaded when the local-art manifest lists the board atlas. The built 3D area, the drawn 2D rows
// and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault prep = the boss field).
// three.js is only downloaded when the local-art manifest lists the board atlas. That manifest is awaited with the asset
// manifest (≤ 4 s), so an enemy whose model only the local client has draws it (assets.js spineEntry). The built 3D
// area, the drawn 2D rows and the lit rect follow `viewKind` (a 'prep' camera on the boss rows = the Final Assault
// prep = the boss field).
// Battle device boxes (`ctx.createBox`) follow the board layer (switchableBox), so a 3D ⇄ 2D switch keeps crates.
// Crowds and clipped Spine skeletons render through the shared impostor atlas (render/impostor.js), flushed once per
// frame before the main pass.
@@ -388,7 +390,10 @@ export async function createFieldView(host, options = {}) {
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));
await withTimeout(Promise.resolve(assets.ready ? assets.ready() : null).catch(() => {}), 4000);
// 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);
// 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 */ }
@@ -1367,7 +1372,7 @@ export async function createFieldView(host, options = {}) {
if (e[0] !== 'atk' || woundUp.has(e) || CHAIN_KINDS.has(e[3])) return;
const v = views.get(e[1]);
if (!v || !v.windUp) return;
if (v.windUp(t - upcomingT)) woundUp.add(e);
if (v.windUp(t - upcomingT, e[3])) woundUp.add(e);
}
const EVS = [];
@@ -1399,7 +1404,7 @@ export async function createFieldView(host, options = {}) {
const src = views.get(e[1]) || battleView(e[1]);
const tgt = views.get(e[2]) || battleView(e[2]);
// chain / chainHeal bounces: the "source" is the previous target of the bounce, not an attacker
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now);
if (src && !CHAIN_KINDS.has(e[3])) src.onAttack?.(tgt, now, e[3]);
if (e[3] === 'none' || !e[3]) { if (tgt && src) meleePending.set(tgt.id, { src, t: now }); }
fx.attack(src, tgt, e[3]);
break;
+7
View File
@@ -237,9 +237,16 @@ const FX_GUESS = [
[/stealth|hide|vanish|cloak/i, 'vanish'], [/pull|push|knock|dash|leap/i, 'move'], [/pulse|wave|ring|sonic/i, 'wave'],
];
/**
* 'phase' (an enemy's mode change, render/units.js FORMS) kinds the model shows on its own: 暴鸰 'bombed' — its bomb is
* the 'droneBomb' projectile of the same moment, a puff on the drone would read as something else (feedback D4).
*/
const SILENT_PHASES = new Set(['bombed']);
/** Visual spec of an fx kind (see FX_KINDS); `extra.kind` / `extra.element` may pick a better colour. */
export function fxSpec(kind, extra = {}) {
const k = typeof kind === 'string' ? kind : '';
if (k === 'phase' && SILENT_PHASES.has(extra && extra.kind)) return { a: 'none', c: 0xffffff };
let spec = FX_KINDS[k];
if (!spec) {
const g = FX_GUESS.find(([re]) => re.test(k));
+24 -14
View File
@@ -6,8 +6,10 @@
//
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
// attack(interval) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
// clip plays once at its own speed, then base
// setSkill(on) skill begin→loop while active (skill idle replaces idle), end on stop
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards)
@@ -17,7 +19,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)
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast: to the end of its clip), then the
// end clip (if any) and base.
const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -229,42 +232,49 @@ export class SpineActor {
* clip from its wind-up so the strike frame lands when the attack event is rendered. Returns true once started;
* false when it is still too early (call again next frame) or there is nothing to wind up.
*/
windUp(interval, lead) {
windUp(interval, lead, once = false) {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
const iv = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const loopDur = this.dur(clip.loop);
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const hit = this._hitTime(clip.loop, loopDur);
const plan = windUpPlan(loopDur, hit, iv, lead);
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
this.interval = iv;
if (!once) this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + Math.max(0.45, iv * 1.4);
this._play(clip.loop, true, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.attackUntil = this.clock + lead + (once ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
this._play(clip.loop, !once, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
return true;
}
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
attack(interval) {
/**
* An attack happened now. `interval` = seconds between attacks (game time already scaled to real); `once` = a one-off
* cast (no rhythm): the clip plays once at its own speed from its strike frame, then the resting state.
*/
attack(interval, once = false) {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change') return; // a form change plays out
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
if (!once) this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
const clip = this._attackClip();
if (!clip) return;
const loopDur = this.dur(clip.loop);
const ts = clampN(loopDur / this.interval, 0.35, 4);
const ts = once ? 1 : clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop;
this.mode = 'attack';
this.attackUntil = this.clock + Math.max(0.45, this.interval * 1.4);
this.attackUntil = this.clock + (once ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
this.windUntil = null;
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, true, { timeScale: ts, start: hit, mix: 0.06 });
this._play(clip.loop, !once, { timeScale: ts, start: hit, mix: 0.06 });
} else if (once) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
} else {
const e = this.spine.state.tracks[0];
if (e) {
+6 -1
View File
@@ -89,7 +89,7 @@ export function dmgStyleKey(type) {
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/**
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang | droneBomb;
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
@@ -101,6 +101,8 @@ export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0x
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
* once a one-off cast, not an attack rhythm: the shooter's attack clip plays once at its own speed (render/spine.js
* SpineActor.attack / windUp) — 暴鸰's bomb drop
*/
export const PROJ = Object.freeze({
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
@@ -111,6 +113,9 @@ export const PROJ = Object.freeze({
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
// 暴鸰's bomb (sim content/enemies.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls
// onto its target with a low arc and bursts where the sim's 'explode' blast goes off
droneBomb: { look: 'shell', speed: 5, once: true, tint: 0xffe2c8, glow: 0xff5a3a, len: 0.6, width: 0.3, head: 0.5, trail: 0xff7a4a, arc: 0.35, smoke: 0x2e2824, hit: 'boom' },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
+61 -19
View File
@@ -4,7 +4,10 @@
// the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar
// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and
// cross-fades to the Spine model when it has loaded; missing or failed models keep the fallback forever (the
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
// game never blocks on Spine) — except an optional local-client model (assets.js spineEntry `fallback`, DESIGN §13:
// 灼热源石虫 / 炽焰源石虫), which falls back to the web model first; that web alias (the plain 源石虫) is drawn tinted
// toward the slug's own colours (ALIAS_TINT). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw
// calls.
//
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
@@ -25,9 +28,10 @@
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } or another fx's `form` → `setForm(kind, fx)`): 掠海漂移体 dropping to 爬行模式
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 转译基底's forms, the 逐火
// embers, 再生's puppet, the leaders' 重生 and 守墓石像 likewise (user report after 0.1.0) — FORMS. A view built later
// (`info.form` = UnitInfo `form`, the sim's current form, through render/app.js renderInfo) starts in the mode.
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its
// bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像
// likewise (user report after 0.1.0) — FORMS. A view built later (`info.form` = UnitInfo `form`, the sim's current form,
// through render/app.js renderInfo) starts in the mode.
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
@@ -55,7 +59,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 { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
@@ -93,6 +97,13 @@ export const DOWN_LOOK = Object.freeze({
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/**
* An enemy drawn with another enemy's web model because its own is only in the local client (manifest `spineAliasOf`
* + `spineLocal`: 灼热 / 炽焰源石虫 → the plain 源石虫's skeleton, feedback D3 after 0.1.0): a multiply tint toward its own
* lava colours (orange / red-orange), so it reads apart from the plain slug where the local art was not extracted
* (research 07 §5.6 "a hue shift") [ASSUMED look]. Never on its official (local) model; status tints win over it.
*/
export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_1305_mhslim_2: 0xff9070 });
/**
* Element gauge row under the bars (see header): the disc's diameter in tiles and its pixel clamp (enemies × `enemy`),
* the gap under the bars (px). The white bar is as tall as the SP bar and fills the rest of the bars' width. During a
@@ -106,6 +117,9 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* → 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';
* - 暴鸰 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);
* - 转译基底·α (user report after 0.1.0, #5): its 2 s change clip A_Die_B / _C / _D, then 寻仇者 B_*, 幽灵 C_* (no attack
* clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move;
* - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on
@@ -144,6 +158,14 @@ const STATUE = Object.freeze({
});
const JAKILL2 = clipSet('C2_Idle', 'C2_Move', 'C2_Die', 'C2_Attack');
export const FORMS = Object.freeze({
enemy_1040_bombd: Object.freeze({
bombed: Object.freeze({
roles: Object.freeze({
idle: 'Idle_2', deploy: 'Idle_2', die: 'Die_2',
move: Object.freeze({ begin: 'Move_Begin_2', loop: 'Move_Loop_2', end: 'Move_End_2' }),
}),
}),
}),
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
}),
@@ -294,6 +316,7 @@ export class UnitView {
this.body.addChild(this.fallback);
this.actor = null;
this.spineReady = false;
this.baseTint = 0xffffff; // the drawn model's own tint (ALIAS_TINT), under the status tints
this.hud = new P.Container();
ctx.layers.bars.addChild(this.hud);
@@ -349,10 +372,19 @@ export class UnitView {
const back = this._wantsBack();
const entry = id ? a.spineEntry(id, { back }) : null;
if (!entry || this.ctx.settings?.quality === 'low' && this.isEnemy && !this.isBoss && this.ctx.crowded?.()) return;
// every acquire is paired with exactly one release: a superseded / failed / post-destroy load releases its own
// entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or destroyed
this.entry = entry;
this.entryBack = back;
this._acquireSpine(entry, id);
}
/**
* Load `entry` and show it. Every acquire is paired with exactly one release: a superseded / failed / post-destroy
* load releases its own entry; the displayed model's entry (`_actorEntry`) is released when that model is replaced or
* destroyed. A model that fails to load keeps the fallback diamond — unless the entry names a `fallback` (an optional
* local-client model, assets.js spineEntry: DESIGN §13), which is loaded in its place.
*/
_acquireSpine(entry, id) {
const a = this.ctx.assets;
this.entry = entry;
const req = this._spineReq = (this._spineReq || 0) + 1;
a.spine.acquire(entry).then((data) => {
if (this.destroyed || req !== this._spineReq) { this._releaseEntry(entry); return; }
@@ -370,6 +402,7 @@ export class UnitView {
if (swap) this._dropActor();
this.actor = actor;
this._actorEntry = entry;
this.baseTint = (!entry.local && ALIAS_TINT[id]) || 0xffffff; // the web alias of a local-only model
this.body.addChild(this.actor.spine);
this.spineReady = true;
this.swapT = swap ? 1 : 0;
@@ -386,7 +419,11 @@ export class UnitView {
if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim());
}
}, () => { this._releaseEntry(entry); /* keep the fallback */ });
}, () => {
this._releaseEntry(entry);
if (entry.fallback && !this.destroyed && req === this._spineReq) this._acquireSpine(entry.fallback, id);
// else keep the fallback diamond
});
}
_formSpec() {
@@ -573,13 +610,17 @@ export class UnitView {
}
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now) {
onAttack(target, now, kind) {
if (!this.alive) return;
if (this.lastAtk >= 0) {
const d = now - this.lastAtk;
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
// 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) {
if (this.lastAtk >= 0) {
const d = now - this.lastAtk;
if (d > 0.05 && d < 6) this.atkInterval = this.atkInterval * 0.6 + d * 0.4;
}
this.lastAtk = now;
}
this.lastAtk = now;
if (target && !this.isEnemy && this.info.kind !== 'device') {
// operators keep their deploy direction (research 09 §1.2); enemies may turn towards their target
} else if (target && this.isEnemy) {
@@ -591,17 +632,18 @@ export class UnitView {
this.lungeDir.x = dx / len; this.lungeDir.y = dy / len;
}
this.lunge = 1;
if (this.actor) this.actor.attack(this.atkInterval); // game seconds: the actor's clock runs in game time
if (this.actor) this.actor.attack(this.atkInterval, once); // game seconds: the actor's clock runs in game time
if (this.imp) this.imp.dirty = true;
}
/**
* An attack by this unit is `lead` game seconds ahead in the snapshot buffer: start the Spine attack wind-up now
* so the strike frame lines up with the attack. True once started (then stop calling for that attack).
* so the strike frame lines up with the attack. True once started (then stop calling for that attack). `kind` = the
* 'atk' projKind (a one-off cast winds up at the clip's own speed).
*/
windUp(lead) {
windUp(lead, kind) {
if (!this.alive || !this.actor || !this.spineReady) return false;
const ok = this.actor.windUp(this.atkInterval, lead);
const ok = this.actor.windUp(this.atkInterval, lead, !!PROJ[kind]?.once);
if (ok && this.imp) this.imp.dirty = true;
return ok;
}
@@ -732,7 +774,7 @@ export class UnitView {
this.fallback.visible = this.swapT < 1;
const sc = s * UNIT.modelScale * this.modelK;
const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff;
let tint = this.baseTint;
if (this.down) tint = DOWN_LOOK.tint;
else if (this.flags & UF.FROZEN) tint = 0x9fd4ff;
else if (this.flags & UF.COLD) tint = 0xcfe6ff;