Merge branch 'master' into fix/spine-start-anim
This commit is contained in:
577 files changed
+43650
-10558
No files matched your search
+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