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; }
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More