Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
+57
-9
@@ -11,8 +11,17 @@
|
||||
// - 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).
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds,
|
||||
// a per-unit cooldown and a per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
// - 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.
|
||||
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
|
||||
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
|
||||
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
|
||||
// on that ally (纯烬艾雅法拉's heals made every later hit on a healed ally ring her impact sound), element gauge fills
|
||||
// and DoTs play none, and a chain bounce ('chain' / 'chainHeal': its first id is the previous target) plays no attack
|
||||
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
|
||||
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
|
||||
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
|
||||
// - 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.
|
||||
@@ -26,9 +35,18 @@ import { PHASE } from '../../shared/constants.js';
|
||||
const MAX_VOICES = 8;
|
||||
const UNIT_COOLDOWN_MS = 160;
|
||||
const URL_GAP_MS = 45;
|
||||
const MAX_PER_URL = 2;
|
||||
const BUFFER_CACHE = 180;
|
||||
const XFADE_S = 1;
|
||||
const FADE_S = 0.8;
|
||||
/** 'atk' projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
|
||||
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
|
||||
/** 'dmg' types that are an attack's impact (element gauge fills / 元素伤害 carry the element's name instead). */
|
||||
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
|
||||
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
|
||||
const IMPACT_WINDOW_MS = 2500;
|
||||
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
|
||||
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
||||
|
||||
// ---- pure helpers (unit-tested) -----------------------------------------------------------------------
|
||||
|
||||
@@ -133,29 +151,45 @@ export function deploySfxUrl(manifest, info) {
|
||||
return typeof url === 'string' ? url : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a unit's manifest `attack` / `hit` sound may play for its normal attacks: an operator's (`char_*`) sound file
|
||||
* of one of its skill modes (`_d` / `_h` / `_s`, see header) may not. Enemy files use `_h` for heavy weapons (always
|
||||
* allowed), and so may summons.
|
||||
* @param {string} defId the unit's model id (sfx.units key)
|
||||
* @param {string} url
|
||||
*/
|
||||
export function normalAttackSfx(defId, url) {
|
||||
return typeof url === 'string' && !(typeof defId === 'string' && defId.startsWith('char_') && SKILL_MODE_FILE.test(url));
|
||||
}
|
||||
|
||||
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
|
||||
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
|
||||
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
|
||||
|
||||
export class SfxLimiter {
|
||||
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number }} [o] */
|
||||
/** @param {{ maxVoices?: number, unitCooldownMs?: number, urlGapMs?: number, maxPerUrl?: number }} [o] */
|
||||
constructor(o = {}) {
|
||||
this.maxVoices = o.maxVoices ?? MAX_VOICES;
|
||||
this.unitCooldownMs = o.unitCooldownMs ?? UNIT_COOLDOWN_MS;
|
||||
this.urlGapMs = o.urlGapMs ?? URL_GAP_MS;
|
||||
// the official battle banks (attack, impact, heal, born, dead…) allow at most 2 overlapping copies of a sound
|
||||
// (audio_data maxSoundAllowed 2): a heal / impact heard on every tick of a crowd never piles up
|
||||
this.maxPerUrl = o.maxPerUrl ?? MAX_PER_URL;
|
||||
this.active = 0;
|
||||
this.lastByUnit = new Map();
|
||||
this.lastByUrl = new Map();
|
||||
this.activeByUrl = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sound may start now; records it when allowed (call `release()` when it ends).
|
||||
* Whether a sound may start now; records it when allowed (call `release(url)` when it ends).
|
||||
* @param {number} now ms
|
||||
* @param {string|number|null} unitKey e.g. `${unitId}:atk`
|
||||
* @param {string} url
|
||||
*/
|
||||
tryAcquire(now, unitKey, url) {
|
||||
if (this.active >= this.maxVoices) return false;
|
||||
if ((this.activeByUrl.get(url) || 0) >= this.maxPerUrl) return false;
|
||||
if (unitKey != null) {
|
||||
const t = this.lastByUnit.get(unitKey);
|
||||
if (t != null && now - t < this.unitCooldownMs) return false;
|
||||
@@ -167,10 +201,16 @@ export class SfxLimiter {
|
||||
if (this.lastByUnit.size > 600) this.lastByUnit.clear();
|
||||
if (this.lastByUrl.size > 400) this.lastByUrl.clear();
|
||||
this.active += 1;
|
||||
this.activeByUrl.set(url, (this.activeByUrl.get(url) || 0) + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
release() { this.active = Math.max(0, this.active - 1); }
|
||||
/** A sound started by tryAcquire ended. */
|
||||
release(url) {
|
||||
this.active = Math.max(0, this.active - 1);
|
||||
const n = this.activeByUrl.get(url) || 0;
|
||||
if (n <= 1) this.activeByUrl.delete(url); else this.activeByUrl.set(url, n - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manager -----------------------------------------------------------------------------------------------
|
||||
@@ -195,7 +235,7 @@ export class AudioManager {
|
||||
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
|
||||
this.bgmToken = 0;
|
||||
this.units = new Map(); // battle unit id → defId
|
||||
this.lastAttacker = new Map(); // target id → attacker defId
|
||||
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
|
||||
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
|
||||
this.installed = false;
|
||||
this._unlock = this._unlock.bind(this);
|
||||
@@ -454,7 +494,7 @@ export class AudioManager {
|
||||
if (limited) { if (!this.limiter.tryAcquire(now, unitKey, url)) return; }
|
||||
else if (this.uiVoices >= 12) return;
|
||||
else this.uiVoices += 1;
|
||||
const release = () => { if (limited) this.limiter.release(); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
|
||||
const release = () => { if (limited) this.limiter.release(url); else this.uiVoices = Math.max(0, this.uiVoices - 1); };
|
||||
this._buffer(url).then((buf) => {
|
||||
if (!buf || !this.ctx) { release(); return; }
|
||||
try {
|
||||
@@ -507,6 +547,7 @@ export class AudioManager {
|
||||
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
|
||||
const url = typeof own === 'string' ? own : u?.[kind];
|
||||
if (typeof url !== 'string') return false;
|
||||
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
|
||||
this._play(url, { volume: kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, limited: true, unitKey: `${unitId}:${kind}` });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
@@ -542,18 +583,25 @@ export class AudioManager {
|
||||
handleBattleEvents(ev) {
|
||||
if (!this.ctx || !Array.isArray(ev)) return;
|
||||
try {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
for (const e of ev) {
|
||||
if (!Array.isArray(e)) continue;
|
||||
const kind = e[0];
|
||||
if (kind === 'spawn') { this._track(e[1]); continue; }
|
||||
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]);
|
||||
if (!src) continue;
|
||||
this.lastAttacker.set(e[2], src.def);
|
||||
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
|
||||
const tgt = this.units.get(e[2]);
|
||||
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
|
||||
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
|
||||
} else if (kind === 'dmg') {
|
||||
const by = this.lastAttacker.get(e[1]);
|
||||
if (by) this.unit(by, 'hit', `h${e[1]}`);
|
||||
if (!by || !IMPACT_TYPES.has(e[3])) continue;
|
||||
this.lastAttacker.delete(e[1]); // one impact per attack
|
||||
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
|
||||
} else if (kind === 'heal') {
|
||||
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
|
||||
} else if (kind === 'skill' && e[2]) {
|
||||
|
||||
Reference in new issue
Block a user