0.2.6-dev: port PR #416 by Cromemadnd — at every round start the own board's pieces replay their Start clip and landing sound one after another in deployment order (the maintainer's confirmation from official play, 2026-10-13), and a purchase or a move onto / across the board plays them too ([ASSUMED], the PR author's account); one sound per landing, idle prep pieces only, the round marked once the view took the sequence, own board only, timers cleared when prep mode ends and on destroy; DESIGN §31.8; golden: unchanged
This commit is contained in:
1 parent
281cf01ff0
commit
231da0efbc
16 files changed
+791
-22
No files matched your search
@@ -62,6 +62,9 @@
|
||||
// - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the
|
||||
// operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`,
|
||||
// 香槟炸弹) plays its impact sound instead of a death sound.
|
||||
// - A prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's replay of the own
|
||||
// board, a purchase, a move onto or across the board) makes its battle deploy sound once (`deployCue`): the same file
|
||||
// and gain, the battle limiter and the SFX channel's volume and mute.
|
||||
// - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning).
|
||||
//
|
||||
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
|
||||
@@ -1127,6 +1130,29 @@ export class AudioManager {
|
||||
if (typeof url === 'string') this._play(url, { volume, limited: true, unitKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* The landing sound of a prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's
|
||||
* replay of the own board, a purchase, a move onto or across the board): the sound its battle deployment makes
|
||||
* (deploySfxUrl — its own ON_UNIT_BORN sound with the bank's mix, else b_char_set / b_char_tokenset) at the same gains,
|
||||
* through the SFX channel (volume, mute) and the battle limiter (≤ 2 copies of one sound, a cooldown per piece).
|
||||
* @param {{ def?: string|null, defId?: string|null, kind?: string|null }} info the piece: its model id (the sfx.units
|
||||
* key), its chess / token id and its kind ('op' | 'token' — the official sound class, unitSoundClass)
|
||||
* @param {string|number} key the piece (its cooldown key)
|
||||
* @returns {boolean} whether the piece has a landing sound (a silent roll of its bank still counts)
|
||||
*/
|
||||
deployCue(info, key) {
|
||||
try {
|
||||
const m = this.getManifest();
|
||||
const u = { def: info?.def ?? null, defId: info?.defId ?? null, kind: info?.kind ?? null, side: 'ally' };
|
||||
const url = deploySfxUrl(m, u);
|
||||
if (!url) return false;
|
||||
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.born : null;
|
||||
if (unitSoundPlays(mix, this.random())) this._playUnitUrl(url, `land:${key}`, own ? unitGain(0.8, mix) : 0.5);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* React to `b.ev` tuples (DESIGN §8.2).
|
||||
* @param {any[]} ev
|
||||
|
||||
+83
-9
@@ -14,7 +14,15 @@
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
|
||||
// new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; 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)
|
||||
// promotion cue instead (render/promote.js, fx.promote). Landings (PR #416,
|
||||
// UnitView.land: the Start clip again + the landing sound, once): an
|
||||
// operator that just arrived on the bench (a purchase, a reward, a grant)
|
||||
// and a merge's elite [ASSUMED], a piece this state puts on a board tile it
|
||||
// was not on — from the bench, from another tile, a swap — [ASSUMED]
|
||||
// view.replayDeploys(uids) the round start: the own board's pieces `uids` (deployment order) land one
|
||||
// after another, DEPLOY_REPLAY_MS apart (the maintainer's confirmation from
|
||||
// official play, 2026-10-13) → how many of them the board holds now (0: not
|
||||
// built yet); the timers end with prep mode (enterBattle) and destroy
|
||||
// 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)
|
||||
@@ -134,14 +142,14 @@ import { pickOnTile, pickBattle, hitTiles } from './pick.js';
|
||||
import { promotionsOf } from './promote.js';
|
||||
import { ensurePixi } from './app/pixi.js';
|
||||
import { pieceDirOf, pickUnitOf } from './app/pick.js';
|
||||
import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS } from './app/tune.js';
|
||||
import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS, DEPLOY_REPLAY_MS } from './app/tune.js';
|
||||
import { boardPreference, switchableBox, bandFor, fieldRows, boardArea, viewKind, penShown, leaderShown } from './app/view.js';
|
||||
import { renderInfo, FORCED_EXIT, showsDeathFx } from './app/info.js';
|
||||
import { resolveAssets, makeData, withTimeout, QUALITY_RES, BOARD_RES, releaseGl } from './app/host.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
export { ensurePixi } from './app/pixi.js';
|
||||
export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES } from './app/tune.js';
|
||||
export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, DEPLOY_REPLAY_MS } from './app/tune.js';
|
||||
export { viewKind, boardArea, bandFor, fieldRows, penShown, leaderShown, boardPreference, switchableBox } from './app/view.js';
|
||||
export { FORCED_EXIT, showsDeathFx, renderInfo } from './app/info.js';
|
||||
export { releaseGl } from './app/host.js';
|
||||
@@ -270,6 +278,8 @@ 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 landings = []; // the last prep landings (UnitView.land, PR #416): { uid, why: 'round'|'buy'|'merge'|'board', at } — dev / tests
|
||||
let replayTimer = 0; // the next turn of the round start's replay (replayDeploys): cleared when prep mode ends and on destroy
|
||||
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
|
||||
@@ -308,6 +318,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 || null, // the prep landings' sound (UnitView.land → audio.deployCue)
|
||||
cam: () => cam, heightAt,
|
||||
animRate: () => (mode === 'battle' ? interp.rate : 1),
|
||||
timeScale: () => (mode === 'battle' ? interp.rate : 1),
|
||||
@@ -748,6 +759,9 @@ export async function createFieldView(host, options = {}) {
|
||||
addList(src.board, 'board');
|
||||
const seen = new Set();
|
||||
const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid));
|
||||
// where each piece stood in the last state of this prep (nothing after a battle rebuilt the scene): a piece this state
|
||||
// puts on a board tile it was not on lands (PR #416)
|
||||
const prevAt = new Map(prepPieces.map((p) => [p.uid, p]));
|
||||
// merges since the last state (render/promote.js): new elite uid → where its consumed copies stood (their views
|
||||
// still exist until the sweep below) — the elite gets the promotion cue instead of the deploy flash
|
||||
const promoFrom = new Map();
|
||||
@@ -782,13 +796,18 @@ export async function createFieldView(host, options = {}) {
|
||||
v.setWorld(w.x, w.y, w.z);
|
||||
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?.();
|
||||
// a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot — it lands there (PR #416)
|
||||
landPiece(v, 'merge');
|
||||
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 (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { landPiece(v, 'board'); fx.deploy(v); }
|
||||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) {
|
||||
// an operator that just arrived on the bench — bought, a reward, a grant — lands there (PR #416); a summon that comes
|
||||
// with its owner's placement does not, nor does an item
|
||||
if (e.piece.kind === 'chess') landPiece(v, 'buy');
|
||||
fx.deploy(v);
|
||||
}
|
||||
} else {
|
||||
const prevHome = v._home;
|
||||
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
|
||||
@@ -800,8 +819,13 @@ export async function createFieldView(host, options = {}) {
|
||||
pending.delete(key);
|
||||
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); }
|
||||
if (e.area === 'board' && !prevBoard.has(e.uid) && v.onDeploy) fx.deploy(v);
|
||||
}
|
||||
// a piece this state puts on a board tile it was not on — from the bench, from another tile, a swap — lands (PR #416),
|
||||
// also while its drop is still held on that tile (the direction step pins it there, so `moved` is false); turned in
|
||||
// place, retreated or moved on the bench it does not
|
||||
const was = prevAt.get(e.uid);
|
||||
if (was && e.area === 'board' && (was.area !== 'board' || was.row !== e.row || was.col !== e.col) && !(dragState && dragState.key === key)) landPiece(v, 'board');
|
||||
}
|
||||
v._home = w;
|
||||
v.dimmed = false;
|
||||
@@ -830,6 +854,52 @@ export async function createFieldView(host, options = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Land a prep piece (UnitView.land, PR #416) and note it (`landings`, dev / tests); false when the view refused. */
|
||||
function landPiece(v, why) {
|
||||
if (!v || typeof v.land !== 'function' || !v.land()) return false;
|
||||
landings.push({ uid: v.uid ?? null, why, at: performance.now() });
|
||||
if (landings.length > 40) landings.shift();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The round start's replay (see the header; PR #416): the listed pieces of the own board land one after another in the
|
||||
* order given (deployment order, ui/gameLogic deployOrder), each turn DEPLOY_REPLAY_MS after the one before (a chain of
|
||||
* timeouts, so a busy frame never squeezes two together). A piece gone, rebuilt, not idle or already landing when its
|
||||
* turn comes is passed over (UnitView.land), and none lands while the tab is hidden (no frame shows it; the suspended
|
||||
* audio would play the sound on return). A new sequence replaces one still running; prep mode ending and destroy()
|
||||
* cancel it.
|
||||
* @param {number[]} uids
|
||||
* @param {number} [gap] ms between two pieces
|
||||
* @returns {number} how many of `uids` the board holds now — 0 outside prep mode or before setPrep built them
|
||||
*/
|
||||
function replayDeploys(uids, gap = DEPLOY_REPLAY_MS) {
|
||||
if (destroyed || mode !== 'prep' || !Array.isArray(uids)) return 0;
|
||||
const step = Number.isFinite(gap) && gap >= 0 ? gap : DEPLOY_REPLAY_MS;
|
||||
const queue = [];
|
||||
for (const uid of uids) {
|
||||
const v = Number.isInteger(uid) ? views.get('p:' + uid) : null;
|
||||
if (v && !v.destroyed && typeof v.land === 'function' && !queue.some((q) => q.v === v)) queue.push({ uid, v });
|
||||
}
|
||||
if (!queue.length) return 0;
|
||||
cancelReplay();
|
||||
let i = 0;
|
||||
const turn = () => {
|
||||
replayTimer = 0;
|
||||
if (destroyed || mode !== 'prep') return;
|
||||
const { uid, v } = queue[i++];
|
||||
if (!globalThis.document?.hidden && views.get('p:' + uid) === v) landPiece(v, 'round');
|
||||
if (i < queue.length) replayTimer = setTimeout(turn, step);
|
||||
};
|
||||
replayTimer = setTimeout(turn, 0);
|
||||
return queue.length;
|
||||
}
|
||||
|
||||
function cancelReplay() {
|
||||
if (replayTimer) clearTimeout(replayTimer);
|
||||
replayTimer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
|
||||
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
|
||||
@@ -874,6 +944,7 @@ export async function createFieldView(host, options = {}) {
|
||||
function enterPrepMode() {
|
||||
mode = 'prep';
|
||||
held.clear();
|
||||
cancelReplay();
|
||||
clearViews();
|
||||
infos.clear();
|
||||
gone.clear();
|
||||
@@ -1323,6 +1394,7 @@ export async function createFieldView(host, options = {}) {
|
||||
holdScene(true); // before the prep views go: the cache is never "quiet" across the switch
|
||||
drag.reset();
|
||||
endDragVisual(false);
|
||||
cancelReplay(); // the round start's replay is a prep-board thing: none of it reaches a battle (or a scouted board)
|
||||
clearViews();
|
||||
if (prepPieces.length) promoBase = prepPieces;
|
||||
prepPieces = [];
|
||||
@@ -1816,6 +1888,7 @@ export async function createFieldView(host, options = {}) {
|
||||
setStage,
|
||||
setCamera,
|
||||
setPrep,
|
||||
replayDeploys,
|
||||
/** Enemy preview pen: a list in m.private.nextEnemies shape, or null to empty it (setPrep does this itself). */
|
||||
setPen(list) { if (destroyed) return false; setPenList(Array.isArray(list) ? list : null); return true; },
|
||||
enterBattle,
|
||||
@@ -1936,6 +2009,7 @@ export async function createFieldView(host, options = {}) {
|
||||
app.ticker.remove(preRender);
|
||||
app.ticker.remove(postRender);
|
||||
clearTimeout(recover.timer);
|
||||
cancelReplay();
|
||||
drag.reset();
|
||||
for (const k of [...views.keys()]) dropView(k);
|
||||
clearPen();
|
||||
@@ -1964,7 +2038,7 @@ export async function createFieldView(host, options = {}) {
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
promotions,
|
||||
promotions, landings, get replaying() { return !!replayTimer; },
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
||||
pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
|
||||
@@ -32,4 +32,11 @@ const DROP_PENDING_MS = 1300;
|
||||
*/
|
||||
export const DRAG_HOLD_TILES = 0.45;
|
||||
|
||||
/**
|
||||
* The round start's replay of the own board (view.replayDeploys, PR #416 by @Cromemadnd): ms between two pieces' Start
|
||||
* clips, in deployment order. The maintainer saw them come one after another in official play (2026-10-13); [ASSUMED] the
|
||||
* gap — the PR's 50 ms, no source gives the official one.
|
||||
*/
|
||||
export const DEPLOY_REPLAY_MS = 50;
|
||||
|
||||
export { CAMERA_MS, BOARD3D_RETRY_MS, BOARD3D_STABLE_MS, RANGE_GROUPS, CHAIN_KINDS, DROP_PENDING_MS };
|
||||
@@ -35,6 +35,13 @@
|
||||
// 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.
|
||||
// Prep landings (PR #416 by @Cromemadnd): `land()` plays a prep piece's deploy clip (Start) again with its landing sound
|
||||
// — the round start's replay of the own board (render/app.js replayDeploys), a purchase, a move onto or across the board.
|
||||
// One landing, one sound: a piece whose Start still plays (on its model, owed to a model still loading, or cued less than
|
||||
// a clip ago) keeps it and sounds nothing more, and only an idle prep piece is ever pulled into it — never a battle unit
|
||||
// or one in an attack, skill, death or form clip. The sound is `ctx.audio.deployCue` (public/js/audio.js: the unit's own
|
||||
// battle deploy sound, through the SFX volume, mute and the battle limiter); `onDeploy` itself — the sim's 'deploy'
|
||||
// events — stays silent: audio.handleBattleEvents sounds those.
|
||||
// Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式
|
||||
// (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its
|
||||
// bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像
|
||||
@@ -149,6 +156,11 @@ export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]);
|
||||
* [ASSUMED] the length: a healthy load finishes within a few seconds once the tab is visible.
|
||||
*/
|
||||
export const SPINE_STUCK_MS = 5000;
|
||||
/**
|
||||
* Seconds a prep landing (UnitView.land) counts as playing when the model's Start clip length is unknown (no manifest entry
|
||||
* yet): the operators' Start clips run 0.77–1.2 s, most of them 1 s (data/assets.json). [ASSUMED]
|
||||
*/
|
||||
export const START_CLIP_FALLBACK = 1;
|
||||
|
||||
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
|
||||
const RAISED_Z = 0.12;
|
||||
@@ -447,9 +459,9 @@ function bar(P, parent, color, alpha = 1) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared context handed to every view by app.js.
|
||||
* Shared context handed to every view by app.js (`audio`: the audio manager of public/js/audio.js — prep landings, land()).
|
||||
* @typedef {{ P: any, layers: { shadow: any, units: any, bars: any, groundFx: any },
|
||||
* assets: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number,
|
||||
* assets: any, audio?: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number,
|
||||
* settings: { damageNumbers: boolean, quality: string } }} ViewCtx
|
||||
*/
|
||||
|
||||
@@ -565,6 +577,7 @@ export class UnitView {
|
||||
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._landUntil = 0; // a prep landing (land) owns the Start clip until then (ms, performance clock)
|
||||
this._spineBusy = false; // a Spine load of this view is in flight
|
||||
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)
|
||||
@@ -1060,6 +1073,44 @@ export class UnitView {
|
||||
if (this.actor) this.actor.deploy();
|
||||
}
|
||||
|
||||
/**
|
||||
* A prep piece lands (PR #416 by @Cromemadnd; see the header): its Start clip again — a model still loading continues it
|
||||
* by the time gone (onDeploy, 0.2.3) — and its landing sound, once. Refused (false) for anything but an idle prep piece
|
||||
* (a battle unit, one dying, lifted by a drag, or in another clip) and for a piece whose Start already plays: a second
|
||||
* trigger of the same landing — a purchase then a move, the round start's replay over a piece just put down — changes
|
||||
* nothing and sounds nothing. Refused in a hidden tab too: no frame shows it there, and the clip and the suspended audio
|
||||
* would only play it, stale, once the tab is back.
|
||||
* @returns {boolean} whether the Start clip started and the landing sound was asked for
|
||||
*/
|
||||
land() {
|
||||
if (this.destroyed || !this.prep || this.isEnemy || !this.alive || this.dying > 0 || this.lift > 0) return false;
|
||||
if (globalThis.document?.hidden) return false;
|
||||
if (this.deploying()) return false;
|
||||
if (this.actor && this.actor.mode !== 'base') return false;
|
||||
this.onDeploy();
|
||||
this._landUntil = nowMs() + this._startDur() * 1000;
|
||||
try { this.ctx.audio?.deployCue?.({ def: this.info.spine || this.info.defId, defId: this.info.defId, kind: this.info.kind }, this.id); } catch { /* the sound never stops the landing */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Start clip plays now: on the model, owed to a model still loading (onDeploy), or a landing (land) cued less
|
||||
* than a clip ago — the last covers a model without a Start clip and a view without a model.
|
||||
*/
|
||||
deploying() {
|
||||
if (this.actor && this.actor.deployElapsed() != null) return true;
|
||||
if (!this.actor && this._pendingDeployElapsed != null && this._pendingDeployElapsed < this._startDur()) return true;
|
||||
return nowMs() < this._landUntil;
|
||||
}
|
||||
|
||||
/** Seconds of the Start clip of the model asked for (its manifest entry), START_CLIP_FALLBACK without one. */
|
||||
_startDur() {
|
||||
const e = this.entry;
|
||||
const name = e && e.anims && typeof e.anims.deploy === 'string' ? e.anims.deploy : null;
|
||||
const d = name && e.animations && Object.hasOwn(e.animations, name) ? Number(e.animations[name]) : NaN;
|
||||
return Number.isFinite(d) && d > 0 ? d : START_CLIP_FALLBACK;
|
||||
}
|
||||
|
||||
onStatus(key, on) {
|
||||
if (typeof key !== 'string') return;
|
||||
if (on) this.statuses.add(key); else this.statuses.delete(key);
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
// reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the
|
||||
// deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand);
|
||||
// the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote).
|
||||
// Round start (PR #416 by @Cromemadnd; the maintainer's confirmation from official play, 2026-10-13): in 回合开始 the own
|
||||
// board's pieces replay their Start clip and landing sound one after another in deployment order, once per round and on
|
||||
// the own board only (gameLogic roundStartReplay → render/app.js replayDeploys); a purchase and a move onto or across the
|
||||
// board play it too ([ASSUMED], the PR author's account: render/app.js setPrep).
|
||||
// The bond strip, its popup and a unit card's bond chips follow the player on screen (DESIGN §20.15, ui/watchBonds.js):
|
||||
// a scouted / watched / auto-observed teammate's field → that teammate's bonds (m.public players[].bonds, tagged
|
||||
// "👁 name"); 联防 / 最终攻势 → the player on the ‹ › half (全景: yours when you fight there, else the teammate picked
|
||||
@@ -99,7 +103,7 @@ import {
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn, roundStartReplay,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -516,6 +520,17 @@ function MatchScreen() {
|
||||
}
|
||||
}, [view, showPrep, priv, editable, field, combat, mode, watchingOther, watching, holdSeq]);
|
||||
|
||||
// the round start (PR #416 by @Cromemadnd; the maintainer's confirmation from official play, 2026-10-13): the own board's
|
||||
// pieces replay their Start clip and landing sound one after another in deployment order — once per round, in 回合开始, on
|
||||
// the own board only (gameLogic roundStartReplay). After the wiring effect above, so the view already holds this state's
|
||||
// pieces; the round counts as replayed only once the view took the sequence (a view still loading or a board not built
|
||||
// yet is asked again on the next render of the phase), and a reconnect or a re-render of the round plays nothing more.
|
||||
const replayedRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const plan = roundStartReplay({ phase, round: pub?.round, replayed: replayedRef.current, ownBoard: showPrep && alive && !spectator, board: priv?.board });
|
||||
if (plan && view && view.replayDeploys?.(plan.uids) > 0) replayedRef.current = plan.round;
|
||||
}, [view, phase, pub?.round, priv, showPrep, alive, spectator]);
|
||||
|
||||
// battle frames straight from the socket (server-run combat, 20 Hz) or from the local simulation (client-side combat,
|
||||
// battle/runner.js, every animation frame) — never through the store. Frames go to the view as received: the game
|
||||
// time travels as `gt` and the render engine reads it (render/interp.js frameTime).
|
||||
|
||||
@@ -18,8 +18,9 @@ const LOAD_TIMEOUT_MS = 12000;
|
||||
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize', 'destroy'];
|
||||
// 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'];
|
||||
// stripes range previews under the units itself), replayDeploys (the round start's Start-clip replay, PR #416) —
|
||||
// render/app.js; the DOM fallback lacks them (→ null)
|
||||
const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder', 'replayDeploys'];
|
||||
|
||||
/**
|
||||
* Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar /
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
export { clamp, tileKey } from './gameLogic/shared.js';
|
||||
export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js';
|
||||
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js';
|
||||
export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js';
|
||||
export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds, deployOrder, roundStartReplay } from './gameLogic/phases.js';
|
||||
export { RESULT_BOX_MS, ownRoundLoss, battleOverSfx, roundResultBox, uniteResultBox, battleResultBox } from './gameLogic/phases.js';
|
||||
export { STATUS_META } from './gameLogic/format.js';
|
||||
export { sortedPlayers } from './gameLogic/shared.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns. Re-exported
|
||||
// from ../gameLogic.js.
|
||||
// ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns, the round
|
||||
// start's deploy replay. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { PHASE } from '../../../../shared/constants.js';
|
||||
import { bossLevelSeconds } from '../matchStatus.js';
|
||||
@@ -177,6 +177,41 @@ export function prepCapsuleLabel(phase) {
|
||||
return t('休息一下');
|
||||
}
|
||||
|
||||
// ---- the round start's deploy replay (PR #416) ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The own board's pieces in deployment order — the order the battle deploys them (sim Battle.start; PRTS 卫戍协议/帮助
|
||||
* "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"): the operators by column from the left, top to bottom within a
|
||||
* column (col asc, row desc, in board coordinates — so the mirrored right half of a boss prep also starts from its own
|
||||
* left), then the summons the same way. A placed item (画卷) is not one of them.
|
||||
* @param {any[]|null|undefined} board m.private board
|
||||
* @returns {number[]} uids
|
||||
*/
|
||||
export function deployOrder(board) {
|
||||
const rank = (p) => (p.kind === 'chess' ? 0 : 1);
|
||||
return (Array.isArray(board) ? board : [])
|
||||
.filter((p) => isObj(p) && Number.isInteger(p.uid) && Number.isInteger(p.row) && Number.isInteger(p.col) && (p.kind === 'chess' || p.kind === 'token'))
|
||||
.sort((a, b) => rank(a) - rank(b) || a.col - b.col || b.row - a.row || a.uid - b.uid)
|
||||
.map((p) => p.uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the round start's replay is due and which pieces play it (PR #416 by @Cromemadnd; the maintainer's confirmation
|
||||
* from official play, 2026-10-13: at the start of every round the operators on the board replay their Start clip and
|
||||
* landing sound one after another in deployment order). Due once per round, during 回合开始, on the viewer's own board —
|
||||
* never a scouted or watched teammate's board, a spectator seat or an eliminated player — while it holds pieces.
|
||||
* `replayed` is the round the screen last scheduled: the screen marks a round only once the field view took the sequence
|
||||
* (render/app.js replayDeploys found the pieces), so a view or a board not built yet is asked again on the next render of
|
||||
* the phase, and a reconnect or a re-render of the same round replays nothing.
|
||||
* @param {{ phase?: string|null, round?: number|null, replayed?: number|null, ownBoard?: boolean, board?: any[]|null }} [s]
|
||||
* @returns {{ round: number, uids: number[] } | null}
|
||||
*/
|
||||
export function roundStartReplay({ phase = null, round = null, replayed = null, ownBoard = false, board = null } = {}) {
|
||||
if (phase !== PHASE.ROUND_START || !Number.isInteger(round) || round === replayed || !ownBoard) return null;
|
||||
const uids = deployOrder(board);
|
||||
return uids.length ? { round, uids } : null;
|
||||
}
|
||||
|
||||
// ---- countdown -------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user