Golden regenerated on the merged code and attributed by reverting each gameplay PR in a scratch tree: #66 alone moves the fights of roster-008/010/012/022/027/028/031/040, the boss pairs (boss_2/4/6, hidden boss_10) and the matches coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted (灵巧's aura no longer comes from merchant exits and retreats; hidden-layer bonds keep their internal layers); #109 changes the skill state of its deploy-timed skills (units / hooks / status events / snapshots); #160 adds the 0.5 s skill events of deploy passives (events.skill only in roster-001/006/025/049). The remaining scenarios listed by the full regenerate are those three effects together.
This commit is contained in:
commit
02b76989fa
49 files changed
+1974
-620
No files matched your search
+43
-7
@@ -1,7 +1,8 @@
|
||||
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
|
||||
//
|
||||
// Sources: data/assets.json → audio (docs/ASSETS.md):
|
||||
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
|
||||
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
|
||||
// bossBgm { [bossId]: { intro?, loop } },
|
||||
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
|
||||
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
|
||||
@@ -11,6 +12,10 @@
|
||||
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
|
||||
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
|
||||
// keeps playing across phases (prep and combat share a track).
|
||||
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
|
||||
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
|
||||
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
|
||||
// 联防 that follows a 作战 keeps its round's track.
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
|
||||
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
|
||||
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
@@ -59,9 +64,11 @@ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
||||
* BGM key for a route + match phase.
|
||||
* @param {'title'|'lobby'|'room'|'game'|string} route
|
||||
* @param {any} pub m.public (may be null)
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'unite' | 'boss' | 'boss:<bossId>' | null
|
||||
* @param {0|1|null} [combatTrack] the round's own 开战 track index into `bgm.combatAlts` (combatTrackFor; omitted ⇒
|
||||
* plain 'combat', i.e. the manifest's default combat track)
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'combat:<i>' | 'unite' | 'boss' | 'boss:<bossId>' | null
|
||||
*/
|
||||
export function bgmKeyFor(route, pub) {
|
||||
export function bgmKeyFor(route, pub, combatTrack = null) {
|
||||
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
|
||||
const phase = pub?.phase;
|
||||
if (!phase) return 'lobby';
|
||||
@@ -74,7 +81,8 @@ export function bgmKeyFor(route, pub) {
|
||||
// resolveBgm falls back to `bgm.combat` when a manifest predates it.
|
||||
return 'unite';
|
||||
case PHASE.COMBAT:
|
||||
return 'combat';
|
||||
// 开战 BGM: the round's own track — 骑士之日 / 无畏者 are fixed per round, not drawn (combatTrackFor)
|
||||
return combatTrack == null ? 'combat' : `combat:${combatTrack ? 1 : 0}`;
|
||||
case PHASE.FINAL_ASSAULT:
|
||||
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
|
||||
case PHASE.HIDDEN_CORE:
|
||||
@@ -85,8 +93,9 @@ export function bgmKeyFor(route, pub) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest: `boss:<id>` falls back to the generic boss track and
|
||||
* `unite` (联防's own track) to `bgm.combat` when the manifest predates it.
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest: `boss:<id>` falls back to the generic boss track,
|
||||
* `combat:<i>` to the i-th `bgm.combatAlts` entry (and to `bgm.combat` when the manifest has none), and `unite`
|
||||
* (联防's own track) to `bgm.combat` when the manifest predates it.
|
||||
* @param {any} manifest
|
||||
* @param {string|null} key
|
||||
* @returns {{ intro: string|null, loop: string }|null}
|
||||
@@ -96,12 +105,33 @@ export function resolveBgm(manifest, key) {
|
||||
if (!a || !key) return null;
|
||||
let t = null;
|
||||
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
|
||||
else if (key.startsWith('combat:')) t = a.bgm?.combatAlts?.[Number(key.slice('combat:'.length))] || a.bgm?.combat;
|
||||
else if (key === 'unite') t = a.bgm?.unite || a.bgm?.combat;
|
||||
else t = a.bgm?.[key];
|
||||
if (!t || typeof t.loop !== 'string') return null;
|
||||
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last round that plays 无畏者 (1–7); from the next round on it is 骑士之日 (8–13) — the official schedule
|
||||
* (docs/ASSETS.md "BGM"; the two tracks are the 塞壬唱片 act13side battle themes).
|
||||
*/
|
||||
export const COMBAT_TRACK_SWITCH_ROUND = 7;
|
||||
|
||||
/**
|
||||
* The round's own 开战 track index into `bgm.combatAlts` (plan.mjs order: 0 = `m_bat_kazimierz2_1` 骑士之日,
|
||||
* 1 = `m_bat_kazimierz2_2` 无畏者). The mode does not draw these: the official schedule plays one per round, 无畏者
|
||||
* through the early rounds (1–7) and 骑士之日 from round 8 to the last normal round (8–13). Everything after that is
|
||||
* the boss rounds (最终攻势 / 隐秘核心), which have their own tracks and never ask for `combat:<i>`.
|
||||
* @param {number|null|undefined} round m.public.round
|
||||
* @returns {0|1|null} null when the round is unknown ⇒ the manifest's plain `combat` track
|
||||
*/
|
||||
export function combatTrackFor(round) {
|
||||
const r = Number(round);
|
||||
if (!Number.isFinite(r) || r < 1) return null;
|
||||
return r <= COMBAT_TRACK_SWITCH_ROUND ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official sound class of a battle unit (audio_data `battle.ON_UNIT_DEAD|BORN.<class>` defaults):
|
||||
* 'enemy' | 'char' (operators: b_char_dead “干员被击倒” / b_char_set) | 'token' (summons: b_char_tokendead /
|
||||
@@ -718,7 +748,13 @@ export function installAudio(deps) {
|
||||
if (deps?.settings) audio.setVolumes(deps.settings);
|
||||
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
|
||||
const sync = (s) => {
|
||||
try { audio.playBgm(bgmKeyFor(deps.selectRoute(s), s.match?.public)); } catch { /* ignore */ }
|
||||
try {
|
||||
const pub = s.match?.public ?? null;
|
||||
// 开战 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.
|
||||
audio.playBgm(bgmKeyFor(deps.selectRoute(s), pub, combatTrackFor(pub?.round)));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
sync(deps.getState());
|
||||
return deps.subscribe((s, prev) => {
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
// setSkill(on) skill begin, then — when the skill has an idle clip of its own (skill.idle,
|
||||
// not its loop) — that idle between attacks, its loop (the skill's attack clip)
|
||||
// only on attacks (community report #23: 折桠's S2 jump attack looped with no
|
||||
// enemy engaged); a skill without one keeps its loop as the stance; end on stop
|
||||
// enemy engaged); end on stop. A skill clip with NEITHER a Begin nor an own Idle
|
||||
// (德克萨斯 剑雨 and the other 58 instant skills: anims.skill {begin:null,
|
||||
// loop:'Skill', end:null}) plays that clip once for its own length: an instant
|
||||
// skill's flag is off again within the same 0.5 s window (sim skills.js), so
|
||||
// without this the actor fell through to its idle and played no skill clip at
|
||||
// all (player report).
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards); a skeleton without one holds its
|
||||
// idle clip's first frame (GitHub issue #25: the attack loop went on)
|
||||
@@ -36,6 +41,9 @@ const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
/** Longest wind-up compression (× the rhythm speed) when the look-ahead is shorter than the natural wind-up. */
|
||||
export const MAX_WIND_SPEEDUP = 2.5;
|
||||
|
||||
/** Floor for the one-shot window of a skill clip with no Begin / own Idle (a very short clip still gets frames). */
|
||||
const SKILL_CLIP_MIN = 0.2;
|
||||
|
||||
/**
|
||||
* Attack clip timing (pure). `loopDur` / `hit` are clip seconds (strike frame at `hit`), `interval` and `lead` game
|
||||
* seconds. The clip plays at `ts = loopDur / interval` (one loop per attack); started `lead` before the attack it
|
||||
@@ -100,6 +108,8 @@ export class SpineActor {
|
||||
this.endAt = 0; // landing in endRoles (the next form's) when given
|
||||
this.endRoles = null;
|
||||
this.skillOn = false;
|
||||
this.skillClipOnce = false; // skillBegin: the skill's own clip plays once (a clip with no Begin / own Idle)
|
||||
this.skillEndPending = false; // … and the skill turned off inside it: play its End clip / base when it ends
|
||||
this.attackUntil = 0;
|
||||
this.clock = 0;
|
||||
this.current = '';
|
||||
@@ -360,6 +370,8 @@ export class SpineActor {
|
||||
// his S3's 'skill' off event — lets the deploy clip play out (then the plain idle) instead of cutting it with the End
|
||||
if (!on && this.mode === 'deploy') return;
|
||||
if (on && sk) {
|
||||
this.skillEndPending = false;
|
||||
this.skillClipOnce = false;
|
||||
if (this.has(sk.begin)) {
|
||||
this.mode = 'skillBegin';
|
||||
this._play(sk.begin, false, { mix: 0.08 });
|
||||
@@ -367,8 +379,25 @@ export class SpineActor {
|
||||
// then the skill's own idle until an attack plays its loop (community report #23); without one, the loop
|
||||
const next = this._skillIdle() || (this.has(sk.loop) ? sk.loop : null);
|
||||
if (next) this._queue(next, true);
|
||||
} else if (!this.has(sk.idle) && this.has(sk.loop) && sk.via !== 'attack' && sk.loop !== this.roles.attack?.loop && !this._skillIsBuffOnly()) {
|
||||
// No Begin and no own Idle: the skill's clip IS its animation (德克萨斯 剑雨 — anims.skill {begin:null,
|
||||
// loop:'Skill', end:null}, the 2.17 s clip; 58 instant skills in all). Play it once for its own length. An
|
||||
// instant skill turns the flag off inside the same tick (sim skills.js fires 'skill' 1 and 0 together and only
|
||||
// holds the anim code SKILL for 0.5 s), so this window is what keeps it on screen: the actor used to fall
|
||||
// through to the base clip and show no skill animation at all (player report).
|
||||
this.mode = 'skillBegin';
|
||||
this.skillClipOnce = true;
|
||||
this._play(sk.loop, false, { mix: 0.08 });
|
||||
this.skillBeginUntil = this.clock + Math.max(SKILL_CLIP_MIN, this.dur(sk.loop));
|
||||
} else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
} else if (!on && sk) {
|
||||
// an instant skill switches off while its own one-shot clip runs: let the clip finish — the state machine plays
|
||||
// its End clip (or the base) when the window ends — instead of cutting it with End / base right now
|
||||
if (this.skillClipOnce && this.clock < this.skillBeginUntil) {
|
||||
this.skillEndPending = true;
|
||||
return;
|
||||
}
|
||||
this.skillClipOnce = false;
|
||||
if (this.has(sk.end)) {
|
||||
this.mode = 'skillEnd';
|
||||
this._play(sk.end, false, { mix: 0.08 });
|
||||
@@ -457,7 +486,24 @@ export class SpineActor {
|
||||
}
|
||||
break;
|
||||
case 'skillBegin':
|
||||
if (this.clock >= this.skillBeginUntil) { this.mode = 'base'; if (!this.has(this.roles.skill?.loop)) this._play(this._baseName(), true); }
|
||||
if (this.clock >= this.skillBeginUntil) {
|
||||
const sk = this.roles.skill;
|
||||
const once = this.skillClipOnce;
|
||||
const pending = this.skillEndPending;
|
||||
this.skillClipOnce = false;
|
||||
this.skillEndPending = false;
|
||||
this.mode = 'base';
|
||||
if (pending && this.has(sk?.end)) {
|
||||
// the skill switched off inside its own clip: its End clip closes it now
|
||||
this.mode = 'skillEnd';
|
||||
this._play(sk.end, false, { mix: 0.08 });
|
||||
this.skillEndUntil = this.clock + this.dur(sk.end);
|
||||
} else if (once || pending || !this.has(sk?.loop)) {
|
||||
// the one-shot clip is over (or the skill has no loop clip at all): rest. A Begin clip with a queued next
|
||||
// clip (the loop, or the skill's own idle) needs no play here — it is already on the track
|
||||
this._play(this._baseName(), true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'skillEnd':
|
||||
if (this.clock >= this.skillEndUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
|
||||
|
||||
@@ -1461,6 +1461,9 @@ export class ItemView {
|
||||
if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {});
|
||||
}
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
/** A battle snapshot sample (syncBattle syncs every unit of the field): a hand item on a scouted prep board rides
|
||||
* the units as kind 'item' (Match.prepFieldMeta) — follow its position, there is nothing else to animate. */
|
||||
sync(s) { this.x = s.x; this.y = s.y; }
|
||||
update(dt, cam, t) {
|
||||
const lifted = this.lift > 0;
|
||||
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
|
||||
@@ -200,6 +200,7 @@ function MatchScreen() {
|
||||
const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看
|
||||
const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null
|
||||
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
|
||||
const [bondsCollapsed, setBondsCollapsed] = useState(false);
|
||||
const [detail, setDetail] = useState(null); // detail target
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
@@ -1244,8 +1245,20 @@ function MatchScreen() {
|
||||
live=${liveLpNow} spectator=${spectator} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||
aria-label=${bondsCollapsed ? '展开盟约' : '收起盟约'} title=${bondsCollapsed ? '展开盟约' : '收起盟约'}
|
||||
onKeyDown=${(e) => {
|
||||
// Keep native Space activation here without also firing the global ready / pause shortcut.
|
||||
if (e.key === ' ') e.stopPropagation();
|
||||
}}
|
||||
onClick=${() => {
|
||||
if (!bondsCollapsed && bondOpen?.from === 'strip') setBondOpen(null);
|
||||
setBondsCollapsed(!bondsCollapsed);
|
||||
}}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} /><span>${bondsCollapsed ? '盟约' : '收起'}</span></button>
|
||||
<div id="match-bond-strip" class="gm__bond-list" hidden=${bondsCollapsed}>
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal}
|
||||
|
||||
@@ -153,9 +153,9 @@ function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function InviteBox({ code }) {
|
||||
function InviteBox({ code, name, difficulty }) {
|
||||
const copy = async (what) => {
|
||||
const ok = await copyText(what === 'code' ? code : inviteLink(code));
|
||||
const ok = await copyText(what === 'code' ? code : `${inviteLink(code)} ${name}邀请你加入卫戍协议:盟约【${DIFFICULTY_NAMES[difficulty]}】`);
|
||||
if (ok) toast(what === 'code' ? `已复制同盟密钥 ${code}` : '已复制邀请链接', 'success');
|
||||
else toast('复制失败,请手动复制', 'warn');
|
||||
};
|
||||
@@ -280,7 +280,7 @@ export function RoomScreen() {
|
||||
<h1 class="topbar__title">${coop ? '同盟模拟' : '独立模拟'}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
|
||||
${coop ? html`<${InviteBox} code=${room.code} name=${me.name} difficulty=${room.difficulty} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>仅限 1 名博士</span></div>`}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
|
||||
const next = nextThreshold(b.count ?? 0, th);
|
||||
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
|
||||
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
|
||||
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
|
||||
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${rec?.noStack ? undefined : b.layers ?? 0}
|
||||
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
|
||||
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
|
||||
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
|
||||
@@ -121,7 +121,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
<h3 class="bpop__name">${b.name}</h3>
|
||||
<div class="bpop__facts">
|
||||
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
|
||||
<span>层数 <b class="num t-mint">${layers}</b></span>
|
||||
${b.noStack ? html`<span>层数不显示</span>` : html`<span>层数 <b class="num t-mint">${layers}</b></span>`}
|
||||
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
|
||||
</div>
|
||||
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
|
||||
<h4>当前效果 <small class="num">(${layers} 层)</small></h4>
|
||||
<h4>当前效果 ${b.noStack ? null : html`<small class="num">(${layers} 层)</small>`}</h4>
|
||||
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
|
||||
</section>` : null}
|
||||
<section class="bpop__sec">
|
||||
|
||||
Reference in new issue
Block a user