feat(render): 干员入场、换位及回合开始时播放入场动画与落地音效
This commit is contained in:
1 parent
3eced7bdba
commit
c44a484e11
5 files changed
+235
-6
No files matched your search
+30
-2
@@ -252,6 +252,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
|
||||
let promoBase = [];
|
||||
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
|
||||
const deployTimers = new Set();
|
||||
let battleMeta = null;
|
||||
let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene)
|
||||
const infos = new Map(); // battle unit id → UnitInfo
|
||||
@@ -290,6 +291,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const heightAt = (r, c) => (tiles ? tiles.heightAt(r, c) : 0);
|
||||
const ctx = {
|
||||
P, layers, assets, settings, fx: null, shadowTex: shadowTexture(),
|
||||
audio: opts.audio || options.audio || null,
|
||||
cam: () => cam, heightAt,
|
||||
animRate: () => (mode === 'battle' ? interp.rate : 1),
|
||||
timeScale: () => (mode === 'battle' ? interp.rate : 1),
|
||||
@@ -742,12 +744,12 @@ export async function createFieldView(host, options = {}) {
|
||||
views.set(key, v);
|
||||
if (promoFrom.has(e.uid)) {
|
||||
// a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot
|
||||
if (e.area === 'board') v.onDeploy?.();
|
||||
if (e.area === 'board') v.onDeploy?.(); else v.onDeploy?.();
|
||||
fx.promote(v, promoFrom.get(e.uid).filter((f) => Math.abs(f.x - w.x) + Math.abs(f.y - w.y) > 1e-3));
|
||||
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
|
||||
if (promotions.length > 20) promotions.shift();
|
||||
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) fx.deploy(v);
|
||||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||||
} else {
|
||||
const prevHome = v._home;
|
||||
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
|
||||
@@ -760,6 +762,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (moved || Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
|
||||
v.lift = 0;
|
||||
if (e.area === 'board' && !prevBoard.has(e.uid) && v.onDeploy) { v.onDeploy(); fx.deploy(v); }
|
||||
else if (moved && v.onDeploy) { v.onDeploy(); }
|
||||
}
|
||||
}
|
||||
v._home = w;
|
||||
@@ -1780,6 +1783,29 @@ export async function createFieldView(host, options = {}) {
|
||||
v.setDir(prepXf.dirToDisp(dir.toUpperCase()));
|
||||
return true;
|
||||
},
|
||||
deployPiece(uid, force = false) {
|
||||
if (destroyed || !Number.isInteger(uid)) return false;
|
||||
const v = views.get('p:' + uid);
|
||||
if (v && !v.destroyed && typeof v.onDeploy === 'function') {
|
||||
v.onDeploy(force);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
deploySequence(uids, delayMs = 50) {
|
||||
if (destroyed || !Array.isArray(uids)) return;
|
||||
uids.forEach((uid, i) => {
|
||||
const tid = setTimeout(() => {
|
||||
deployTimers.delete(tid);
|
||||
if (destroyed) return;
|
||||
const v = views.get('p:' + uid);
|
||||
if (v && !v.destroyed && typeof v.onDeploy === 'function') {
|
||||
v.onDeploy(true);
|
||||
}
|
||||
}, i * delayMs);
|
||||
deployTimers.add(tid);
|
||||
});
|
||||
},
|
||||
setSettings(s) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
const q = settings.quality;
|
||||
@@ -1818,6 +1844,8 @@ export async function createFieldView(host, options = {}) {
|
||||
clearTimeout(recover.timer);
|
||||
drag.reset();
|
||||
for (const k of [...views.keys()]) dropView(k);
|
||||
for (const tid of deployTimers) clearTimeout(tid);
|
||||
deployTimers.clear();
|
||||
clearPen();
|
||||
holdScene(false); // no scene any more: the quiet budget may free the skeletons (lobby / room / result)
|
||||
try { fx.destroy(); } catch { /* ignore */ }
|
||||
|
||||
@@ -458,6 +458,7 @@ export class UnitView {
|
||||
this.spineReady = false;
|
||||
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
|
||||
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)
|
||||
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
|
||||
this._spineHidden = false; // the load in flight began while the tab was hidden (SPINE_STUCK_MS)
|
||||
@@ -599,7 +600,14 @@ export class UnitView {
|
||||
} else {
|
||||
if (this.flags & UF.SKILL) this.actor.setSkill(true);
|
||||
this.actor.setBase(this._baseFromAnim());
|
||||
if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); }
|
||||
if (deployed != null) {
|
||||
this.actor.deploy();
|
||||
if (deployed > 0) this.actor.update(deployed);
|
||||
} else if (this._deployPending) {
|
||||
this.actor.deploy();
|
||||
this._playDeploySound();
|
||||
this._deployPending = false;
|
||||
}
|
||||
}
|
||||
}, () => {
|
||||
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
|
||||
@@ -879,10 +887,30 @@ export class UnitView {
|
||||
if (this.actor) this.actor.setSkillIndex(index);
|
||||
}
|
||||
|
||||
onDeploy() {
|
||||
onDeploy(force = false) {
|
||||
this.fadeIn = 0;
|
||||
if (!this.alive) this.revive();
|
||||
if (this.actor) this.actor.deploy();
|
||||
if (this.actor) {
|
||||
const elapsed = this.actor.deployElapsed();
|
||||
if (force || elapsed == null || elapsed > 0.3) {
|
||||
this.actor.deploy();
|
||||
this._playDeploySound();
|
||||
}
|
||||
this._deployPending = false;
|
||||
} else {
|
||||
this._deployPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
_playDeploySound() {
|
||||
if (this.isEnemy) return;
|
||||
const a = this.ctx?.audio;
|
||||
if (!a) return;
|
||||
const def = this.info?.spine || this.info?.defId;
|
||||
const played = def && typeof a.unit === 'function' ? a.unit(def, 'born', this.id) : false;
|
||||
if (!played && typeof a.battle === 'function') {
|
||||
a.battle(this.isToken ? 'tokenDeploy' : 'deploy', { unitKey: `deploy:${this.id}`, volume: 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
onStatus(key, on) {
|
||||
|
||||
@@ -636,6 +636,19 @@ function MatchScreen() {
|
||||
if (phase !== PHASE.PREP) setRewardMin(false);
|
||||
setSpBusy(null);
|
||||
}, [phaseKey]);
|
||||
// round-start deploy sequence: in operator deployment order, play start animation with 50ms interval and landing sound
|
||||
const lastStartRoundRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const round = pub?.round;
|
||||
if ((phase === PHASE.ROUND_START || phase === PHASE.PREP) && round != null && lastStartRoundRef.current !== round) {
|
||||
lastStartRoundRef.current = round;
|
||||
const boardOps = Array.isArray(priv?.board) ? priv.board.filter((p) => p && p.kind !== 'item') : [];
|
||||
if (boardOps.length && view?.deploySequence) {
|
||||
view.deploySequence(boardOps.map((p) => p.uid), 50);
|
||||
}
|
||||
}
|
||||
}, [phase, pub?.round, priv?.board, view]);
|
||||
|
||||
|
||||
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
|
||||
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
|
||||
@@ -1029,6 +1042,7 @@ function MatchScreen() {
|
||||
const f = live.current.facing;
|
||||
if (!f) return;
|
||||
setFacing(null);
|
||||
if (f.piece.kind !== 'item') view?.deployPiece?.(f.uid);
|
||||
const intent = facingIntent(f.piece, { row: f.row, col: f.col }, dir);
|
||||
heldRef.current.set(f.uid, { row: f.row, col: f.col, t: Date.now() });
|
||||
const ok = intent.t === 'g.art'
|
||||
|
||||
@@ -19,7 +19,7 @@ const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapsho
|
||||
// direction-step hooks (ui/facingWheel.js): optional — the wheel falls back to the engine's dev hooks when absent;
|
||||
// setPen (enemy preview pen list), prepField ({ kind, side, mirror } of the Final Assault prep), stripesUnder (the view
|
||||
// stripes range previews under the units itself) — render/app.js; the DOM fallback lacks them (→ null)
|
||||
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder'];
|
||||
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder', 'deployPiece', 'deploySequence'];
|
||||
|
||||
/**
|
||||
* Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar /
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// test/render/operator-deploy-anim.test.js — verification of operator deploy animation triggers and landing sfx.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
let fake, UnitView;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView } = await import('../../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const cam = () => presetCamera('prep', { width: 1280, height: 720 });
|
||||
|
||||
function makeStore({ spineDelay = 0 } = {}) {
|
||||
const entryFront = {
|
||||
skel: '/s/op.skel', atlas: '/s/op.atlas', textures: ['/s/op.png'],
|
||||
anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } },
|
||||
animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 },
|
||||
};
|
||||
const entryBack = {
|
||||
skel: '/s/op_b.skel', atlas: '/s/op_b.atlas', textures: ['/s/op_b.png'],
|
||||
anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } },
|
||||
animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 },
|
||||
};
|
||||
return {
|
||||
picture: (id) => `/pic/${id}.png`,
|
||||
image: async () => ({ width: 180, height: 180 }),
|
||||
imageNow: () => ({ width: 180, height: 180 }),
|
||||
hasBack: () => true,
|
||||
spineEntry: (_id, opts) => (opts?.back ? entryBack : entryFront),
|
||||
spine: {
|
||||
acquire: async () => {
|
||||
if (spineDelay > 0) await new Promise((r) => setTimeout(r, spineDelay));
|
||||
return {
|
||||
animations: [
|
||||
{ name: 'Idle' },
|
||||
{ name: 'Start' },
|
||||
{ name: 'Die' },
|
||||
{ name: 'Attack' },
|
||||
],
|
||||
};
|
||||
},
|
||||
release() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeAudioMock() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
battle(name, opts) {
|
||||
calls.push({ type: 'battle', name, opts });
|
||||
},
|
||||
unit(def, kind, id) {
|
||||
calls.push({ type: 'unit', def, kind, id });
|
||||
return false; // fall back to battle('deploy')
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Operator deploy animation & landing sound', () => {
|
||||
test('onDeploy before spine model loads sets _deployPending, plays deploy and sound on load', async () => {
|
||||
const assets = makeStore({ spineDelay: 10 });
|
||||
const audio = makeAudioMock();
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
ctx.audio = audio;
|
||||
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', tier: 3, x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
|
||||
assert.equal(v.actor, null, 'actor not yet loaded');
|
||||
v.onDeploy();
|
||||
assert.equal(v._deployPending, true, 'deploy marked pending');
|
||||
assert.equal(audio.calls.length, 0, 'no sound played while loading');
|
||||
|
||||
// Wait for spine to load
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
assert.ok(v.actor, 'actor loaded');
|
||||
assert.equal(v.actor.mode, 'deploy', 'actor played deploy animation on load');
|
||||
assert.equal(v._deployPending, false, 'deploy pending cleared');
|
||||
assert.ok(audio.calls.some((c) => c.name === 'deploy'), 'landing sound played on load');
|
||||
});
|
||||
|
||||
test('facing preview via setDir only displays idle and does NOT trigger deploy or sound', async () => {
|
||||
const assets = makeStore({ spineDelay: 0 });
|
||||
const audio = makeAudioMock();
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
ctx.audio = audio;
|
||||
const v = new UnitView(ctx, { id: 2, side: 'ally', kind: 'chess', defId: 'char_op', tier: 3, x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.ok(v.actor, 'actor loaded');
|
||||
audio.calls.length = 0; // clear any init sound
|
||||
|
||||
v.actor.update(1.5);
|
||||
assert.equal(v.actor.mode, 'base', 'actor in idle mode');
|
||||
|
||||
// Change direction to LEFT (preview)
|
||||
v.setDir('LEFT');
|
||||
assert.equal(v.dir, 'LEFT');
|
||||
assert.equal(v.actor.mode, 'base', 'facing preview stays in idle/base mode');
|
||||
assert.equal(audio.calls.length, 0, 'no sound on facing preview');
|
||||
|
||||
// Change direction to UP (swaps to Back model)
|
||||
v.setDir('UP');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(v.dir, 'UP');
|
||||
assert.ok(v.entryBack, 'using Back model');
|
||||
assert.equal(v.actor.mode, 'base', 'Back model stays in idle/base mode during preview');
|
||||
assert.equal(audio.calls.length, 0, 'no sound on facing preview with model swap');
|
||||
});
|
||||
|
||||
test('confirming deployment plays deploy animation and landing sound', async () => {
|
||||
const assets = makeStore({ spineDelay: 0 });
|
||||
const audio = makeAudioMock();
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
ctx.audio = audio;
|
||||
const v = new UnitView(ctx, { id: 3, side: 'ally', kind: 'chess', defId: 'char_op', tier: 3, x: 5, y: 12, maxHp: 1000, dir: 'UP' }, { prep: true });
|
||||
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.ok(v.actor);
|
||||
audio.calls.length = 0;
|
||||
|
||||
v.onDeploy();
|
||||
assert.equal(v.actor.mode, 'deploy', 'deploy played upon confirmation');
|
||||
assert.ok(audio.calls.some((c) => c.name === 'deploy'), 'landing sound played upon confirmation');
|
||||
});
|
||||
|
||||
test('deploySequence plays start animation and sound with delay between units', async () => {
|
||||
const assets = makeStore({ spineDelay: 0 });
|
||||
const audio = makeAudioMock();
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
ctx.audio = audio;
|
||||
|
||||
const v1 = new UnitView(ctx, { id: 10, side: 'ally', kind: 'chess', defId: 'char_op1', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
const v2 = new UnitView(ctx, { id: 11, side: 'ally', kind: 'chess', defId: 'char_op2', tier: 3, x: 6, y: 10, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
v1.actor.update(1.5);
|
||||
v2.actor.update(1.5);
|
||||
assert.equal(v1.actor.mode, 'base');
|
||||
assert.equal(v2.actor.mode, 'base');
|
||||
audio.calls.length = 0;
|
||||
|
||||
// Simulate deploy sequence: v1 at 0ms, v2 at 50ms
|
||||
v1.onDeploy(true);
|
||||
assert.equal(v1.actor.mode, 'deploy');
|
||||
assert.equal(v2.actor.mode, 'base');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
v2.onDeploy(true);
|
||||
assert.equal(v2.actor.mode, 'deploy');
|
||||
|
||||
const deploySounds = audio.calls.filter((c) => c.name === 'deploy');
|
||||
assert.equal(deploySounds.length, 2, 'two deploy landing sounds played 50ms apart');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user