Files
SoundGame/web/engine/novice.js
T
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

98 lines
3.5 KiB
JavaScript

/**
* novice.js — 新手手势模式与解锁阶梯(26 号文档 §4.3,P0 第 1 项 + P3 第 11 项)。
*
* 新手手势表(全部游戏共用,原则 6):
* | 手势 | 唯一含义 | 新手期(阶段 0) |
* |------------|------------------------|---------------------------------|
* | tap | 继续 / 确定 / 听一下 | 开放 |
* | left/right | 选择 / 移动 | 开放 |
* | up | 我在哪、我该干嘛 | 阶段 1 解锁(完成第一个小目标) |
* | down | 菜单 | 阶段 2 解锁(完成第一局) |
* | doubletap | 重听 | 阶段 3 解锁(累计 3 局);未解锁按单击处理 |
* | longpress | 帮助(四向玩法类游戏) | 仅「滑动是玩法」的游戏开放(§4.3 裁定) |
*
* 解锁进度跨局持久(localStorage),全局共享(手势表全项目一致,学会一次就算会)。
* 游戏在对应时机调 onGoal() / onGameDone(),返回新解锁的手势(null=无新解锁),
* 随后按原则 11 教学:先说一句(teachLine),马上用一次。
*/
const STORAGE_KEY = 'soundgame.gestures';
/** 解锁教学句 id(每游戏目录各存一份同文案资产,§4.3 阶梯表)。 */
const TEACH_IDS = {
up: 'sys_unlock_up', // 「想知道自己在哪,就向上滑。」
down: 'sys_unlock_down', // 「向下滑,可以打开菜单。」
doubletap: 'sys_unlock_dt', // 「没听清,就双击,再听一遍。」
};
const UNLOCK_AT = { 1: 'up', 2: 'down', 3: 'doubletap' };
export class GestureLadder {
constructor(key = STORAGE_KEY) {
this.key = key;
}
_load() {
try {
const raw = localStorage.getItem(this.key);
if (!raw) return { stage: 0, games: 0 };
const o = JSON.parse(raw);
return {
stage: Math.max(0, Math.min(3, Number(o.stage) || 0)),
games: Math.max(0, Number(o.games) || 0),
};
} catch (_) {
return { stage: 0, games: 0 };
}
}
_save(o) {
try { localStorage.setItem(this.key, JSON.stringify(o)); } catch (_) { /* 隐私模式 */ }
}
/** 当前阶段 0..3。 */
stage() { return this._load().stage; }
/** 手势是否已解锁(tap/left/right 永远开放;longpress 由游戏按玩法裁定)。 */
has(g) {
if (g === 'tap' || g === 'left' || g === 'right') return true;
if (g === 'up') return this.stage() >= 1;
if (g === 'down') return this.stage() >= 2;
if (g === 'doubletap') return this.stage() >= 3;
return false; // longpress 等:走游戏自己的核心玩法裁定
}
/** 完成第一个小目标 → 阶段 1。返回新解锁手势或 null。 */
onGoal() { return this._unlock(1); }
/** 完成一局 → games+1;第 1 局解锁阶段 2,第 3 局解锁阶段 3。 */
onGameDone() {
const o = this._load();
o.games += 1;
this._save(o);
let got = null;
if (o.games >= 1) got = this._unlock(2) || got;
if (o.games >= 3) got = this._unlock(3) || got;
return got;
}
_unlock(stage) {
const o = this._load();
if (o.stage >= stage) return null;
o.stage = stage;
this._save(o);
return UNLOCK_AT[stage];
}
/** 老玩家在大厅一键全部解锁。 */
unlockAll() {
const o = this._load();
o.stage = 3;
this._save(o);
}
/** 解锁教学句 voice id。 */
teachLine(g) { return TEACH_IDS[g] || null; }
}
export default GestureLadder;