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:
sganggs committed 2026-10-13 04:36:56 +08:00
1 parent 281cf01ff0
commit 231da0efbc
16 files changed
+791 -22

No files matched your search

+83 -9
View File
@@ -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 },
},