feedback1 integrate: WD 转译基底 transforms, 逐火 embers beatable, knock-out forms kept by the client
# Conflicts: # docs/PLAYING.md
This commit is contained in:
commit
3328dedea2
19 files changed
+1850
-188
No files matched your search
@@ -13,6 +13,11 @@
|
||||
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
|
||||
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
|
||||
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
|
||||
// A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys,
|
||||
// statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is
|
||||
// hidden the battle on screen keeps the same events (≤ HELD_MAX, else the view re-enters from the field meta) and the
|
||||
// first frame back delivers them, so the view still knows every unit and every enemy's current form (player report #5
|
||||
// after 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model).
|
||||
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
|
||||
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
|
||||
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
|
||||
@@ -53,7 +58,7 @@
|
||||
|
||||
import { net as appNet } from '../net.js';
|
||||
import { store as appStore } from '../store.js';
|
||||
import { unitStatsEntry } from '../../../shared/protocol.js';
|
||||
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
|
||||
|
||||
const TICK = 1 / 30;
|
||||
/** Fast-forward budget per frame (ticks) when far behind. */
|
||||
@@ -62,6 +67,13 @@ export const CATCHUP_TICKS = 240;
|
||||
const PREPARE_SLICE = 600;
|
||||
const MAX_ENTRIES = 4;
|
||||
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
|
||||
/**
|
||||
* The b.ev tuples a catch-up frame or the hidden-tab backlog keeps: the state-bearing kinds, and every fx that sets an
|
||||
* enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0).
|
||||
*/
|
||||
export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined);
|
||||
/** Hidden-tab backlog cap (tuples) of the battle on screen; beyond it the view re-enters from the field meta. */
|
||||
export const HELD_MAX = 3000;
|
||||
/** Data files the simulation reads (DataSource + content/support gameData()). */
|
||||
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
|
||||
|
||||
@@ -304,16 +316,33 @@ export function createBattleRunner(deps) {
|
||||
}
|
||||
|
||||
function emitFrame(e, catchingUp) {
|
||||
// the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form)
|
||||
if (e.stale) { show(e); return; }
|
||||
let ev = [];
|
||||
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
|
||||
const gt = Number(e.battle.time) || 0;
|
||||
if (ev.length) {
|
||||
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
|
||||
const held = e.held;
|
||||
if (held.length) e.held = [];
|
||||
if (ev.length || held.length) {
|
||||
const list = held.concat(catchingUp ? ev.filter(keepsState) : ev);
|
||||
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
|
||||
}
|
||||
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Events of a step that is not rendered (hidden tab): the battle on screen keeps its state-bearing ones for the next
|
||||
* rendered frame (keepsState, ≤ HELD_MAX — beyond that it is re-entered from the field meta); any other battle starts
|
||||
* from its field meta when shown (show()), so its events go.
|
||||
*/
|
||||
function hold(e) {
|
||||
let ev = [];
|
||||
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
|
||||
if (e !== cur || e.stale) return;
|
||||
for (const x of ev) if (keepsState(x)) e.held.push(x);
|
||||
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
|
||||
}
|
||||
|
||||
function progress(e, force = false) {
|
||||
if (!e.authoritative || e.resultSent || !net) return;
|
||||
const t = now();
|
||||
@@ -420,7 +449,7 @@ export function createBattleRunner(deps) {
|
||||
const dt = stepEntry(e, n);
|
||||
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
|
||||
if (render) emitFrame(e, catchingUp);
|
||||
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
|
||||
else hold(e);
|
||||
noteLeaks(e);
|
||||
progress(e);
|
||||
if (e.battle.finished) finished(e);
|
||||
@@ -456,6 +485,8 @@ export function createBattleRunner(deps) {
|
||||
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
|
||||
function show(e) {
|
||||
cur = e;
|
||||
e.held = [];
|
||||
e.stale = false;
|
||||
let meta = null;
|
||||
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
|
||||
const field = {
|
||||
@@ -538,6 +569,9 @@ export function createBattleRunner(deps) {
|
||||
leaks: 0, leakMark: '', left: null,
|
||||
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
|
||||
live: null, layerSum: 0,
|
||||
// state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame),
|
||||
// and whether that backlog overflowed (the next frame re-enters the view from the field meta)
|
||||
held: [], stale: false,
|
||||
};
|
||||
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
|
||||
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
|
||||
|
||||
+48
-25
@@ -12,15 +12,19 @@
|
||||
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
|
||||
// the deployed copy it replaced, or on its bench slot — gets the
|
||||
// promotion cue instead (render/promote.js, fx.promote)
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
|
||||
// unit through renderInfo(UnitInfo) (an enemy's `form`: a view built
|
||||
// mid-battle starts in the current model form)
|
||||
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
|
||||
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
|
||||
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
|
||||
// operators on the field under a redeploy ring and `elem` draws the
|
||||
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
|
||||
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
|
||||
// user playtest #5 item 2) goes straight to the held pose, no burst
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// user playtest #5 item 2) goes straight to the held pose, no burst; an
|
||||
// fx with a `form` (shared/protocol.js fxForm) switches the enemy's
|
||||
// model to that clip set (render/units.js FORMS)
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// ~2-frame buffer at the battle's game speed
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
|
||||
@@ -87,10 +91,11 @@
|
||||
// `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps.
|
||||
|
||||
import { GEO, ANIM, UF } from '../../../shared/constants.js';
|
||||
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 } from './units.js';
|
||||
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
|
||||
import { FxSystem, ensureDamageFonts } from './fx.js';
|
||||
import { createDragController, pieceTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||
@@ -243,6 +248,32 @@ export const FORCED_EXIT = 'forcedExit';
|
||||
*/
|
||||
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
|
||||
|
||||
/**
|
||||
* The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js
|
||||
* unitInfo), sanitised; null for a malformed entry. `form` — an enemy's current model form (content/enemies.js setForm:
|
||||
* 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl) — makes a view built mid-battle (a teammate's
|
||||
* field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that
|
||||
* clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5).
|
||||
*/
|
||||
export function renderInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
return {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// an enemy's current model form (UnitInfo.form): the view starts in it (UnitView reads info.form)
|
||||
form: typeof u.form === 'string' ? u.form : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
|
||||
export function boardPreference(opt) {
|
||||
let q = null;
|
||||
@@ -1283,21 +1314,8 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
function addInfo(u) {
|
||||
if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null;
|
||||
const info = {
|
||||
id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null,
|
||||
defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null,
|
||||
avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1,
|
||||
maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion,
|
||||
// deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the
|
||||
// ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge
|
||||
dir: typeof u.dir === 'string' ? u.dir : undefined,
|
||||
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
};
|
||||
infos.set(info.id, info);
|
||||
const info = renderInfo(u);
|
||||
if (info) infos.set(info.id, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -1415,20 +1433,25 @@ export async function createFieldView(host, options = {}) {
|
||||
break;
|
||||
}
|
||||
case 'status': { const v = views.get(e[1]); if (v) v.onStatus?.(e[2], !!e[3]); break; }
|
||||
case 'fx':
|
||||
case 'fx': {
|
||||
if (e[4] && typeof e[4] === 'object' && e[4].consumed && e[4].id != null) {
|
||||
consumedIds.add(e[4].id);
|
||||
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
|
||||
}
|
||||
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set
|
||||
// (UnitView.setForm); the info keeps it for a view built later
|
||||
if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) {
|
||||
// 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
|
||||
// nothing); the info keeps it for a view built later. The client runner never drops these fx (catch-up frames,
|
||||
// hidden-tab backlog), nor does the game screen's pre-entry buffer.
|
||||
const form = fxForm(e);
|
||||
if (form !== undefined) {
|
||||
const inf = infos.get(e[4].id);
|
||||
if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null;
|
||||
views.get(e[4].id)?.setForm?.(e[4].kind);
|
||||
if (inf && (form === null || FORMS[inf.spine || inf.defId]?.[form])) inf.form = form;
|
||||
views.get(e[4].id)?.setForm?.(form, e[4]);
|
||||
}
|
||||
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
|
||||
break;
|
||||
}
|
||||
case 'layer': {
|
||||
const bondId = e[2], n = Number(e[3]) || 0;
|
||||
if (!(n > 0) || typeof bondId !== 'string') break;
|
||||
|
||||
+37
-13
@@ -12,7 +12,10 @@
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards)
|
||||
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
|
||||
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
|
||||
// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode), after a change clip
|
||||
// (no attack cuts the change clip short); `end` = { clip, in, roles? }: a
|
||||
// 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.
|
||||
|
||||
@@ -80,7 +83,10 @@ export class SpineActor {
|
||||
}
|
||||
try { this.spine.stateData.defaultMix = 0.12; } catch { /* ignore */ }
|
||||
this.base = 'idle';
|
||||
this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun
|
||||
this.mode = 'base'; // base | attack | skillBegin | deploy | die | stun | change
|
||||
this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt,
|
||||
this.endAt = 0; // landing in endRoles (the next form's) when given
|
||||
this.endRoles = null;
|
||||
this.skillOn = false;
|
||||
this.attackUntil = 0;
|
||||
this.clock = 0;
|
||||
@@ -118,22 +124,34 @@ export class SpineActor {
|
||||
/**
|
||||
* Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02
|
||||
* clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first
|
||||
* (also while stunned: the pose it ends in is the one a stun then holds).
|
||||
* (also while stunned: the pose it ends in is the one a stun then holds). `end` = { clip, in, roles? } (game s): a
|
||||
* closing clip played the same way so that it ends `in` s from now, landing in `roles` — a leader's 重生 ends on its
|
||||
* last clip while the sim still holds it, and the next form starts on its own clips.
|
||||
*/
|
||||
setForm(roles, change = null) {
|
||||
setForm(roles, change = null, end = null) {
|
||||
const anims = this.entry?.anims || {};
|
||||
this.roles = roles ? { ...anims, ...roles } : anims;
|
||||
this.endClip = null;
|
||||
if (this.dead) return;
|
||||
if (change && this.has(change)) {
|
||||
this.stunWanted = this.mode === 'stun';
|
||||
this.frozen = false;
|
||||
this.mode = 'change';
|
||||
this._play(change, false, { mix: 0.08 });
|
||||
this.changeUntil = this.clock + this.dur(change);
|
||||
} else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
if (end && this.has(end.clip) && end.in > 0) {
|
||||
this.endClip = end.clip;
|
||||
this.endAt = this.clock + Math.max(0, end.in - this.dur(end.clip));
|
||||
this.endRoles = end.roles || null;
|
||||
}
|
||||
if (change && this.has(change)) this._change(change);
|
||||
else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
|
||||
}
|
||||
|
||||
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
|
||||
_change(clip) {
|
||||
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
|
||||
this.frozen = false;
|
||||
this.mode = 'change';
|
||||
this._play(clip, false, { mix: 0.08 });
|
||||
this.changeUntil = this.clock + this.dur(clip);
|
||||
}
|
||||
|
||||
has(name) { return !!name && this.names.has(name); }
|
||||
dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); }
|
||||
|
||||
@@ -212,7 +230,7 @@ export class SpineActor {
|
||||
* false when it is still too early (call again next frame) or there is nothing to wind up.
|
||||
*/
|
||||
windUp(interval, lead) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return 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
|
||||
@@ -232,7 +250,7 @@ export class SpineActor {
|
||||
|
||||
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
|
||||
attack(interval) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
|
||||
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);
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return;
|
||||
@@ -338,6 +356,12 @@ export class SpineActor {
|
||||
|
||||
update(dt) {
|
||||
this.clock += dt;
|
||||
if (this.endClip && this.clock >= this.endAt) {
|
||||
const clip = this.endClip;
|
||||
this.endClip = null;
|
||||
if (this.endRoles) this.roles = { ...(this.entry?.anims || {}), ...this.endRoles };
|
||||
if (!this.dead) this._change(clip);
|
||||
}
|
||||
if (this.windUntil != null && this.clock >= this.windUntil) {
|
||||
// a compressed wind-up reached its strike frame: back to the rhythm speed (attack() also does it)
|
||||
this.windUntil = null;
|
||||
|
||||
+77
-18
@@ -24,8 +24,10 @@
|
||||
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
|
||||
// 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 } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
|
||||
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
|
||||
// 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.
|
||||
// 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
|
||||
@@ -100,22 +102,72 @@ export const DOWN_LOOK = Object.freeze({
|
||||
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
|
||||
|
||||
/**
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
|
||||
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
|
||||
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind, or the `form` of another sim fx
|
||||
* → 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';
|
||||
* - 转译基底·α (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
|
||||
* Die_2 (also while 'Revive' plays: the ember can still be beaten); standing up again → 'Revive', then the warrior's
|
||||
* manifest clips;
|
||||
* - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips;
|
||||
* (both: the stand-up clip (`end`) is timed from the 'ember' fx's `dur` to end as the husk stands up — the 'revive'
|
||||
* fx then only lands in the manifest clips);
|
||||
* - the leaders' 重生 (sim reborn(): forms 'reborn' → 'form2'): 锏 Revive1, Revive2 held, Revive3, then B_*; 扎罗 A_revive_1 /
|
||||
* _2 / _3, then B_* (its double-hit attack); “复仇者” Revive_Begin / _Loop / _End; 杰斯顿 C1_Die (its 4 s 重生), then
|
||||
* C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5). The closing
|
||||
* clip (`end`) is timed from the 重生's `dur` (the 'telegraph' fx) to end with it, so the second form walks and
|
||||
* attacks on its own clips at once (a view that missed the timing — built mid-重生 — skips the closing clip);
|
||||
* - 守墓石像 (forms 'stone' → 'fly'): the statue on Sleep [ASSUMED by name], then the flyer's *_2 clips.
|
||||
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
|
||||
*/
|
||||
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),
|
||||
attack: attack ? loop(attack, 'attackAny') : null,
|
||||
skill: attack ? Object.freeze({ begin: null, loop: attack, end: null, via: 'attack', index: 0, idle: null }) : null,
|
||||
});
|
||||
const EMBER = Object.freeze({
|
||||
husk: Object.freeze({ change: 'Die', end: 'Revive', next: 'revived', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }),
|
||||
revived: Object.freeze({ change: null, roles: Object.freeze({}) }),
|
||||
});
|
||||
/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, closing on `end` as the 重生 ends,
|
||||
* then the second form. */
|
||||
const rebirth = (begin, hold, end, form2 = Object.freeze({})) => Object.freeze({
|
||||
reborn: Object.freeze({ change: begin, end, next: 'form2', roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }),
|
||||
form2: Object.freeze({ change: null, roles: form2 }),
|
||||
});
|
||||
const STATUE = Object.freeze({
|
||||
stone: Object.freeze({ change: null, roles: Object.freeze({ idle: 'Sleep', deploy: 'Sleep', move: loop('Sleep'), stun: loop('Sleep'), attack: null, skill: null }) }),
|
||||
fly: Object.freeze({ change: null, roles: clipSet('Idle_2', 'Move_2', 'Die_2', 'Attack_2') }),
|
||||
});
|
||||
const JAKILL2 = clipSet('C2_Idle', 'C2_Move', 'C2_Die', 'C2_Attack');
|
||||
export const FORMS = Object.freeze({
|
||||
enemy_2025_syufo: Object.freeze({
|
||||
crawl: Object.freeze({
|
||||
change: 'Change',
|
||||
roles: Object.freeze({
|
||||
idle: 'Idle_02', deploy: 'Idle_02', die: 'Die_02',
|
||||
move: Object.freeze({ begin: null, loop: 'Move_02', end: null }),
|
||||
attack: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attackAny' }),
|
||||
skill: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attack', index: 0, idle: null }),
|
||||
}),
|
||||
}),
|
||||
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
|
||||
}),
|
||||
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') }),
|
||||
translator_shushi: Object.freeze({ change: 'A_Die_D', roles: clipSet('D_Idle', 'D_Move', 'D_Die', 'D_Attack') }),
|
||||
}),
|
||||
enemy_1288_duskls: EMBER,
|
||||
enemy_1288_duskls_2: EMBER,
|
||||
enemy_1292_duskld: EMBER,
|
||||
enemy_9010_acpupp: Object.freeze({
|
||||
husk: Object.freeze({ change: 'A_Die', end: 'B_Revive', next: 'revived', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }),
|
||||
revived: Object.freeze({ change: null, roles: Object.freeze({}) }),
|
||||
}),
|
||||
enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
|
||||
enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
|
||||
enemy_1539_reid: rebirth('Revive_Begin', 'Revive_Loop', 'Revive_End'),
|
||||
enemy_1516_jakill: Object.freeze({
|
||||
reborn: Object.freeze({ change: 'C1_Die', roles: JAKILL2 }),
|
||||
form2: Object.freeze({ change: null, roles: JAKILL2 }),
|
||||
}),
|
||||
enemy_1172_dugago: STATUE,
|
||||
enemy_1172_dugago_2: STATUE,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -342,18 +394,25 @@ export class UnitView {
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit changed mode (sim fx 'phase' { id, kind }): the mode's clip set (FORMS) after its change clip; a kind with
|
||||
* no clip set of its own goes back to the manifest clips. Kept for a model built later.
|
||||
* The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `fx` = that fx's extra): the mode's clip
|
||||
* set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end
|
||||
* `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second),
|
||||
* so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton
|
||||
* has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later.
|
||||
*/
|
||||
setForm(kind) {
|
||||
setForm(kind, fx = null) {
|
||||
const k = typeof kind === 'string' ? kind : null;
|
||||
if (k === this.form) return;
|
||||
if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return;
|
||||
const had = !!this._formSpec();
|
||||
this.form = k;
|
||||
this.info.form = k;
|
||||
const f = this._formSpec();
|
||||
if (!this.actor) return;
|
||||
if (f) this.actor.setForm(f.roles, f.change || null);
|
||||
const dur = fx && Number(fx.dur);
|
||||
let next = f && f.next ? FORMS[this.info.spine || this.info.defId]?.[f.next]?.roles || null : null;
|
||||
if (next && typeof f.roles.die === 'string') next = { ...next, die: f.roles.die };
|
||||
if (f) this.actor.setForm(f.roles, f.change || null, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null);
|
||||
else if (had) this.actor.setForm(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { fxForm } from '../../../shared/protocol.js';
|
||||
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
|
||||
import { useGameData, GIcon } from '../ui/gameComponents.js';
|
||||
import { useFieldView } from '../ui/fieldHost.js';
|
||||
@@ -109,6 +110,9 @@ const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3,
|
||||
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
|
||||
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
|
||||
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
|
||||
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
|
||||
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
|
||||
const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
export function GameScreen() {
|
||||
@@ -446,7 +450,7 @@ function MatchScreen() {
|
||||
if (typeof msg.fieldId !== 'string') return;
|
||||
const buf = evBufRef.current.get(msg.fieldId) || [];
|
||||
// only state-bearing tuples are replayed later (see the enter effect)
|
||||
for (const e of msg.ev) if (Array.isArray(e) && STATE_EV.has(e[0])) buf.push(e);
|
||||
for (const e of msg.ev) if (keepEarly(e)) buf.push(e);
|
||||
if (buf.length > 1500) buf.splice(0, buf.length - 1500);
|
||||
evBufRef.current.set(msg.fieldId, buf);
|
||||
if (evBufRef.current.size > 8) evBufRef.current.delete(evBufRef.current.keys().next().value);
|
||||
|
||||
Reference in new issue
Block a user