HTML5 重写:6 款游戏 + 用户系统 + 全局设置 + 5 轮迭代重构

- 新增 web/ 纯音频 HTML5 平台:共享引擎(手势/WebAudio 三层/设置/震动)、
  Node 零依赖服务器(静态 + 注册登录 + 进度存档 API)
- 游戏:剑与声(RPG 新作)、声命线、暗室(重设计)、音序回响(重设计)、
  节拍行者(跟随领奏重构)、盲航(同向闪避重构);声东击西删除
- 全局功能:男声/女声双套 TTS 资产、三档语速、BGM 音量三档、
  震动反馈语言(10 种震型)、服务器进度同步
- 资产:899 ogg + 899 m4a(Qwen3-TTS 双性别语音、Stable Audio 3 音效、
  Mureka BGM 4+3 首),终检零缺失
- 文档:docs/design/ 13 篇设计审查文档、docs/audio/ 资产管线、
  web/server/README.md API 文档
This commit is contained in:
SoundGame Dev
2026-08-29 21:55:44 +08:00
parent f0f0a9b50e
commit cc0f8c008d
1885 changed files with 11246 additions and 5 deletions
+375
View File
@@ -0,0 +1,375 @@
/**
* audio.js — Web Audio 三层音频引擎,对齐 Android DefaultAudioEngine 行为。
*
* 三层架构(id 前缀即层名):
* | 层 | id 前缀 | 特性 |
* |------|-----------|------------------------------------------------------------|
* | BGM | 'bgm/' | 循环播放,切换时双 BufferSource 交叉淡入淡出(默认 800ms) |
* | 对白 | 'voice/' | 单通道;播放期间 BGM duck 到 20%,播完恢复; |
* | | | onComplete 回调驱动链播;被打断(stopVoice/新 voice 顶掉) |
* | | | 时**不触发** onComplete——游戏层可依赖"打断即作废语音链" |
* | 音效 | 'sfx/' | 预解码缓存,低延迟即时播放,可并发叠加 |
*
* 统一入口 play(id, opts),id 形如 'bgm/theme' 'voice/intro' 'sfx/hit',
* 自动拼路径 `${basePath}${id}.ogg`;也可带扩展名('sfx/hit.mp3')。
* id 不带扩展名时依次尝试 .ogg → .m4a → .mp3 → .wav(对齐 Android 的
* resolveAssetPath 容错;**.ogg 在 iOS Safari 上无法解码,建议给 iOS 准备
* .m4a/.mp3 副本**,详见 docs/web-engine-api.md)。
* 资产缺失只 console.error 静默跳过,不抛异常(对齐 Android)。
*
* **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。
* 标准流程(必须遵守,否则无声):
* const audio = new WebAudioEngine('audio/');
* await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + "轻触开始"
* // 此处 AudioContext 已解锁
* game.start();
*
* 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后
* 即可即时播放,**不需要** Android SoundPool 那种"音量 0 预播预热";
* prewarm(ids) 的作用是提前解码(消除首次播放的网络+解码延迟)。
*
* **用户设置(engine/settings.js)自动生效**:
* - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试
* `audio/voice/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
* - settings.speed:voice 层 source.playbackRate(语速),从下一句开始生效。
* - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。
* 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过;
* 调回非 0 时若此前有想播的 BGM 会自动补播)。
*/
import { settings } from './settings.js';
const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav'];
export class WebAudioEngine {
/**
* @param {string} basePath 音频根目录,默认 'audio/'(相对游戏页面)
* @param {object} [opts]
* @param {number} [opts.bgmVolume=1] BGM 基础音量 0~1
* @param {number} [opts.voiceVolume=1] 对白音量
* @param {number} [opts.sfxVolume=1] 音效音量
* @param {number} [opts.duckVolume=0.2] 对白播放时 BGM duck 到的比例
* @param {number} [opts.fadeMs=800] BGM 交叉淡入时长 ms
* @param {number} [opts.duckMs=300] duck / 恢复时长 ms
*/
constructor(basePath = 'audio/', opts = {}) {
this.basePath = basePath.endsWith('/') ? basePath : basePath + '/';
this.bgmVolume = opts.bgmVolume ?? 1;
this.voiceVolume = opts.voiceVolume ?? 1;
this.sfxVolume = opts.sfxVolume ?? 1;
this.duckVolume = opts.duckVolume ?? 0.2;
this.fadeMs = opts.fadeMs ?? 800;
this.duckMs = opts.duckMs ?? 300;
/** @type {AudioContext|null} */
this.ctx = null;
this.unlocked = false;
this._master = null; // 总线
this._bgmBus = null; // BGM 总线(duck 作用于此)
this._bgmPrefBus = null; // BGM 偏好总线(settings.bgm 增益作用于此)
this._voiceBus = null;
this._sfxBus = null;
this._cache = new Map(); // key -> Promise<AudioBuffer|null>(voice 的 key 含性别)
this._bgm = null; // { id, source, gain } 当前 BGM
this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播)
this._voice = null; // { source, gain, stopped } 当前对白 token
this._voiceQueue = []; // queue:true 时的等待队列
// 设置变更实时生效
settings.subscribe((_, changed) => this._onSettingsChange(changed));
}
/**
* 创建/恢复 AudioContext。必须在用户手势回调中调用(showStartOverlay 已处理)。
* @returns {Promise<void>}
*/
async unlock() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) throw new Error('Web Audio API 不可用');
this.ctx = new AC();
this._master = this.ctx.createGain();
this._master.connect(this.ctx.destination);
this._bgmPrefBus = this.ctx.createGain();
this._bgmPrefBus.gain.value = settings.get().bgm;
this._bgmPrefBus.connect(this._master);
this._bgmBus = this.ctx.createGain();
this._bgmBus.connect(this._bgmPrefBus);
this._voiceBus = this.ctx.createGain();
this._voiceBus.connect(this._master);
this._sfxBus = this.ctx.createGain();
this._sfxBus.connect(this._master);
}
if (this.ctx.state !== 'running') await this.ctx.resume();
this.unlocked = true;
}
/**
* 启动遮罩层:全屏封面 + "轻触开始"。第一次点按时解锁音频并移除遮罩。
* @param {object} [opts]
* @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景
* @param {string} [opts.text='轻触开始']
* @returns {Promise<void>} resolve 时音频已解锁,可安全 start()
*/
showStartOverlay(opts = {}) {
const { cover, text = '轻触开始' } = opts;
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'start-overlay';
if (cover) {
const img = document.createElement('img');
img.className = 'cover';
img.src = cover;
img.alt = '';
overlay.appendChild(img);
}
const hint = document.createElement('div');
hint.className = 'hint';
hint.textContent = text;
overlay.appendChild(hint);
document.body.appendChild(overlay);
let done = false;
const onTap = () => {
if (done) return;
done = true;
this.unlock().then(() => {
overlay.remove();
resolve();
});
};
// 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起
overlay.addEventListener('touchend', onTap);
overlay.addEventListener('click', onTap);
});
}
/**
* 统一播放入口。按 id 前缀分发到三层。
* @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名)
* @param {object} [opts]
* @param {number} [opts.volume] 本次播放音量 0~1(乘层音量)
* @param {number} [opts.fadeMs] 仅 BGM:本次交叉淡入时长
* @param {boolean} [opts.loop=true] 仅 BGM:是否循环
* @param {() => void} [opts.onComplete] 仅 voice:自然播完回调(被打断不触发)
* @param {boolean} [opts.queue=false] 仅 voice:true 时排队等待当前对白播完,
* false(默认)顶掉当前对白(对齐 Android)
*/
play(id, opts = {}) {
const slash = id.indexOf('/');
const layer = slash > 0 ? id.slice(0, slash) : '';
if (!this.unlocked) {
console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id);
return;
}
if (layer === 'bgm') this._playBgm(id, opts);
else if (layer === 'voice') this._playVoice(id, opts);
else if (layer === 'sfx') this._playSfx(id, opts);
else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id);
}
/** 预解码一批音频(消除首次播放延迟),id 同 play()。 */
prewarm(ids) {
for (const id of ids) this._load(id);
}
/** 停止 BGM(淡出后释放)。 */
stopBgm(fadeOutMs = this.fadeMs) {
this._bgmWanted = null;
const cur = this._bgm;
if (!cur || !this.ctx) return;
this._bgm = null;
this._fadeGain(cur.gain, 0, fadeOutMs);
setTimeout(() => { try { cur.source.stop(); } catch (_) { /* 已停止 */ } }, fadeOutMs + 60);
}
/**
* 打断当前对白并清空等待队列。**不触发** onComplete(对齐 Android,
* 游戏层依赖此实现"打断即作废整条语音链")。
*/
stopVoice() {
this._voiceQueue.length = 0;
const cur = this._voice;
if (!cur) return;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
this._unduckBgm();
}
/** 停止 BGM + 对白(sfx 都很短,不主动停)。 */
stopAll() {
this.stopBgm(200);
this.stopVoice();
}
// ---------------- 内部实现 ----------------
/**
* id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。
* voice 层按 settings.voice 优先尝试 `voice/<gender>/` 子目录,
* 找不到再回退平铺路径(过渡期兼容)。
*/
_candidates(id) {
const name = id.slice(id.lastIndexOf('/') + 1);
const hasExt = name.includes('.');
if (id.startsWith('voice/')) {
const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6);
const flatBase = this.basePath + id;
if (hasExt) return [genderBase, flatBase];
return [
...EXT_FALLBACKS.map((ext) => genderBase + ext),
...EXT_FALLBACKS.map((ext) => flatBase + ext),
];
}
const base = this.basePath + id;
if (hasExt) return [base];
return EXT_FALLBACKS.map((ext) => base + ext);
}
/**
* 加载并解码,缓存 Promise(含失败结果 null,避免重复 404)。
* 资产缺失/无法解码时 console.error 并返回 null,不抛异常。
*/
_load(id) {
// voice 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果
const key = id.startsWith('voice/') ? `${settings.get().voice}|${id}` : id;
if (this._cache.has(key)) return this._cache.get(key);
const p = (async () => {
if (!this.ctx) return null;
for (const url of this._candidates(id)) {
try {
const resp = await fetch(url);
if (!resp.ok) continue;
const raw = await resp.arrayBuffer();
return await this.ctx.decodeAudioData(raw);
} catch (_) {
// 解码失败(如 iOS Safari 遇到 .ogg)→ 尝试下一个扩展名
}
}
console.error('[WebAudioEngine] 资产缺失或无法解码:', id, this._candidates(id));
return null;
})();
this._cache.set(key, p);
return p;
}
_fadeGain(gainNode, target, ms) {
const t = this.ctx.currentTime;
const g = gainNode.gain;
g.cancelScheduledValues(t);
g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(target, t + ms / 1000);
}
_duckBgm() {
if (this._bgmBus) this._fadeGain(this._bgmBus, this.duckVolume, this.duckMs);
}
/**
* 设置变更实时生效(settings.subscribe 回调)。
* - bgm 改值:进行中的 BGM 立即渐变到新增益(150ms);
* 改到 0:立即淡出停止(保留"想播"意图);从 0 改回:补播最近想播的 BGM。
* - voice/speed:无需处理——voice 播放在创建 source 时实时读取设置,
* 自然从下一句生效。
*/
_onSettingsChange(changed) {
if (changed.bgm === undefined) return;
const v = changed.bgm;
if (v === 0) {
const wanted = this._bgmWanted;
this.stopBgm(200); // stopBgm 会清 _bgmWanted,先存后恢复
this._bgmWanted = wanted;
} else {
if (this.ctx && this._bgmPrefBus) this._fadeGain(this._bgmPrefBus, v, 150);
if (!this._bgm && this._bgmWanted && this.unlocked) {
const w = this._bgmWanted;
this._playBgm(w.id, w.opts);
}
}
}
_unduckBgm() {
if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs);
}
async _playBgm(id, { volume = 1, fadeMs = this.fadeMs, loop = true } = {}) {
this._bgmWanted = { id, opts: { volume, fadeMs, loop } };
if (settings.get().bgm === 0) return; // BGM 关闭:只记意图,不加载不播放
if (this._bgm && this._bgm.id === id) return; // 同一首不重启
const buf = await this._load(id);
if (!buf || (this._bgm && this._bgm.id === id)) return;
const source = this.ctx.createBufferSource();
source.buffer = buf;
source.loop = loop;
const gain = this.ctx.createGain();
gain.gain.value = 0;
source.connect(gain);
gain.connect(this._bgmBus);
const old = this._bgm;
this._bgm = { id, source, gain };
source.start();
// 交叉淡入:新 0→目标,旧 →0 后释放(对齐 Android fadeVolume 时序)
this._fadeGain(gain, this.bgmVolume * volume, fadeMs);
if (old) {
this._fadeGain(old.gain, 0, fadeMs);
setTimeout(() => { try { old.source.stop(); } catch (_) { /* 已停止 */ } }, fadeMs + 60);
}
}
async _playVoice(id, { volume = 1, onComplete, queue = false } = {}) {
if (queue && this._voice) {
this._voiceQueue.push({ id, volume, onComplete });
return;
}
// 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义)
if (this._voice) {
const cur = this._voice;
cur.stopped = true;
this._voice = null;
try { cur.source.stop(); } catch (_) { /* 已停止 */ }
}
const buf = await this._load(id);
if (!buf) { this._unduckBgm(); return; }
const source = this.ctx.createBufferSource();
source.buffer = buf;
source.playbackRate.value = settings.get().speed; // 语速(下一句生效)
const gain = this.ctx.createGain();
gain.gain.value = this.voiceVolume * volume;
source.connect(gain);
gain.connect(this._voiceBus);
const token = { source, gain, stopped: false };
this._voice = token;
this._duckBgm();
source.onended = () => {
if (this._voice !== token || token.stopped) return; // 被打断:不回调
this._voice = null;
if (this._voiceQueue.length === 0) this._unduckBgm();
if (onComplete) onComplete();
const next = this._voiceQueue.shift();
if (next) this._playVoice(next.id, next);
};
source.start();
}
async _playSfx(id, { volume = 1 } = {}) {
const buf = await this._load(id);
if (!buf) return;
const source = this.ctx.createBufferSource();
source.buffer = buf;
const gain = this.ctx.createGain();
gain.gain.value = this.sfxVolume * volume;
source.connect(gain);
gain.connect(this._sfxBus);
source.start(); // 即发即弃,天然支持并发叠加
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* game.js — 游戏侧契约:Game 基类 + StateMachineGame 状态机基类。
* 对齐 Android 的 Game 接口 / GameEngine<S>(见 docs/architecture.md)。
*
* 每个游戏的接线方式(见 web/template/index.html 完整示例):
* const audio = new WebAudioEngine('audio/');
* const game = new MyGame(audio, document.getElementById('game-root'));
* await audio.showStartOverlay({ cover: 'cover.png' }); // 解锁音频
* game.start();
* attachGestures(document.getElementById('game-root'), (g) => game.onGesture(g));
*/
/**
* Game 基类(对齐 Android Game 接口)。
* 直接继承它,覆写三个方法即可;适合状态复杂的游戏
* (Android 端 storyline/reflex/escape 实际都是直接实现 Game 的)。
*/
export class Game {
/**
* @param {WebAudioEngine} audio 音频引擎(已解锁后才会调用 start)
* @param {HTMLElement} root 全屏根元素(#game-root),可用于改封面/DOM 提示
*/
constructor(audio, root) {
this.audio = audio;
this.root = root;
}
/**
* 手势入口。g ∈ 'up'|'down'|'left'|'right'|'tap'|'doubletap'
* 注意 'tap' 回调有约 300ms 延迟(双击判定窗口),即时反馈请用滑动/双击。
* @param {string} g
*/
onGesture(g) { /* 子类实现 */ }
/** 音频解锁后由页面调用一次。游戏从这里开始(播 BGM、开场白等)。 */
start() { /* 子类实现 */ }
/** 页面隐藏/退出时调用,应停止计时器与音频。可选实现。 */
stop() {
this.audio.stopAll();
}
}
/**
* 状态机基类(对齐 Android GameEngine<S>),线性剧情类游戏的推荐起点。
*
* 子类实现:
* initialState() -> 初始状态
* transition(state, g) -> 纯函数,返回新状态;返回原状态表示无转移
* onEnterState(state, prev) -> 副作用(播音频等),prev 初始为 null
*
* onGesture 是 final 逻辑:算 transition,状态变了才更新并回调 onEnterState。
*
* 示例:
* class MyGame extends StateMachineGame {
* initialState() { return 'INTRO'; }
* transition(s, g) {
* if (s === 'INTRO' && g === 'tap') return 'PLAY';
* return s;
* }
* onEnterState(s) {
* if (s === 'INTRO') this.audio.play('voice/intro', { onComplete: () => ... });
* }
* }
*/
export class StateMachineGame extends Game {
constructor(audio, root) {
super(audio, root);
this.state = null;
}
/** @returns {*} 初始状态 */
initialState() { throw new Error('implement initialState()'); }
/**
* 纯函数:由 (当前状态, 手势) 算新状态。返回原状态表示无转移。
* @param {*} state @param {string} g @returns {*}
*/
transition(state, g) { throw new Error('implement transition()'); }
/** 进入新状态的副作用(播音频)。prev 初始为 null。 */
onEnterState(state, prev) { /* 子类实现 */ }
onGesture(g) {
const next = this.transition(this.state, g);
if (next !== this.state) {
const prev = this.state;
this.state = next;
this.onEnterState(next, prev);
}
}
/** 强制切换状态(如计时器驱动、语音 onComplete 驱动)。 */
setState(next) {
if (next !== this.state) {
const prev = this.state;
this.state = next;
this.onEnterState(next, prev);
}
}
start() {
this.state = this.initialState();
this.onEnterState(this.state, null);
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* gesture.js — 6 手势识别(触摸 + 鼠标双支持),对齐 Android GestureEngine 行为。
*
* 手势集合:'up' | 'down' | 'left' | 'right' | 'tap' | 'doubletap'
*
* 判定规则(与 Android 版 GestureConfig 一致):
* - 滑动:累积位移 >= swipeMinDistance(默认 64px ≈ 64dp@160dpi)
* 且平均速度 >= swipeMinVelocity(默认 400 px/s)。
* 方向取位移较大的轴(|dx|>|dy| 判左右,否则判上下),斜滑归一到最近主轴。
* - 单击/双击:位移 < tapSlop 视为点按;双击窗口 doubleTapWindow(默认 300ms)。
* **单击回调有约 300ms 延迟**(要等双击窗口关闭才能确定不是双击),
* 即时反馈类操作请绑定滑动或双击,单击只用于非即时操作。
*
* 用法:
* import { attachGestures } from '../engine/gesture.js';
* const detach = attachGestures(element, (g) => game.onGesture(g));
* // detach() 可解除绑定
*
* 基础震动反馈(haptics.js,不支持的平台自动 no-op):
* 滑动识别成功→swipe,tap→confirm,doubletap→confirm。
* 游戏逻辑层的语义反馈(error/success/warning 等)由游戏自行调用 vibrate()。
*
* 注意:绑定点按的元素的 touchstart/touchmove/touchend 会被 preventDefault
* (阻止页面滚动、双指缩放与系统合成鼠标事件),元素需应用
* style.css 里的全屏样式(touch-action: none)。
*/
import { vibrate } from './haptics.js';
export const DEFAULT_GESTURE_CONFIG = {
swipeMinDistance: 64, // px,累积位移阈值(≈ Android 64dp)
swipeMinVelocity: 400, // px/s,平均速度阈值(位移 / 拖动时长)
doubleTapWindow: 300, // ms,双击判定窗口(单击因此延迟 ~300ms 回调)
tapSlop: 24, // px,位移小于此值才算点按
};
export function attachGestures(element, onGesture, config = {}) {
const cfg = { ...DEFAULT_GESTURE_CONFIG, ...config };
// 单指/单指针跟踪状态
let tracking = false;
let startX = 0, startY = 0, startTime = 0;
let moved = false; // 位移是否已超 tapSlop(超过则本次不可能是点按)
// 点按状态
let lastTapTime = 0;
let pendingTapTimer = null;
// 触摸发生后短时间内忽略合成鼠标事件
let lastTouchEndTime = 0;
function cancelPendingTap() {
if (pendingTapTimer !== null) {
clearTimeout(pendingTapTimer);
pendingTapTimer = null;
}
}
function handleDown(x, y) {
tracking = true;
moved = false;
startX = x;
startY = y;
startTime = performance.now();
}
function handleMove(x, y) {
if (!tracking) return;
if (Math.hypot(x - startX, y - startY) > cfg.tapSlop) moved = true;
}
function handleUp(x, y) {
if (!tracking) return;
tracking = false;
const dx = x - startX;
const dy = y - startY;
const dist = Math.hypot(dx, dy);
const durationMs = Math.max(performance.now() - startTime, 1);
const velocity = dist / (durationMs / 1000); // px/s
if (dist >= cfg.swipeMinDistance && velocity >= cfg.swipeMinVelocity) {
// 主轴归一:位移较大的轴决定方向
cancelPendingTap(); // 滑动作废等待中的单击
lastTapTime = 0;
let g;
if (Math.abs(dx) > Math.abs(dy)) g = dx > 0 ? 'right' : 'left';
else g = dy > 0 ? 'down' : 'up';
vibrate('swipe'); // 滑动识别成功
onGesture(g);
return;
}
if (!moved && dist < cfg.tapSlop) {
const now = performance.now();
if (now - lastTapTime <= cfg.doubleTapWindow) {
// 双击:取消尚未触发的单击
cancelPendingTap();
lastTapTime = 0;
vibrate('confirm'); // 双击
onGesture('doubletap');
} else {
lastTapTime = now;
cancelPendingTap();
// 等双击窗口关闭后才回调单击(与 Android 一致的 ~300ms 延迟)
pendingTapTimer = setTimeout(() => {
pendingTapTimer = null;
lastTapTime = 0;
vibrate('confirm'); // 单击
onGesture('tap');
}, cfg.doubleTapWindow);
}
}
}
function handleCancel() {
tracking = false;
}
// ---- 触摸 ----
// 全部 preventDefault:防滚动 / 防双击缩放 / 防合成鼠标事件重复触发。
// 必须 passive:false 才能 preventDefault。
function onTouchStart(e) {
if (e.touches.length > 1) { handleCancel(); return; } // 忽略多指
e.preventDefault();
const t = e.touches[0];
handleDown(t.clientX, t.clientY);
}
function onTouchMove(e) {
e.preventDefault();
if (!tracking) return;
const t = e.touches[0];
handleMove(t.clientX, t.clientY);
}
function onTouchEnd(e) {
e.preventDefault();
lastTouchEndTime = performance.now();
const t = e.changedTouches[0];
handleUp(t.clientX, t.clientY);
}
function onTouchCancel() {
lastTouchEndTime = performance.now();
handleCancel();
}
// ---- 鼠标(桌面调试)----
let mouseDown = false;
function recentlyTouched() {
return performance.now() - lastTouchEndTime < 700;
}
function onMouseDown(e) {
if (recentlyTouched() || e.button !== 0) return;
mouseDown = true;
handleDown(e.clientX, e.clientY);
}
function onMouseMove(e) {
if (!mouseDown) return;
handleMove(e.clientX, e.clientY);
}
function onMouseUp(e) {
if (!mouseDown || recentlyTouched()) return;
mouseDown = false;
handleUp(e.clientX, e.clientY);
}
function onContextMenu(e) {
e.preventDefault(); // 防移动端长按弹菜单
}
const opts = { passive: false };
element.addEventListener('touchstart', onTouchStart, opts);
element.addEventListener('touchmove', onTouchMove, opts);
element.addEventListener('touchend', onTouchEnd, opts);
element.addEventListener('touchcancel', onTouchCancel, opts);
element.addEventListener('mousedown', onMouseDown);
element.addEventListener('mousemove', onMouseMove);
element.addEventListener('mouseup', onMouseUp);
element.addEventListener('contextmenu', onContextMenu);
return function detach() {
cancelPendingTap();
element.removeEventListener('touchstart', onTouchStart, opts);
element.removeEventListener('touchmove', onTouchMove, opts);
element.removeEventListener('touchend', onTouchEnd, opts);
element.removeEventListener('touchcancel', onTouchCancel, opts);
element.removeEventListener('mousedown', onMouseDown);
element.removeEventListener('mousemove', onMouseMove);
element.removeEventListener('mouseup', onMouseUp);
element.removeEventListener('contextmenu', onContextMenu);
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* haptics.js — navigator.vibrate 震动反馈封装。
*
* 预定义震型:
* | 名称 | 震型 (ms) | 建议用途 |
* |-----------|----------------------|------------------------|
* | confirm | [20] | 点按确认 |
* | error | [60,40,60] | 操作错误 |
* | success | [100] | 过关/成功 |
* | warning | [30,50,30,50,30] | 警告/危险逼近 |
* | swipe | [10] | 滑动识别成功(最轻) |
* | heartbeat | [40,100,40] | 心跳/倒计时紧张感 |
* | pickup | [15] | 拾取/获得物品 |
* | hit | [80] | 受击/终结性失败 |
* | beat | [8] | 节拍锚点(最轻) |
* | danger | [25,40,25,40,25] | 危险逼近 |
*
* 特性检测:'vibrate' in navigator。不支持的平台(iOS Safari 等)
* 所有调用静默 no-op,不抛异常。
*
* 用法:
* import { vibrate } from '../engine/haptics.js';
* vibrate('confirm'); // 预定义名称
* vibrate([50, 30, 50]); // 自定义震型
*
* 注意:gesture.js 已内置基础反馈(滑动→swipe,tap/doubletap→confirm),
* 游戏逻辑层只需在语义事件(错误/成功/警告等)处自行调用。
*/
/** @type {Record<string, number[]>} */
export const HAPTIC_PATTERNS = {
confirm: [20],
error: [60, 40, 60],
success: [100],
warning: [30, 50, 30, 50, 30],
swipe: [10],
heartbeat: [40, 100, 40],
pickup: [15],
hit: [80],
beat: [8],
danger: [25, 40, 25, 40, 25],
};
/** 当前环境是否支持震动(iOS Safari 为 false)。 */
export const hapticsSupported =
typeof navigator !== 'undefined' && 'vibrate' in navigator;
/**
* 触发震动。不支持时静默 no-op。
* @param {keyof typeof HAPTIC_PATTERNS | number[]} pattern 预定义名称或毫秒数组
*/
export function vibrate(pattern) {
if (!hapticsSupported) return;
const p = typeof pattern === 'string' ? HAPTIC_PATTERNS[pattern] : pattern;
if (!p) {
console.warn('[haptics] 未知震型:', pattern);
return;
}
try {
navigator.vibrate(p);
} catch (_) {
// 某些平台调用即抛(极少见)→ 静默
}
}
export default vibrate;
+140
View File
@@ -0,0 +1,140 @@
/**
* settings.js — 全局用户设置(纯音频游戏合集共享)。
*
* 三项设置:
* | 键 | 取值 | 默认 | 作用 |
* |--------|-----------------------|----------|----------------------------------------|
* | voice | 'male' \| 'female' | 'female' | voice 层加载 audio/voice/<gender>/ 下资产 |
* | speed | 1.0 \| 1.35 \| 1.7 | 1.0 | voice 层 source.playbackRate(语速) |
* | bgm | 1.0 \| 0.3 \| 0 | 1.0 | BGM 层增益;0 = 不加载不播放 BGM |
*
* localStorage 持久化(key: 'soundgame.settings'),同域下大厅与所有游戏
* 页面天然共享;跨标签页修改通过 'storage' 事件同步给订阅者。
*
* 用法:
* import { settings } from '../engine/settings.js';
* settings.get(); // -> { voice, speed, bgm }(副本)
* settings.set({ speed: 1.35 }); // 部分更新,持久化并通知订阅者
* const off = settings.subscribe((s, changed) => { ... }); // off() 退订
*
* 游戏侧一般**不需要**直接用本模块:audio.js 已自动应用三项设置
* (voice 路径/语速/BGM 增益)。需要自定义响应(如改 UI)才 subscribe。
*/
const STORAGE_KEY = 'soundgame.settings';
/** @typedef {{ voice: 'male'|'female', speed: number, bgm: number }} SoundGameSettings */
/** @type {SoundGameSettings} */
const DEFAULTS = { voice: 'female', speed: 1.0, bgm: 1.0 };
const VALID = {
voice: ['male', 'female'],
speed: [1.0, 1.35, 1.7],
bgm: [1.0, 0.3, 0],
};
/** @type {SoundGameSettings} */
let current = { ...DEFAULTS };
/** @type {Set<(s: SoundGameSettings, changed: Partial<SoundGameSettings>) => void>} */
const listeners = new Set();
function isValid(key, value) {
return VALID[key] && VALID[key].includes(value);
}
/** 从 localStorage 读取并合并默认值(非法值逐项丢弃,保证永不炸)。 */
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const obj = JSON.parse(raw);
for (const key of Object.keys(DEFAULTS)) {
if (isValid(key, obj[key])) current[key] = obj[key];
}
} catch (_) {
// localStorage 不可用(隐私模式)或数据损坏 → 用默认值
}
}
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
} catch (_) {
// 写不进去就算了,内存值仍然生效
}
}
function notify(changed) {
const snapshot = { ...current };
for (const fn of listeners) {
try {
fn(snapshot, changed);
} catch (e) {
console.error('[settings] 订阅者回调异常:', e);
}
}
}
load();
// 跨标签页同步(大厅和游戏页同时开着时)
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY) return;
const before = { ...current };
load();
const changed = {};
for (const key of Object.keys(DEFAULTS)) {
if (before[key] !== current[key]) changed[key] = current[key];
}
if (Object.keys(changed).length > 0) notify(changed);
});
export const settings = {
/**
* 读取当前设置(返回副本,改它不影响内部状态)。
* @returns {SoundGameSettings}
*/
get() {
return { ...current };
},
/**
* 部分更新设置。非法键/值被忽略(console.warn),合法项立即持久化
* 并通知所有订阅者。
* @param {Partial<SoundGameSettings>} patch
* @returns {SoundGameSettings} 更新后的完整设置
*/
set(patch) {
const changed = {};
for (const [key, value] of Object.entries(patch || {})) {
if (!isValid(key, value)) {
console.warn('[settings] 忽略非法设置项:', key, value);
continue;
}
if (current[key] !== value) {
current[key] = value;
changed[key] = value;
}
}
if (Object.keys(changed).length > 0) {
persist();
notify(changed);
}
return { ...current };
},
/**
* 订阅设置变更。回调签名 (snapshot, changed)。
* audio.js 靠它实现实时生效(BGM 立即调增益、voice 下一句生效)。
* @param {(s: SoundGameSettings, changed: Partial<SoundGameSettings>) => void} fn
* @returns {() => void} 退订函数
*/
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
export default settings;
+71
View File
@@ -0,0 +1,71 @@
/*
* style.css — 共享引擎样式:全屏黑底 + 封面铺满 + 屏蔽系统手势干扰。
* 游戏页引用方式:<link rel="stylesheet" href="../engine/style.css">
* 同时必须在 <head> 里带 viewport meta(见各 index.html):
* <meta name="viewport" content="width=device-width, initial-scale=1,
* maximum-scale=1, user-scalable=no, viewport-fit=cover">
*/
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
/* 防下拉刷新 / 橡皮筋回弹 */
overscroll-behavior: none;
}
/* 游戏全屏根元素:手势全屏捕获层 */
#game-root {
position: fixed;
inset: 0;
/* 浏览器原生手势全部禁用,交给 engine/gesture.js */
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none; /* iOS 长按弹菜单 */
-webkit-tap-highlight-color: transparent;
}
/* 封面图:居中铺满(裁切多余部分) */
#game-root img.cover,
#start-overlay img.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 启动遮罩层:封面 + “轻触开始”(audio.showStartOverlay 创建) */
#start-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: #000;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
#start-overlay .hint {
position: absolute;
left: 0;
right: 0;
bottom: 18%;
text-align: center;
color: #fff;
font: 500 20px/1.5 system-ui, sans-serif;
letter-spacing: 0.2em;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
animation: start-overlay-pulse 1.6s ease-in-out infinite;
}
@keyframes start-overlay-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}