feedback7: merge fb7-render — #382 赤刃明霄陈 S3 sword-qi drawn (sim chen3Wave fx), #381 enemy attack recovery held in interpolation, #380 push / pull slide (corpse stays where pushed; same-step kills finish the slide), #384 eye masks, 香槟炸弹 HP bar hidden, #246 an unwound attack plays its whole clip
- Conflicts with the merged fb7-hud (both added display-only snapshot lists): server/sim/battle/events.js, types/core.js,
public/js/render/interp.js, docs/SIM.md, docs/design/network.md — union of ammo / wolves / neg and stand / standCut.
- Grok review: MERGE AFTER FIXES — the HIGH finding (same-step push kill) fixed on the branch (7a33bc7e).
- Golden: diy-073 / diy-074 (fx counts only; union OK); broad node --test 5442 (5441 pass, 0 fail, 1 skipped); lint 0
errors; typecheck.
This commit is contained in:
commit
cf98f4c3f8
27 files changed
+1812
-53
No files matched your search
+27
-6
@@ -117,7 +117,7 @@ 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';
|
||||
@@ -1447,6 +1447,14 @@ 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]));
|
||||
}
|
||||
// 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
|
||||
@@ -1499,10 +1507,24 @@ 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;
|
||||
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), { at: slideAt });
|
||||
}
|
||||
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);
|
||||
@@ -1513,9 +1535,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
|
||||
@@ -1586,8 +1606,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;
|
||||
|
||||
@@ -15,6 +15,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
|
||||
|
||||
@@ -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 : {};
|
||||
@@ -168,6 +169,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));
|
||||
|
||||
+66
-16
@@ -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)
|
||||
@@ -21,7 +22,7 @@
|
||||
// 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]. Optional lists ride along
|
||||
// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9):
|
||||
// (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);
|
||||
@@ -35,10 +36,16 @@
|
||||
// `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 我执).
|
||||
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
|
||||
// * `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). */
|
||||
@@ -50,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']);
|
||||
/**
|
||||
@@ -73,9 +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, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | 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.
|
||||
* 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;
|
||||
@@ -115,7 +133,21 @@ export function normalizeSnapshot(snap) {
|
||||
(down || (down = [])).push(e);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, neg, 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, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,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.
|
||||
@@ -320,22 +362,30 @@ export class SnapshotBuffer {
|
||||
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;
|
||||
|
||||
+99
-10
@@ -62,20 +62,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 +124,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;
|
||||
@@ -134,6 +168,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;
|
||||
@@ -332,9 +367,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;
|
||||
@@ -546,6 +585,56 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
|
||||
+128
-7
@@ -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,36 @@ 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): real seconds per √tile of travel. The sim displaces at once —
|
||||
* server/sim/battle/displacement.js walks its 0.1-tile steps inside one call — so the snapshots only ever show the
|
||||
* destination, and a pushed / pulled enemy appeared at the end of its path (a ≤ 2.5-tile push in one snapshot interval,
|
||||
* a longer pull snapped). The view now slides there under a constant deceleration (UnitView.slideTo): the official
|
||||
* knockback abilities (`Knockback`, `KnockBackWithDirection`, `DragTowardSource` in the client's metadata) carry an
|
||||
* impulse (`m_force`) and a velocity decaying under friction (`m_friction`, the tile's `additionalFriction`), and the data
|
||||
* has no per-skill duration (docs/research/13-knockback-official.md). The duration T = DISPLACE_SLIDE·√D, kept in
|
||||
* 0.12–0.45 s, is ours [ASSUMED: a fit so the view arrives where the sim put it — no source gives the official
|
||||
* timing]; v0 = 2D/T, a = v0/T then run the distance in exactly T. Purely visual: the sim's timing, damage, blocking and
|
||||
* the golden digests are untouched.
|
||||
*/
|
||||
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 });
|
||||
@@ -404,6 +450,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} */
|
||||
@@ -744,13 +791,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;
|
||||
@@ -774,6 +822,51 @@ 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 (DISPLACE_SLIDE).
|
||||
* `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;
|
||||
// a slippery tile (f < 1) slides longer; the duration still follows √D (constant deceleration)
|
||||
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur : 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().
|
||||
// The official turns a displaced unit towards the force (its _dontChangeFaceByDirection is an opt-in flag), while
|
||||
// our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the
|
||||
// displacement, i.e. against the push. The slide therefore owns the facing until it lands.
|
||||
if (this.isEnemy) 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,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
@@ -928,7 +1021,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); }
|
||||
}
|
||||
@@ -967,7 +1060,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);
|
||||
@@ -988,6 +1081,32 @@ 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;
|
||||
// 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) 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(); }
|
||||
@@ -1006,7 +1125,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;
|
||||
@@ -1020,7 +1141,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; }
|
||||
}
|
||||
@@ -1172,7 +1293,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
|
||||
|
||||
Reference in new issue
Block a user