0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+70 -3
View File
@@ -525,6 +525,9 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.bondCueNode = null;
this.bondCueToken = 0;
this.bondCueScope = null;
this.voiceOverrides = {};
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
@@ -666,6 +669,7 @@ export class AudioManager {
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
};
this._applyVolumes();
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
}
/**
@@ -834,6 +838,58 @@ export class AudioManager {
// ---- SFX ------------------------------------------------------------------------------------------------
/**
* True while a cue of `scope` started less than `ms` ago. One preparation update can both add layers (m.layer → layerGain)
* and raise the active-bond count (screens/game.js): the second trigger of that same update keeps the playing cue
* instead of cutting it off and starting it again. A later gain still replaces the cue at once (no cooldown).
*/
bondCueRecent(scope, ms = 400) {
if (this.bondCueScope !== scope || typeof performance === 'undefined') return false;
return performance.now() - (this.bondCueAt || 0) < ms;
}
/** The official ADD_BOND bank has maxSoundAllowed=1 / popOldest=true. No extra cooldown. */
stopBondCue(scope = null) {
if (scope && this.bondCueScope !== scope) return;
this.bondCueToken++;
this.bondCueScope = null;
const cur = this.bondCueNode;
this.bondCueNode = null;
if (!cur) return;
try { cur.src.stop(); } catch { /* already ended */ }
cur.end();
}
_playBondCue(url, volume, scope = 'prep') {
this.stopBondCue();
const token = this.bondCueToken, ctx = this.ctx;
if (!ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
this.bondCueScope = scope;
this.bondCueAt = typeof performance !== 'undefined' ? performance.now() : 0;
this._buffer(url).then((buf) => {
if (!buf || token !== this.bondCueToken || ctx !== this.ctx || this.volumes.muted || this.volumes.sfx <= 0) return;
let src, gain;
const end = () => {
try { src?.disconnect(); gain?.disconnect(); } catch { /* ignore */ }
if (this.bondCueNode?.src === src) this.bondCueNode = null;
};
try {
src = ctx.createBufferSource(); gain = ctx.createGain();
src.buffer = buf;
gain.gain.value = Math.max(0, Math.min(1.5, volume));
src.connect(gain); gain.connect(this.sfxGain);
src.onended = end;
this.bondCueNode = { src, gain, end };
src.start();
} catch { end(); }
}, () => {});
}
/** One cue per real gain, including several layers at once or an inactive bond. */
layerGain(bondId, add, scope = 'prep') {
if (typeof bondId === 'string' && bondId && Number.isFinite(add) && add > 0) this.sfx('bondUp', { volume: 0.7, scope });
}
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -862,12 +918,15 @@ export class AudioManager {
/**
* UI sound by name (sfx.ui keys). Unknown names are ignored.
* @param {string} name
* @param {{ volume?: number }} [o]
* @param {{ volume?: number, scope?: string }} [o]
*/
sfx(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
if (typeof url === 'string') {
if (name === 'bondUp') this._playBondCue(url, o.volume ?? 0.9, o.scope);
else this._play(url, { volume: o.volume ?? 0.9 });
}
} catch { /* ignore */ }
}
@@ -1013,6 +1072,7 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.stopBondCue();
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
@@ -1056,7 +1116,9 @@ export class AudioManager {
if (!Array.isArray(e)) continue;
const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') {
if (kind === 'layer') {
this.layerGain(e[2], e[3], 'battle');
} else if (kind === 'atk') {
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
const src = this.units.get(e[1]);
@@ -1176,6 +1238,11 @@ export function installAudio(deps) {
const sync = (s) => {
try {
const pub = s.match?.public ?? null;
if (deps.selectRoute(s) !== 'game') audio.stopBondCue();
else if (![PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub?.phase)) {
// A new prep gain can precede its m.public phase update. Cancel only the previous battle's cue.
audio.stopBondCue('battle');
}
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.