Files
SoundGame/web/teahouse/game.js
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1789 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《深夜茶馆》R2 —— 经营养成纯音频游戏,按 docs/design/26-audio-interaction-playbook.md
* 全面重构(交互层)。玩法核心(客人点单、茶谱、时机窗判定、口碑/账目、多晚连续剧)
* 不动;设计单正文见 docs/design/20-teahouse.md 附录 C,接线与偏差见 26-apply-teahouse.md。
*
* 交互层(对齐 26 号文档):
* - 发言权管理器(§4.1):整条语音链 = SPEAKING(音乐压低跨链、句间不掉回「轮到你」
* 听感);链完 → yieldTurn:交接提示音 → 400ms 后起等待声。服务链/旁白/账目链全部
* 走 floor.speak;res_ / rent_ / 打烊结算链 / tut 指令 = critical(busy 回执,不可跳)。
* - 输入回执 + 输入管道(§4.2):ok/invalid/busy;单击「继续/跳过/提壶/出汤」后 400ms
* 防连点(时机窗内连点只第一下生效);滑动打断并执行。
* - 手势宪法(§4.3):doubleTapWindow=0(废除时机步双击=提壶/出汤,单击已即时);
* 时机步单击=提壶/出汤(内容音,silent-ok);上滑=「我在哪」阶段 1(第一盏茶送出后
* 解锁);下滑=菜单阶段 2(第一晚打烊后解锁,冻结计时);长按=新手 invalid。
* - 三档提示(§4.4):等待点 full → short → 提示音;主动重听 = 「再说一遍:」+ 完整句。
* - 听觉分层(20 号 §3.3):注水/沸腾/滴答时间上串行;时机窗内禁止向导语音——
* water/steep 等待点 full/short 置空(被动档只剩 earcon),无效滑动不解释只回执。
* - 时机错过(§4.9):失败表归因句 water_ / steep_(原因真话 + 这一步偏了,2 秒内),
* 不终止——早/老照走闷泡/上茶;无出手到点自动过老/自动出汤。
* - 迷茫检测(§4.5):乱点/无效/静默 → 三级接管(L1 短句/earcon → L2 只留期待手势 →
* L3 替打:替点/替选点单那罐/替提壶(拨到将沸)/替出汤)。
* - 断点复述(§4.12):每位客人结算后自动存档;回来 sys_continue_ask 问继续/重开,
* 复述链 = 第 N 晚 → 下一位客人 → 上滑听点单,summary 保留。
* - 教学寄生首晚(20 号 §5):D0 左右滑看柜 → D1 单击选定 → D2 听水声单击提壶 →
* D3 听滴答单击出汤 → D4 单击端茶 → D5 上滑状态;偏了真话归因重教,2 次后替打,
* 90s 无进展自动替打。已 drillDone 玩家开场「点一下开始」(不念教学)。
*
* 状态 × 手势矩阵(新手期,完整版见 20 号附录 C §4):
* | 状态 | 单击 | 左/右滑 | 上滑 | 下滑(未解锁) | 双击(未解锁) | 连点 |
* |--------------|-----------------|-------------------|---------------|--------------|--------------|-------------|
* | TITLE 等开工 | 开店/继续档 | invalid+解释 | 店铺概况 | invalid | 按单击 | 400ms busy |
* | OPENING 在说 | 跳句 | 打断并推进 | 状态句 | invalid | 按单击 | busy |
* | 堂前在说 | 跳句(res_/rent_ 关键句 busy) | 打断并推进 | 状态句(点单可重听) | invalid | 按单击 | busy |
* | 选茶 TEA | 选定→候汤 | 换罐+jar+口诀句 | 重播点单 | invalid | 按单击 | busy |
* | 候汤 WATER | 提壶(时机判定) | invalid 不判错步 | 状态;水声照走 | invalid | =提壶 | 窗内只第一下 |
* | 闷泡 STEEP | 出汤(时机判定) | invalid 不判错步 | 状态;时间线照走 | invalid | =出汤 | 只第一下 |
* | 打烊链内 | busy(吞) | busy | busy | busy | busy | busy |
* | 打烊链完 | 睡下 | invalid | 账目链 | 菜单(阶段2含置办) | 按单击 | 第一下 |
* | 菜单 | 确认 | 切项+报名 | 重听说明 | 关闭 | 按单击 | busy |
*/
import { Game } from '../engine/game.js';
import { Guide } from '../engine/guide.js';
import { Floor } from '../engine/floor.js';
import { Receipts } from '../engine/receipts.js';
import { Confusion } from '../engine/confusion.js';
import { WaitPoint, InvalidHints } from '../engine/hints.js';
import { GestureLadder } from '../engine/novice.js';
import { vibrate } from '../engine/haptics.js';
const SAVE_KEY = 'soundgame.progress.teahouse';
const AUTH_KEY = 'soundgame.auth';
// ---------------- 数值(20 号 §3.1/§3.2/§7.2/§7.4;修订记录 §14-1:sign 双门) ----------------
const WATER_CENTER = { simmer: 3500, rolling: 6500 }; // 将沸窗/滚透窗开窗时刻(ms)
const WATER_CLOSE_AT = 9500; // 窗闭(滚老嘶鸣)
const WATER_AUTO_AT = 11000; // 无出手自动过老
const STEEP_TARGET = { fast: 7000, mid: 11000, long: 15000 };
const STEEP_PRE_TICK = 2500; // 目标前 tick_slow 起播点
const STEEP_AUTO_PAD = 4000; // 窗后自动出汤
const TEAWARE_BONUS = 800; // 新茶具:候汤/闷泡窗各 +0.8s
const NIGHT1_MULT = 1.3; // 首晚窗口 ×1.3(§5)
const CLOSE_GRACE_MS = 800; // §4.4 打烊死窗
const SERVE_GAP_MS = 1200; // §13 客人间隙自动推进
const RENT = 20;
/** 房租夜(§14-4 修订记录:本章 N3/N5;N6+ 每 3 晚)。 */
const isRentNight = (n) => n === 3 || n === 5 || (n >= 6 && n % 3 === 0);
const TIP_GOOD = 2;
const REP_GAIN = { good: 1, plain: 0, wrong: -1 };
// ---------------- 数据单源(20 号 §6.1) ----------------
const TEAS = {
longjing: { name: 'tea_longjing', price: 6, water: 'simmer', steep: 'fast' },
moli: { name: 'tea_moli', price: 4, water: 'rolling', steep: 'fast' },
tieguanyin: { name: 'tea_tieguanyin', price: 8, water: 'rolling', steep: 'mid' },
dianhong: { name: 'tea_dianhong', price: 5, water: 'rolling', steep: 'long' },
puer: { name: 'tea_puer', price: 7, water: 'rolling', steep: 'long', cabinet: true },
yinzhen: { name: 'tea_yinzhen', price: 10, water: 'simmer', steep: 'mid', cabinet: true },
};
const SHELF_BASE = ['longjing', 'moli', 'tieguanyin', 'dianhong'];
const SHELF_CABINET = ['puer', 'yinzhen'];
const GUESTS = {
zhou: { sig: 'sig_zhou', enter: 'zhou_enter',
orders: ['zhou_o1', 'zhou_o2', 'zhou_o3'], teaByVisit: ['longjing', 'dianhong', 'longjing'] },
su: { sig: 'sig_su', enter: 'su_enter',
orders: ['su_o1', 'su_o2', 'su_o3'], teaByVisit: ['dianhong', 'moli', 'dianhong'] },
shi: { sig: 'sig_shi', enter: 'shi_enter',
orders: ['shi_o1', 'shi_o2', 'shi_o3'], teaByVisit: ['moli', 'longjing', 'yinzhen'] },
bai: { sig: 'sig_bai', enter: 'bai_enter',
orders: ['bai_o1', 'bai_o2', 'bai_o3'], teaByVisit: ['tieguanyin', 'puer', 'puer'] },
he: { sig: 'sig_he', enter: 'he_enter',
orders: ['he_o1', 'he_o2', 'he_o3'], teaByVisit: ['longjing', 'puer', 'yinzhen'] },
};
const AMBIENT = {
postman: { order: 'amb_1', tea: 'moli' },
embroider: { order: 'amb_2', tea: 'longjing' },
drunk: { order: 'amb_3', tea: 'dianhong' },
};
const NIGHTS = [
['zhou', 'amb:postman', 'su', 'shi'],
['zhou', 'bai', 'he', 'shi'],
['zhou', 'su', 'bai', 'amb:drunk'],
['he', 'shi', 'su', 'amb:postman'],
['bai', 'he', 'amb:embroider', 'amb:postman'],
];
const AMB_POOL = ['amb:postman', 'amb:embroider', 'amb:drunk'];
const UPGRADES = [
{ id: 'cabinet', voice: 'upg_cabinet', cost: 15 },
{ id: 'teaware', voice: 'upg_teaware', cost: 12 },
{ id: 'sign', voice: 'upg_sign', cost: 20, repReq: 12, needDebtFree: true },
];
/** 数据单源导出(playtest 矩阵不变式断言用,§11-3)。 */
export const DATA = { TEAS, GUESTS, AMBIENT, NIGHTS, UPGRADES, STEEP_TARGET, WATER_CENTER, REP_GAIN };
/** mulberry32(§6.1/§13-14:N6+ 轮换序 seed 可复现)。 */
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 档句映射(§7.3;播报与 summary 共用,单源)。 */
const stGuestId = (n) => 'st_guest_' + Math.min(5, Math.max(1, n));
const stRepId = (rep) => (rep >= 20 ? 'st_rep_4' : rep >= 12 ? 'st_rep_3' : rep >= 5 ? 'st_rep_2' : 'st_rep_1');
const stPurseId = (p) => (p >= 25 ? 'st_purse_3' : p >= 10 ? 'st_purse_2' : 'st_purse_1');
const coinId = (income) => (income >= 25 ? 'close_coin_3' : income >= 15 ? 'close_coin_2' : 'close_coin_1');
/** summary/店铺概况用的口碑档名(§14-3 修订记录:取各档措辞短式)。 */
const REP_BAND_NAME = (rep) => (rep >= 20 ? '一盏难求' : rep >= 12 ? '小有名气' : rep >= 5 ? '有了回头客' : '还没立起来');
const nightId = (n) => (n <= 5 ? 'night_' + n : 'night_more');
/** sfx 时长表(秒,调度记账用;资产实测见 gen 脚本)。 */
const SFX_DUR = {
door_bell: 1.4, steps_in: 3.7, steps_out: 3.7,
sig_zhou: 0.45, sig_su: 0.5, sig_shi: 0.6, sig_bai: 0.3, sig_he: 0.55,
tea_jar: 0.35, tea_scoop: 0.35, water_pour: 2.5, boil_blip: 0.3,
boil_simmer: 1.6, boil_roar: 2.0, boil_over: 1.2, kettle_lift: 0.5,
steep_rustle: 2.0, tick_slow: 0.15, tick_fast: 0.12, pour_cup: 2.5,
coin_soft: 0.35, coin_stack: 0.6, rep_chime: 0.55, rep_fall: 0.55,
lamp_light: 0.25, lamp_off: 0.45, shop_buy: 0.8, upg_install: 0.6,
};
// 等待点静默阈值(20 号附录 C §5;water/steep 窗内只 earcon,见 _timingWp)
const SILENCE = {
intro: [6000, 15000],
reopen: [12000, 30000],
tea: [12000, 30000],
timing: [6000, 10000], // 只会降为 chime(full/short 置空,分层铁律)
close: [15000, 35000],
menu: [20000, 45000],
};
// 无效操作解释(原则 7:每个「状态 × 手势」只解释一次;null=只给回执)
const INVALID_LINES = {
'locked:up': 'voice/inv_up_locked',
'locked:down': 'voice/inv_down_locked',
'locked:longpress': 'voice/gd_invalid',
'TITLE:swipe': 'voice/gd_invalid',
'OPENING:swipe': 'voice/gd_invalid',
'SERVE:swipe': 'voice/inv_serve_swipe',
'CLOSE:swipe': 'voice/gd_invalid',
// WATER/STEEP:时机窗内禁止解释语音(分层铁律),只给 invalid 回执
};
export class TeahouseGame extends Game {
/**
* @param {WebAudioEngine} audio
* @param {HTMLElement} root
* @param {object} [opts]
* @param {number} [opts.seed] PRNG 种子(N6+ 氛围客轮换;缺省 Date.now())
*/
constructor(audio, root, opts = {}) {
super(audio, root);
// ---- 26 号交互层六件套 ----
this.receipts = new Receipts(audio);
this.floor = new Floor(audio);
this.confusion = new Confusion({
onConfused: (lvl) => { const wp = this.floor.waitPoint; if (wp) wp.takeover(lvl); },
});
this.invalidHints = new InvalidHints().bind(this.floor);
this.ladder = new GestureLadder();
this.guide = new Guide(audio); // 仅用 state() 状态签名 earcon(menu/settle/danger);看门狗由等待点接管
this.seed = opts.seed ?? Date.now();
// 宏观状态
this.state = 'TITLE'; // TITLE/OPENING/SERVE/BREW/CLOSE
this._menuOpen = false;
this._menuIdx = 0;
this._goingHome = false;
// 跨晚进度(meta)与当晚进度(run),§10 存档结构
this.meta = { drillDone: false, nightsClosed: 0, rep: 0, purse: 0, debt: 0,
upgrades: [], chapterEnd: false };
this.run = null; // { night, guestIdx, results[], incomeTonight, guestStory{}, rentCharged }
this.story = {}; // 弧线方向值累计(跨晚持久化,结局映射单源)
this._visits = {}; // 本档每客访问计数(跨晚持久化)
// 自动流续接(发言权层裁定「滑动=打断并执行」:被杀的自动链在这里保住续接点)
this._flowSeq = 0;
this._flow = null; // { token, ids, cont } 当前自动语音链
this._menuResume = null; // 菜单挂起时的续链点 { ids, from, cont }
// 客人/泡茶
this._guest = null; // { kind:'arc'|'amb', id, visit, tea, orderId, sig }
this._brew = null; // { sub:'TEA'|'WATER'|'STEEP', teaIdx, tea, water, steep, ... }
this._brewEvents = [];
this._brewBase = 0;
this._brewElapsed = 0;
this._tickLog = []; // 断言用:{ id, t } sfx 调度记录
// 手势阶梯挂载
this._goalMet = false; // 第一盏茶已送出 → 阶段 1(上滑)
this._teachQueue = null; // 待挂到下一段链尾的解锁教学句(§4.3 阶梯)
// 教学
this._drill = null; // { step, misses, timers:[] }
// CLOSE
this._graceUntil = 0; // 800ms 死窗(§4.4)
this._closeChainActive = false;
this._closeDone = false;
this._closeChain = [];
this._resetArm = 0; // TITLE 右滑二次确认
// 菜单置办二级
this._shopOpen = false;
this._shopIdx = 0;
this._schedToken = 0; // 自动推进调度令牌(测试可取消)
this._playLog = []; // 节奏日志(playtest 断言用)
}
// ================================================================ Game 契约
start() {
this.audio.prewarm(Object.keys(SFX_DUR).map((k) => 'sfx/' + k));
this._loadProgress();
this._pullServerProgress();
this._enterTitle();
}
stop() {
this.audio.setRateBoostEnabled(true);
this._schedToken++;
this._clearBrewTimers();
if (this._drill) this._clearDrillTimers();
this.floor.interrupt();
this.audio.stopAll();
}
/** pipeline 收口后的游戏处理;返回 'ok' | 'hold' | 'invalid' | 'silent-ok'。 */
onGesture(g) {
if (this._goingHome) return 'invalid';
if (this._menuOpen) return this._onMenuGesture(g);
switch (this.state) {
case 'TITLE': return this._onTitleGesture(g);
case 'OPENING': return this._onOpeningGesture(g);
case 'SERVE': return this._onServeGesture(g);
case 'BREW': return this._onBrewGesture(g);
case 'CLOSE': return this._onCloseGesture(g);
default: return 'invalid';
}
}
/** 无效操作解释键(原则 7;WATER/STEEP 窗内返回 null=只给回执,分层铁律)。 */
stateName() {
if (this._menuOpen) return this._shopOpen ? 'SHOP' : 'MENU';
if (this.state === 'BREW' && this._brew) {
return this._brew.sub === 'TEA' ? 'TEA' : this._brew.sub; // WATER / STEEP
}
return this.state; // TITLE / OPENING / SERVE / CLOSE
}
invalidHint(state, g) {
if (state === 'locked') return INVALID_LINES['locked:' + g] || null;
if (g === 'left' || g === 'right') {
if (state === 'MENU' || state === 'SHOP' || state === 'TEA') return null; // 合法操作
if (state === 'WATER' || state === 'STEEP') return null; // 窗内不解释
return INVALID_LINES[state + ':swipe'] || null;
}
if (g === 'longpress') return INVALID_LINES['locked:longpress'];
return null;
}
// ================================================================ 工具:自动语音流(发言权层杀链后保住续接)
/**
* 自动流语音链:包装 floor.speak。发言权层裁定「滑动/上滑=打断并执行」(§4.1),
* floor 会直接杀链且不触发 onDone——营业态的自动推进(客人→泡茶→打烊)在这里
* 保住续接点:被打断后由当前手势的消费方调 _popFlow() 决定推进方式。
*/
_speakFlow(ids, { critical = false, kind, onDone = null } = {}) {
const token = ++this._flowSeq;
const vids = ids.map((id) => (id.startsWith('voice/') ? id : 'voice/' + id));
this._flow = { token, ids: vids, cont: onDone };
this.floor.speak(vids, {
critical,
kind,
onDone: () => {
if (this._flow && this._flow.token === token) this._flow = null;
if (onDone) onDone();
},
});
}
/** 取走被中断自动流的续接点(调用方负责推进:立即跑 / 排下一拍 / 链尾接)。 */
_popFlow() {
const f = this._flow;
this._flow = null;
return f ? f.cont : null;
}
/** 间隔后推进(调度令牌保护:新流程启动即作废)。 */
_afterMs(ms, fn) {
const tok = ++this._schedToken;
setTimeout(() => { if (tok === this._schedToken) fn(); }, ms);
}
_sfx(id, opts = {}) {
this._tickLog.push({ id, t: performance.now() });
this.audio.play('sfx/' + id, opts);
}
_shelf() {
const s = SHELF_BASE.slice();
if (this.meta.upgrades.includes('cabinet')) s.push(...SHELF_CABINET);
return s;
}
// ================================================================ TITLE(含断点复述问询)
_enterTitle() {
this.state = 'TITLE';
this._resetArm = 0;
this.audio.play('bgm/menu');
this.guide.state('menu');
if (this.run) {
// 断点复述(§4.12):回来先问继续还是重开(提问句 critical:抢拍单击给 busy)
this._speakFlow(['sys_continue_ask'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
} else {
// §4.10 第一句让玩家动手;已 drillDone 玩家开场「点一下开始」
const first = this.meta.drillDone ? 'sys_tap_start' : 'sys_first_tap';
this.floor.speak(['voice/' + first], {
critical: true,
onDone: () => this.floor.yieldTurn(this._introWp()),
});
}
}
_onTitleGesture(g) {
const hasRun = !!this.run;
// 复述链播中被滑动/上滑打断:保住续接点。注意管道先经 floor.handleInput
// 杀链(state→BUSY,speaking 已 false),所以这里无条件取走自动流,不看
// floor.speaking——有自动流在 = 刚被打断。
const popCont = () => this._popFlow();
switch (g) {
case 'tap': {
if (hasRun) this._continueRun();
else this._newGame();
return 'ok';
}
case 'left': {
const cont = popCont();
if (cont) cont();
return cont ? 'ok' : 'invalid';
}
case 'right': {
if (!hasRun) return 'invalid';
const cont = popCont();
if (cont) { cont(); return 'ok'; } // 复述链中右滑=打断推进(重开确认只在等问询时)
if (this._resetArm && performance.now() < this._resetArm) {
this._resetRun();
return 'ok';
}
this._resetArm = performance.now() + 3000;
this._speakFlow(['sys_reset_ask'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
return 'ok';
}
case 'up': {
// 店铺概况(口碑档 → 存款档 → 已置办句)
const cont = popCont();
const ids = [stRepId(this.meta.rep), stPurseId(this.meta.purse)];
for (const u of UPGRADES) if (this.meta.upgrades.includes(u.id)) ids.push(u.voice);
this.floor.speak(ids.map((i) => 'voice/' + i), {
onDone: () => {
if (cont) cont();
else if (this.floor.waitPoint) this.floor.resumeTurn();
},
});
return 'hold';
}
case 'down':
this._openMenu();
return 'ok';
case 'doubletap': {
// 阶段 3 重听:回到开工问询
const cont = popCont();
const again = hasRun ? 'sys_continue_ask' : (this.meta.drillDone ? 'sys_tap_start' : 'sys_first_tap');
this.floor.speak(['voice/sys_say_again', 'voice/' + again], {
kind: 'hint',
onDone: () => {
if (cont) cont();
else if (this.floor.waitPoint) this.floor.resumeTurn();
},
});
return 'hold';
}
default:
return 'invalid';
}
}
_resetRun() {
this.run = null;
this._visits = {};
this.story = {};
this.meta = { drillDone: false, nightsClosed: 0, rep: 0, purse: 0, debt: 0,
upgrades: [], chapterEnd: false };
this._writeProgress(true);
this._newGame();
}
_newGame() {
this._visits = {};
this.run = { night: 1, guestIdx: 0, results: [], incomeTonight: 0,
guestStory: {}, rentCharged: false, inBrew: false };
// 原则 2:状态声第一次出现配一句解释(通用句,与 storyline 一致;critical 防杀链失速)
this.floor.speak(['voice/sys_turn_teach'], {
critical: true,
onDone: () => this._enterOpening(),
});
}
_continueRun() {
// 客人粒度恢复(§10):局面复述链 → 当前客人从进门链重放
const r = this.run;
const ids = [stGuestId(r.guestIdx + 1), stRepId(this.meta.rep)];
if (r.inBrew) {
// BREW 中断:复述含需求重播(sig=sfx);客人信息从当晚客序单源推导
const info = this._guestInfo(this._schedule(r.night)[r.guestIdx]);
if (info.kind === 'arc') this._sfx(info.sig);
ids.push(info.orderId);
}
ids.push('recap_go');
this._speakFlow(ids, { onDone: () => this._startGuest(r.guestIdx) });
}
// ================================================================ OPENING
_enterOpening() {
this.state = 'OPENING';
this.audio.play('bgm/serve');
this._sfx('lamp_light');
const n = this.run.night;
const ids = [];
// 解锁教学句(§4.3 阶梯)挂链首:先说一句,马上用一次
if (this._teachQueue) { ids.push(this._teachQueue); this._teachQueue = null; }
ids.push(nightId(n));
// 首晚 intro 只留叙事两句;旧 intro_3 规则念白废除(§4.10 砍规则念白)
if (n === 1 && !this.meta.drillDone) ids.push('intro_1', 'intro_2');
this._speakFlow(ids, {
onDone: () => this._afterMs(SERVE_GAP_MS, () => this._startGuest(0)),
});
}
_onOpeningGesture(g) {
// 管道杀链后 speaking 已 false:续接点无条件取(有自动流 = 刚被打断)
const popCont = () => this._popFlow();
switch (g) {
case 'tap':
// 跳句(管道下 floor 已 consumed;直连路径兜底)
if (this.floor.speaking) { this.audio.skipVoice(); return 'ok'; }
return 'invalid';
case 'left':
case 'right':
// 打断并推进(发言权层已杀链:跳过余句,直接迎下一位客人)
{
const cont = popCont();
if (cont) { cont(); return 'ok'; }
return 'invalid';
}
case 'up': {
const cont = popCont();
this._statusChain(cont);
return 'ok';
}
case 'down':
this._openMenu();
return 'ok';
case 'doubletap': {
const cont = popCont();
this.floor.speak(['voice/sys_say_again', 'voice/' + nightId(this.run.night)], {
kind: 'hint',
onDone: () => {
if (cont) cont();
else if (this.floor.waitPoint) this.floor.resumeTurn();
},
});
return 'hold';
}
default:
return 'invalid';
}
}
// ================================================================ 客人系统
/** N6+ 轮换序(§6.1 nightsEndless;seed 可复现,深相等断言用)。 */
_schedule(night) {
if (night <= NIGHTS.length) return NIGHTS[night - 1].slice();
const rng = mulberry32((this.seed + night * 7919) >>> 0);
const pool = AMB_POOL.slice();
const out = [];
for (let i = 0; i < 4; i++) {
const k = Math.floor(rng() * pool.length);
out.push(pool.splice(k, 1)[0]);
}
return out;
}
_guestInfo(slotId) {
if (slotId.startsWith('amb:')) {
const id = slotId.slice(4);
return { kind: 'amb', id, orderId: AMBIENT[id].order, tea: AMBIENT[id].tea,
sig: null, visit: 0 };
}
const g = GUESTS[slotId];
const visit = this._visits[slotId] || 0;
return { kind: 'arc', id: slotId, orderId: g.orders[visit], tea: g.teaByVisit[visit],
sig: g.sig, enter: g.enter, visit };
}
_startGuest(idx) {
const plan = this._schedule(this.run.night);
this.run.guestIdx = idx;
this.run.inBrew = false;
this._guest = this._guestInfo(plan[idx]);
this.state = 'SERVE';
this.audio.play('bgm/serve');
// 客人链 = door_bell → steps_in → sig → enter → 点单(氛围客=旁白转述 amb_N)
const g = this._guest;
const ids = [];
this._sfx('door_bell');
this._sfx('steps_in');
if (g.kind === 'arc') { this._sfx(g.sig); ids.push(g.enter, g.orderId); }
else ids.push(g.orderId);
this._speakFlow(ids, {
onDone: () => this._afterMs(SERVE_GAP_MS, () => this._enterBrew()),
});
}
_onServeGesture(g) {
if (this._drill) return this._onDrillGesture(g); // 教学 D5 在堂前等上滑
// 管道杀链后 speaking 已 false:续接点无条件取(有自动流 = 刚被打断)
const popCont = () => this._popFlow();
switch (g) {
case 'tap':
if (this.floor.speaking) { this.audio.skipVoice(); return 'ok'; } // 跳句(直连兜底)
return 'invalid'; // 链间空档(BUSY busy 回执已覆盖)
case 'left':
case 'right':
// 打断并推进:被杀链的收尾(反应链的币声/口碑档/迎下一位)立即续上
{
const cont = popCont();
if (cont) { cont(); return 'ok'; }
return 'invalid';
}
case 'up': {
this._statusChain(popCont());
return 'ok';
}
case 'down':
this._openMenu();
return 'ok';
case 'doubletap': {
// 阶段 3 重听点单(点单可重听,20 号附录 C §3)
this._replayOrder(popCont());
return 'ok';
}
default:
return 'invalid';
}
}
/** 上滑状态链(原则 3;客人未走时第一段=sig+「再说一遍:」+点单句,需求随时可查——
* 堂前点单链与灶上 BREW 同口径;after=打断了自动流时的续接)。 */
_statusChain(after = null) {
this.confusion.recordReplay(); // 「我在哪」也算反复重听信号(§4.5)
const ids = [];
if ((this.state === 'BREW' || this.state === 'SERVE') && this._guest) {
if (this._guest.kind === 'arc') this._sfx(this._guest.sig);
ids.push('sys_say_again', this._guest.orderId); // 再说一遍:+ 点单句(原则 9)
}
if (this.run) ids.push(stGuestId(this.run.guestIdx + 1));
ids.push(stRepId(this.meta.rep), stPurseId(this.meta.purse));
this._speakFlow(ids, {
onDone: () => {
if (after) after();
else if (this.floor.waitPoint) this.floor.resumeTurn();
else if (this.state === 'BREW' && this._brew && this._brew.sub === 'TEA'
&& !this._brew.judged && !this._drill) {
this.floor.yieldTurn(this._teaWp()); // 茶名句被打断后续接:交回选茶等待点
}
},
});
}
/** 双击重听点单(阶段 3;同状态链口径,只回到点单句)。 */
_replayOrder(after = null) {
if (!this._guest) return;
this.confusion.recordReplay();
if (this._guest.kind === 'arc') this._sfx(this._guest.sig);
this._speakFlow(['sys_say_again', this._guest.orderId], {
onDone: () => { if (after) after(); },
});
}
// ================================================================ BREW(选茶 / 候汤 / 闷泡)
_enterBrew() {
this.state = 'BREW';
this.audio.play('bgm/brew');
this.guide.state('battle');
this.audio.setRateBoostEnabled(false); // E3:BREW 全态关加速(长按不快进)
this._brew = { sub: 'TEA', teaIdx: 0, tea: null, water: null, steep: null,
judged: false };
this.run.inBrew = true;
this._speakFlow(['brew_start'], {
critical: true, // 单句短链:critical 防滑动打断后教学/预览失联
onDone: () => {
if (this.meta.drillDone) this._previewTea();
else this._startDrill(0);
},
});
}
_previewTea() {
const shelf = this._shelf();
this._brew.teaIdx = 0;
this._sfx('tea_jar');
this._speakFlow([TEAS[shelf[0]].name], {
onDone: () => this.floor.yieldTurn(this._teaWp()),
});
}
_onBrewGesture(g) {
const b = this._brew;
if (!b) return 'invalid';
if (this._drill) return this._onDrillGesture(g); // 教学接管
if (b.sub === 'TEA') {
switch (g) {
case 'left':
case 'right':
this._cycleTea(g === 'right' ? 1 : -1);
return 'silent-ok'; // tea_jar + 茶名句即反馈
case 'tap':
this._selectTea();
return 'silent-ok'; // tea_scoop 即反馈
case 'up':
this._statusChain();
return 'hold';
case 'down':
this._openMenu();
return 'ok';
case 'doubletap':
// 阶段 3 重听茶名句
this._replayTeaName();
return 'hold';
default:
return 'invalid';
}
}
// WATER / STEEP:时机步——单击=提壶/出汤(20 号附录 C §1 核心动作)
switch (g) {
case 'tap':
case 'doubletap': // 双击=按单击(时机步不另绑重听,附录 C §3)
if (b.sub === 'WATER' && b.water === null) this._waterJudge(false);
else if (b.sub === 'STEEP' && b.steep === null) this._steepJudge(false);
else if (this.floor.speaking) {
// 已判定、归因句播中被抢点:续上收尾(进闷泡/上茶),不判第二次
const cont = this._popFlow();
if (cont) cont();
}
return 'silent-ok'; // kettle_lift / pour_cup 即反馈
case 'left':
case 'right': {
// 不判错步(§3.1);窗内无解释语音(分层铁律)。归因句播中被滑:续上收尾
// (管道杀链后 speaking 已 false,续接点无条件取)
const cont = this._popFlow();
if (cont) cont();
return 'invalid';
}
case 'up':
this._statusChain(this._popFlow()); // 状态;水声/时间线照走
return 'hold';
case 'down':
this._openMenu(); // 冻结计时
return 'ok';
default:
return 'invalid';
}
}
_cycleTea(dir) {
const shelf = this._shelf();
this._brew.teaIdx = (this._brew.teaIdx + dir + shelf.length) % shelf.length;
this._sfx('tea_jar');
this._speakFlow([TEAS[shelf[this._brew.teaIdx]].name], {
onDone: () => this.floor.yieldTurn(this._teaWp()),
});
}
_replayTeaName() {
const shelf = this._shelf();
this.confusion.recordReplay();
this._speakFlow(['sys_say_again', TEAS[shelf[this._brew.teaIdx]].name], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
_selectTea(startWater = true) {
const shelf = this._shelf();
this._brew.tea = shelf[this._brew.teaIdx];
this._sfx('tea_scoop');
if (startWater) this._startWater();
}
_startWater() {
const b = this._brew;
b.sub = 'WATER';
b.water = null;
this._startBrewTimeline(this._waterEvents());
this.floor.yieldTurn(this._timingWp('water')); // 等待点+内容音(水声即状态)
}
_waterJudge(auto, afterWater = null) {
const b = this._brew;
if (!b || b.water !== null) return;
const now = auto ? WATER_AUTO_AT : this._brewNow();
const [lo, hi] = this._waterWindow();
b.water = (!auto && now < lo) ? 'early' : (now <= hi ? 'ok' : 'late');
if (auto) b.water = 'late';
this._clearBrewTimers();
this._sfx('kettle_lift');
this.floor.settleWaitPoint(true); // 时机等待点完成(自动过老路径无手势,手动补)
// 判定即归因(真话律,§4.9 两秒内):water_early/ok/late 按真实时机
this._speakFlow(['water_' + b.water], {
onDone: () => { if (afterWater) afterWater(); else this._startSteep(); },
});
}
_startSteep() {
const b = this._brew;
b.sub = 'STEEP';
b.steep = null;
this._startBrewTimeline(this._steepEvents());
this.floor.yieldTurn(this._timingWp('steep'));
}
_steepJudge(auto, afterSteep = null) {
const b = this._brew;
if (!b || b.steep !== null) return;
const [lo, hi] = this._steepWindow();
const now = auto ? hi + STEEP_AUTO_PAD : this._brewNow();
b.steep = (!auto && now < lo) ? 'early' : (now <= hi ? 'ok' : 'late');
if (auto) b.steep = 'late';
this._clearBrewTimers();
this._sfx('pour_cup');
this.floor.settleWaitPoint(true);
this._speakFlow(['steep_' + b.steep], {
onDone: () => { if (afterSteep) afterSteep(); else this._settleGuest(); },
});
}
// ---------------- 时机窗与时间线(§3.1/§3.2,判定核心不动) ----------------
/** 时机窗(§3.1;首晚 ×1.3、茶具 +0.8s,修订口径以 §7.4 为准)。 */
_waterWindow() {
const m = (this.run && this.run.night === 1) ? NIGHT1_MULT : 1;
const half = (1500 + (this.meta.upgrades.includes('teaware') ? TEAWARE_BONUS : 0)) * m;
const c = WATER_CENTER[TEAS[this._brew.tea].water];
return [c - half, c + half];
}
_steepWindow() {
const m = (this.run && this.run.night === 1) ? NIGHT1_MULT : 1;
const t = STEEP_TARGET[TEAS[this._brew.tea].steep];
return [t - 1000 * m, t + (1400 + (this.meta.upgrades.includes('teaware') ? TEAWARE_BONUS : 0)) * m];
}
/** 构建候汤事件表(§3.2;全部相对 ms)。 */
_waterEvents() {
const ev = [{ t: 0, id: 'water_pour' }];
for (let t = 2000; t < 3500; t += 700) ev.push({ t, id: 'boil_blip' }); // 温水段 700ms
ev.push({ t: 3500, id: 'boil_simmer' }); // 将沸低鸣
for (let t = 3500; t <= 6300; t += 350) ev.push({ t, id: 'boil_blip' }); // 将沸段 350ms
ev.push({ t: 6500, id: 'boil_roar' }); // 滚透连片
ev.push({ t: 8500, id: 'boil_roar' });
ev.push({ t: WATER_CLOSE_AT, id: 'boil_over' }); // 滚老嘶鸣
ev.push({ t: WATER_AUTO_AT, auto: 'water' }); // 无出手 → 自动过老
return ev;
}
/** 构建闷泡事件表(§3.2;目标闷时由茶参数决定)。 */
_steepEvents() {
const t = STEEP_TARGET[TEAS[this._brew.tea].steep];
const [lo, hi] = this._steepWindow();
const ev = [{ t: 0, id: 'water_pour' }, { t: 2000, id: 'steep_rustle' }];
for (let k = t - STEEP_PRE_TICK; k < lo - 1; k += 1000) ev.push({ t: k, id: 'tick_slow' });
for (let k = Math.max(lo, t - STEEP_PRE_TICK); k <= hi; k += 600) ev.push({ t: k, id: 'tick_fast' });
ev.push({ t: hi + STEEP_AUTO_PAD, auto: 'steep' }); // 无出手 → 自动出汤
return ev;
}
_startBrewTimeline(events) {
this._brewEvents = events.map((e) => ({ ...e, done: false, timer: null }));
this._brewBase = performance.now();
this._brewElapsed = 0;
this._scheduleBrewEvents();
}
_scheduleBrewEvents() {
for (const e of this._brewEvents) {
if (e.done || e.timer !== null) continue;
const wait = e.t - this._brewElapsed;
if (wait < 0) continue;
e.timer = setTimeout(() => {
e.timer = null;
e.done = true;
if (this._menuOpen) return; // 冻结中(不应到达:定时器已清)
if (e.auto === 'water') { this._waterJudge(true); return; }
if (e.auto === 'steep') { this._steepJudge(true); return; }
this._sfx(e.id);
}, wait);
}
}
_freezeBrewTimeline() {
this._brewElapsed = performance.now() - this._brewBase;
this._clearBrewTimers();
}
_resumeBrewTimeline() {
this._brewBase = performance.now();
this._scheduleBrewEvents();
}
_clearBrewTimers() {
for (const e of this._brewEvents) {
if (e.timer !== null) { clearTimeout(e.timer); e.timer = null; }
}
}
/** 当前 BREW 子相位已进行的毫秒(判定输入用)。 */
_brewNow() {
if (!this._brew) return 0;
if (this._menuOpen) return this._brewElapsed;
return this._brewElapsed + (performance.now() - this._brewBase);
}
// ---------------- 判定三元组 → 三档结果(§3.1/§6.2/§7.2,单源不动) ----------------
/** 判定三元组 → 三档结果(§3.1):茶错⇒wrong;茶对∧双 ok⇒good;否则 plain。 */
_result() {
const b = this._brew;
const teaWrong = b.tea !== this._guest.tea;
if (teaWrong) return 'wrong';
return (b.water === 'ok' && b.steep === 'ok') ? 'good' : 'plain';
}
_payOf(result) {
const price = TEAS[this._guest.tea].price;
return result === 'good' ? price + TIP_GOOD : result === 'plain' ? price : 0;
}
/** 账目结算(运行值先算齐,链句只读它——播报值=运行值,§7.3)。 */
_settleEconomy(result) {
const g = this._guest;
const pay = this._payOf(result);
this.run.incomeTonight += pay;
const repBefore = this.meta.rep;
this.meta.rep = Math.max(0, this.meta.rep + REP_GAIN[result]);
if (g.kind === 'arc') {
this.story[g.id] = (this.story[g.id] || 0) + REP_GAIN[result];
this._visits[g.id] = (this._visits[g.id] || 0) + 1;
}
return { pay, repBefore, bandChanged: stRepId(repBefore) !== stRepId(this.meta.rep) };
}
_reactionIds(g, result) {
if (g.kind === 'arc') {
if (g.visit >= 2) {
const sum = this.story[g.id] || 0; // 三晚累计(跨晚持久化)
const e = sum >= 2 ? 'a' : sum >= -1 ? 'b' : 'c';
return [`${g.id}_e_${e}`];
}
return [`${g.id}_r${g.visit + 1}_${result}`];
}
return [`amb_react_${result}`];
}
/** 反应链(serve_link → sig → 反应/结局 → bridge)+ 币声 + 口碑跨档。 */
_reactionChain(onDone, eco = null) {
const g = this._guest;
const result = this.run.results[this.run.results.length - 1];
const repBefore = eco ? eco.repBefore : this.meta.rep;
const bandChanged = eco ? eco.bandChanged : false;
const ids = ['serve_link'];
if (g.kind === 'arc') { this._sfx(g.sig); ids.push(...this._reactionIds(g, result), 'bridge_' + result); }
else ids.push(...this._reactionIds(g, result));
const finish = () => {
if (result === 'good') this._sfx('coin_stack');
else if (result === 'plain') this._sfx('coin_soft');
// wrong:免单静默(无币声)
if (bandChanged) {
this._sfx(this.meta.rep > repBefore ? 'rep_chime' : 'rep_fall');
this._speakFlow([stRepId(this.meta.rep)], { onDone });
} else if (onDone) {
onDone();
}
};
this._speakFlow(ids, { onDone: finish });
}
/** 客人结算(§6.2/§7.2);drill 模式只走到 res 句,D4 单击后接反应链(§5)。 */
_settleGuest() {
const b = this._brew;
const result = this._result();
b.judged = true;
this.run.results.push(result);
this.run.inBrew = false;
const eco = this._settleEconomy(result);
this._saveAuto();
if (this._drill) {
// 教学路径:res 句(关键句)播完停住等 D4(单击端茶)
this._speakFlow(['res_' + result], {
critical: true,
onDone: () => {
this._drillGoto(4);
this._speakFlow(['tut_serve'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._drillWp()),
});
},
});
return;
}
this.state = 'SERVE'; // 判定链播完进反应相位
this.audio.play('bgm/serve');
this.guide.state('explore');
this.audio.setRateBoostEnabled(true); // 出灶恢复加速开关
// res_* 关键句不可跳(busy)→ 反应链可跳
this._speakFlow(['res_' + result], {
critical: true,
onDone: () => this._reactionChain(() => this._nextGuest(), eco),
});
}
_nextGuest() {
this._brew = null;
const plan = this._schedule(this.run.night);
if (this.run.guestIdx + 1 < plan.length) {
this._afterMs(SERVE_GAP_MS, () => this._startGuest(this.run.guestIdx + 1));
} else {
this._afterMs(SERVE_GAP_MS, () => this._enterClose());
}
}
// ================================================================ CLOSE(打烊结算链独占)
_enterClose() {
this.state = 'CLOSE';
this.audio.setRateBoostEnabled(true);
this._graceUntil = performance.now() + CLOSE_GRACE_MS;
this._closeChainActive = true;
this._closeDone = false;
this.audio.play('bgm/menu', { volume: 0.5 });
this.guide.state('settle');
// ---- 经济结算(§7.2;先入账 → 还债 → 房租) ----
const income = this.run.incomeTonight;
this.meta.purse += income;
const chain = ['close_open', coinId(income)];
let rentShort = false;
let rentDue = isRentNight(this.run.night) && !this.run.rentCharged;
const hadDebt = this.meta.debt > 0;
if (hadDebt) {
const payDebt = Math.min(this.meta.purse, this.meta.debt);
this.meta.purse -= payDebt;
this.meta.debt -= payDebt;
if (this.meta.debt === 0) chain.push('debt_clear');
else chain.push('st_debt_1');
}
if (rentDue) {
this.run.rentCharged = true;
chain.push('rent_due');
if (this.meta.purse >= RENT) {
this.meta.purse -= RENT;
chain.push('rent_ok');
} else {
const short = RENT - this.meta.purse;
this.meta.purse = 0;
this.meta.debt += short;
rentShort = true;
chain.push('rent_short'); // 不可跳
}
}
if (this.meta.upgrades.includes('sign')) {
const before = this.meta.rep;
this.meta.rep = Math.max(0, this.meta.rep + 1);
if (stRepId(before) !== stRepId(this.meta.rep)) {
this._sfx('rep_chime');
chain.push(stRepId(this.meta.rep));
}
}
const isN5 = this.run.night === 5 && !this.meta.chapterEnd;
if (isN5) chain.push('chapter_end');
this._closeChain = chain;
// 结算链独占(§4.4 链锁 + 800ms 死窗):整链 critical,手势一律 busy(吞)
this._speakFlow(chain, {
critical: true,
onDone: () => {
this._closeChainActive = false;
this._closeDone = true;
if (isN5) this.meta.chapterEnd = true;
this._metaAfterNight();
this._speakFlow(['settle_night'], { // 链锁解除:单击睡下(言行一致)
onDone: () => { this._saveAuto(); this.floor.yieldTurn(this._closeWp()); },
});
if (rentShort) { // danger 签名当刻(每欠账事件 1 次)
this.guide.state('danger');
vibrate('warning');
}
},
});
}
/** 打烊后 run 清空、meta.nightsClosed+1(§10 summary 口径)。 */
_metaAfterNight() {
this.meta.nightsClosed = this.run.night;
const keepVisits = this._visits;
this.run = null;
this._visits = keepVisits; // 弧线访问计数跨晚保留
}
_onCloseGesture(g) {
// 链锁:结算链播中手势一律吞掉(floor critical 已给 busy;直连路径兜底)
if (this._closeChainActive) return 'invalid';
// 死窗(§4.4):ghost 手势不算输入
if (performance.now() < this._graceUntil) return 'invalid';
// 管道杀链后 speaking 已 false:续接点无条件取(有自动流 = 刚被打断)
const popCont = () => this._popFlow();
switch (g) {
case 'tap':
case 'doubletap':
this._sleepNext();
return 'silent-ok'; // lamp_off 即反馈
case 'up': {
// 重听账目链(st_rep → st_purse → 未置办清单)
const cont = popCont();
const ids = [stRepId(this.meta.rep), stPurseId(this.meta.purse)];
for (const u of UPGRADES) if (!this.meta.upgrades.includes(u.id)) ids.push(u.voice);
this._speakFlow(ids, {
onDone: () => {
if (cont) cont();
else if (this.floor.waitPoint) this.floor.resumeTurn();
},
});
return 'hold';
}
case 'down':
this._openMenu(); // 阶段 2:菜单(CLOSE 含「打烊置办」)
return 'ok';
default: {
// settle_night 播中被滑:续上(存档+交出睡下等待点)
const cont = popCont();
if (cont) { cont(); return 'ok'; }
return 'invalid';
}
}
}
_sleepNext() {
this._sfx('lamp_off');
// 第一晚打烊 → 手势阶梯阶段 2(下滑=菜单),教学句挂下一晚开场链首
const got = this.ladder.onGameDone();
if (got) this._teachQueue = this.ladder.teachLine(got);
const night = this.meta.nightsClosed + 1;
this.run = { night, guestIdx: 0, results: [], incomeTonight: 0,
guestStory: {}, rentCharged: false, inBrew: false };
this._enterOpening();
}
// ================================================================ 等待点(20 号附录 C §5)
_introWp() {
return new WaitPoint({
id: 'intro',
expected: ['tap'],
full: null, // 指令句刚说过(critical),第一档直接短句
short: 'voice/wp_tap_short',
silenceMs: SILENCE.intro,
onDemo: () => { this._newGame(); }, // L3 替点:直接替玩家开店
}, { floor: this.floor, confusion: this.confusion });
}
_reopenWp() {
return new WaitPoint({
id: 'reopen',
expected: ['tap', 'right'],
full: 'voice/sys_continue_ask',
short: 'voice/wp_tap_short',
silenceMs: SILENCE.reopen,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_teaWp() {
return new WaitPoint({
id: 'tea',
expected: ['left', 'right', 'tap'],
full: 'voice/wp_tea_full',
short: 'voice/wp_tea_short',
silenceMs: SILENCE.tea,
onDemo: () => {
// L3 替选点单那罐;教学中(D0/D1)走教学演示口径
if (this._drill && this._drill.step <= 1) { this._drillAuto(this._drill.step); return; }
this._demoSelectOrderedTea();
},
}, { floor: this.floor, confusion: this.confusion });
}
/**
* 时机步等待点(water/steep):full/short 置空——窗内禁止向导语音(分层铁律),
* 被动档与 L1 只剩交接提示音;L2 只放行 tap;L3 替提壶(拨到将沸窗中点)/替出汤。
*/
_timingWp(sub) {
return new WaitPoint({
id: sub,
expected: ['tap'],
full: null,
short: null,
silenceMs: SILENCE.timing,
onDemo: () => {
const b = this._brew;
if (!b) return;
// 教学中(D2/D3)走教学演示口径(替打后接 tut_steep / 结算)
if (this._drill && this._drill.step === (sub === 'water' ? 2 : 3)) {
this._drillAuto(this._drill.step);
return;
}
if (sub === 'water') {
if (b.water !== null) return;
const [lo, hi] = this._waterWindow();
this._brewElapsed = (lo + hi) / 2; // 演示:拨到窗中点(将沸)
this._brewBase = performance.now();
this._waterJudge(false);
} else {
if (b.steep !== null) return;
const [lo, hi] = this._steepWindow();
this._brewElapsed = (lo + hi) / 2;
this._brewBase = performance.now();
this._steepJudge(false);
}
},
}, { floor: this.floor, confusion: this.confusion });
}
_closeWp() {
return new WaitPoint({
id: 'close',
expected: ['tap'],
full: 'voice/gd_close', // 「账报完了。单击,熄灯睡下。」(settle_night 已含动作口径)
short: 'voice/wp_tap_short',
silenceMs: SILENCE.close,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_menuWp() {
return new WaitPoint({
id: 'menu',
expected: ['tap', 'left', 'right'],
full: 'voice/sys_menu',
short: 'voice/sys_menu',
silenceMs: SILENCE.menu,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
_shopWp() {
return new WaitPoint({
id: 'shop',
expected: ['tap', 'left', 'right'],
full: 'voice/upg_open',
short: 'voice/upg_open',
silenceMs: SILENCE.menu,
onDemo: () => this.floor.chime(),
}, { floor: this.floor, confusion: this.confusion });
}
/** L3 演示:替玩家选点单那罐(jar + 茶名句 → 选定进候汤)。 */
_demoSelectOrderedTea() {
const b = this._brew;
if (!b || b.sub !== 'TEA' || !this._guest) return;
const shelf = this._shelf();
const idx = shelf.indexOf(this._guest.tea);
b.teaIdx = idx >= 0 ? idx : 0;
this._sfx('tea_jar');
this._speakFlow([TEAS[shelf[b.teaIdx]].name], {
onDone: () => this._selectTea(),
});
}
// ================================================================ 教学 DRILL(§5,单击版)
_startDrill(step) {
const d = this._drill = { step, misses: 0, timers: [] };
const say = ['tut_d0', 'tut_tea', 'tut_water', 'tut_steep', 'tut_serve', null, null][step];
if (say) {
this._speakFlow([say], {
critical: true, // 教学指令 = 规则的唯一一次说明(§4.6),不可打断
onDone: () => {
if (step === 0) this._previewTea();
else this.floor.yieldTurn(this._drillWp());
},
});
}
this._armDrillTimers(step);
}
/** 教学步等待点:D1/D4 等 tap;D5 等 up(刚教过,第一档直接短句)。 */
_drillWp() {
const step = this._drill ? this._drill.step : 0;
return new WaitPoint({
id: 'drill' + step,
expected: step === 5 ? ['up'] : ['tap'],
full: null, // 指令句刚说过(critical)
short: step === 5 ? 'voice/wp_up_short' : 'voice/wp_tap_short',
silenceMs: SILENCE.intro,
onDemo: () => this._drillAuto(step),
}, { floor: this.floor, confusion: this.confusion });
}
_armDrillTimers(step) {
const d = this._drill;
if (!d) return;
d.timers.forEach(clearTimeout);
d.timers = [];
// 30s earcon → 60s 重教(时机步不发语音,水声/滴答时间线自会自动判定)→ 90s 自动替打(§5)
d.timers.push(setTimeout(() => { if (this._drill === d) this.floor.chime(); }, 30000));
d.timers.push(setTimeout(() => {
if (this._drill !== d) return;
const say = ['tut_d0', 'tut_tea', null, null, 'tut_serve', null, null][step];
if (say) this._speakFlow([say], { critical: true });
}, 60000));
d.timers.push(setTimeout(() => { if (this._drill === d) this._drillAuto(step); }, 90000));
}
_clearDrillTimers() {
if (this._drill) { this._drill.timers.forEach(clearTimeout); this._drill.timers = []; }
}
/** 教学手势接管:返回 'ok'|'invalid'|'silent-ok'|'hold'。 */
_onDrillGesture(g) {
const d = this._drill;
if (!d) return 'invalid';
// 归因句/教学衔接句播中被滑/被抢点:续上收尾(时机窗内滑动不判错步)。
// 管道杀链后 speaking 已 false:有自动流在 = 刚被打断,无条件取
const popCont = () => this._popFlow();
switch (d.step) {
case 0: { // D0:左右滑看茶柜(单击=已在龙井则选定也算过,附录 C §6)
const shelf = this._shelf();
if (g === 'left' || g === 'right') {
this._brew.teaIdx = (this._brew.teaIdx + (g === 'right' ? 1 : -1) + shelf.length) % shelf.length;
this._sfx('tea_jar');
this._speakFlow([TEAS[shelf[this._brew.teaIdx]].name], {
onDone: () => {
// 做中学:换过一罐马上教「单击选定」(原则 11)
this._speakFlow(['tut_tea'], {
critical: true,
onDone: () => { this._drillGoto(1); this.floor.yieldTurn(this._drillWp()); },
});
},
});
return 'silent-ok';
}
if (g === 'tap') {
this._selectTea(false); // 茶柜 0 = 龙井 = 老周点单
this._teachWater();
return 'silent-ok';
}
if (g === 'up') { const cont = popCont(); this._statusChain(cont); return 'ok'; }
return 'invalid';
}
case 1: { // D1:单击选定
if (g === 'tap') {
this._selectTea(false);
this._teachWater();
return 'silent-ok';
}
if (g === 'left' || g === 'right') {
this._cycleTea(g === 'right' ? 1 : -1);
return 'silent-ok';
}
if (g === 'up') { const cont = popCont(); this._statusChain(cont); return 'ok'; }
return 'invalid';
}
case 2: { // D2:单击提壶(将沸窗内;偏了真话归因+重教,2 次后自动演示通过)
if (g === 'tap' || g === 'doubletap') {
const [lo, hi] = this._waterWindow();
const now = this._brewNow();
if (now >= lo && now <= hi) {
this._waterJudge(false, () => this._teachSteep());
} else if (d.misses < 2) {
d.misses++;
this._clearBrewTimers();
this._sfx('kettle_lift');
const miss = now < lo ? 'early' : 'late';
this._speakFlow(['water_' + miss, 'tut_water'], {
critical: true,
onDone: () => this._restartWaterForDrill(),
});
} else {
this._drillAuto(2);
}
return 'silent-ok';
}
if (g === 'up') { this._statusChain(popCont()); return 'ok'; }
{ const cont = popCont(); if (cont) cont(); }
return 'invalid'; // 时机窗内滑动不解释(分层铁律)
}
case 3: { // D3:单击出汤(滴答密时;容错同 D2)
if (g === 'tap' || g === 'doubletap') {
const [lo, hi] = this._steepWindow();
const now = this._brewNow();
if (now >= lo && now <= hi) {
this._steepJudge(false); // settle 走教学分支:res 句后等 D4
} else if (d.misses < 2) {
d.misses++;
this._clearBrewTimers();
this._sfx('pour_cup');
const miss = now < lo ? 'early' : 'late';
this._speakFlow(['steep_' + miss, 'tut_steep'], {
critical: true,
onDone: () => this._restartSteepForDrill(),
});
} else {
this._drillAuto(3);
}
return 'silent-ok';
}
if (g === 'up') { this._statusChain(popCont()); return 'ok'; }
{ const cont = popCont(); if (cont) cont(); }
return 'invalid';
}
case 4: { // D4:单击端茶 → 反应链 → 解锁上滑教学 → D5
if (g === 'tap' || g === 'doubletap') {
this._sfx('pour_cup');
this._reactionChain(() => this._drillServeDone());
return 'silent-ok';
}
if (g === 'up') { this._statusChain(popCont()); return 'ok'; }
return 'invalid';
}
case 5: { // D5:上滑状态链 → tut_done → 完成
if (g === 'up') {
this._statusChain(() => this._speakFlow(['tut_done'], {
onDone: () => this._drillFinish(),
}));
return 'ok';
}
if (g === 'tap') return 'invalid'; // 等上滑:单击不误触完成
return 'invalid';
}
default:
return 'invalid';
}
}
/** D1 通过:tut_water(critical,听觉分层——教学句先说完再坐水)。 */
_teachWater() {
this._speakFlow(['tut_water'], {
critical: true,
onDone: () => {
this._startWater();
this._drillGoto(2);
},
});
}
/** D2 通过:tut_steep(critical)→ 时间线 → D3。 */
_teachSteep() {
this._speakFlow(['tut_steep'], {
critical: true,
onDone: () => {
this._startSteep();
this._drillGoto(3);
},
});
}
/** D4 反应链收尾:第一盏茶送出 → 阶段 1 解锁上滑(教学句挂链首)→ D5。 */
_drillServeDone() {
this.state = 'SERVE';
this.audio.play('bgm/serve');
this.guide.state('explore');
this.audio.setRateBoostEnabled(true);
if (!this._goalMet) {
this._goalMet = true;
const got = this.ladder.onGoal();
if (got) this._teachQueue = this.ladder.teachLine(got); // 「想知道自己在哪,就向上滑。」
}
this._drillGoto(5);
if (this._teachQueue) {
const t = this._teachQueue;
this._teachQueue = null;
this._speakFlow([t], {
critical: true,
onDone: () => this.floor.yieldTurn(this._drillWp()),
});
} else {
this.floor.yieldTurn(this._drillWp());
}
}
_restartWaterForDrill() {
if (this._drill) this._armDrillTimers(2);
this._startWater();
}
_restartSteepForDrill() {
if (this._drill) this._armDrillTimers(3);
this._startSteep();
}
_drillGoto(step) {
const d = this._drill;
if (!d) return;
d.step = step;
d.misses = 0;
this._armDrillTimers(step);
}
_drillFinish() {
this._clearDrillTimers();
this._drill = null;
this.meta.drillDone = true;
this._writeProgress(false);
this._nextGuest(); // D6:第 2 位客人门铃,自由营业
}
/** 90s 自动替打(演示通过不卡流程,§5)。 */
_drillAuto(step) {
const d = this._drill;
if (!d) return;
switch (step) {
case 0: {
const shelf = this._shelf();
this._sfx('tea_jar');
this._speakFlow([TEAS[shelf[0]].name, 'tut_tea'], {
critical: true,
onDone: () => { this._drillGoto(1); this.floor.yieldTurn(this._drillWp()); },
});
break;
}
case 1:
this._selectTea(false);
this._teachWater();
break;
case 2: {
const [lo, hi] = this._waterWindow();
this._brewElapsed = (lo + hi) / 2; // 演示:拨到窗中点
this._brewBase = performance.now();
this._waterJudge(false, () => this._teachSteep());
break;
}
case 3: {
const [lo, hi] = this._steepWindow();
this._brewElapsed = (lo + hi) / 2;
this._brewBase = performance.now();
this._steepJudge(false);
break;
}
case 4:
this._sfx('pour_cup');
this._reactionChain(() => this._drillServeDone());
break;
case 5:
this._statusChain(() => this._speakFlow(['tut_done'], { onDone: () => this._drillFinish() }));
break;
default:
break;
}
}
// ================================================================ 模态菜单(§2.5:左右滑切项 + 单击确认)
_menuItems() {
const items = ['sys_menu_resume', 'sys_menu_help', 'sys_menu_save'];
if (this.state === 'CLOSE') items.splice(1, 0, 'sys_menu_upg'); // 打烊置办仅 CLOSE
return items;
}
_openMenu() {
this._menuOpen = true;
this._menuIdx = 0;
this._shopOpen = false;
this.guide.state('menu');
// 停链(关闭时重播被停链)+ 冻结计时。注意:下滑先经管道 floor.handleInput
// 杀链(此后 currentId() 已不可查),所以这里整条重播——盲玩语义「节点不丢」
// 按完整重听算;引擎缺口:floor 暴露被杀链进度后可改为从被停句续播
this._menuResume = null;
if (this._flow) {
const flow = this._flow;
this._popFlow();
this._menuResume = { ids: flow.ids, from: 0, cont: flow.cont };
}
this.floor.interrupt();
if (this.state === 'BREW' && this._brew && this._brew.sub !== 'TEA') this._freezeBrewTimeline();
const items = this._menuItems();
this.floor.speak(['voice/sys_menu', 'voice/' + items[0], 'voice/settings_hint'], {
onDone: () => this.floor.yieldTurn(this._menuWp()),
});
}
_closeMenu() {
this._menuOpen = false;
this._shopOpen = false;
// 恢复签名
if (this.state === 'BREW') this.guide.state('battle');
else if (this.state === 'CLOSE') this.guide.state(this._closeDone ? 'menu' : 'settle');
else if (this.state === 'TITLE') this.guide.state('menu');
else this.guide.state('explore');
// 恢复被冻结时间线(从冻结点续走)+ 时机等待点
if (this.state === 'BREW' && this._brew && this._brew.sub !== 'TEA') {
this._resumeBrewTimeline();
const recap = this._brew.sub === 'WATER' ? 'hint_water' : TEAS[this._brew.tea].name;
this._speakFlow([recap], {
onDone: () => this.floor.yieldTurn(this._timingWp(this._brew.sub.toLowerCase())),
});
return;
}
// 恢复被停链(从被停那句续播,节点不丢)
if (this._menuResume) {
const r = this._menuResume;
this._menuResume = null;
this._speakFlow(r.ids.slice(r.from), { onDone: r.cont });
return;
}
// 无被停链:按状态恢复等待点
if (this.state === 'BREW' && this._brew && this._brew.sub === 'TEA') {
this.floor.yieldTurn(this._teaWp());
} else if (this.state === 'CLOSE' && this._closeDone) {
this.floor.yieldTurn(this._closeWp());
} else if (this.state === 'TITLE') {
this.floor.yieldTurn(this.run ? this._reopenWp() : this._introWp());
}
// SERVE/OPENING:自动流(BUSY 空档),交回自动推进
}
_announceMenuItem() {
const items = this._menuItems();
this.floor.speak(['voice/' + items[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
_onMenuGesture(g) {
if (this._shopOpen) return this._onShopGesture(g);
const items = this._menuItems();
switch (g) {
case 'left':
case 'right':
this._menuIdx = (this._menuIdx + (g === 'right' ? 1 : -1) + items.length) % items.length;
this._announceMenuItem();
return 'hold';
case 'tap':
case 'doubletap': { // 确认当前项(双击按单击)
const item = items[this._menuIdx];
if (item === 'sys_menu_resume') this._closeMenu();
else if (item === 'sys_menu_help') {
this.floor.speak(['voice/sys_help'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
} else if (item === 'sys_menu_upg') this._openShop();
else this._saveAndHome();
return 'ok';
}
case 'up': // 重听菜单说明 + 当前项
this.floor.speak(['voice/sys_menu', 'voice/' + items[this._menuIdx]], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'down':
this._closeMenu();
return 'ok';
default:
return 'invalid';
}
}
// ---------------- 打烊置办二级列表(§2.5) ----------------
_openShop() {
this._shopOpen = true;
this._shopIdx = 0;
this.floor.speak(['voice/upg_open', 'voice/' + UPGRADES[0].voice], {
onDone: () => this.floor.yieldTurn(this._shopWp()),
});
}
_onShopGesture(g) {
switch (g) {
case 'left':
case 'right':
this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : -1) + UPGRADES.length) % UPGRADES.length;
this.floor.speak(['voice/' + UPGRADES[this._shopIdx].voice], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'tap':
case 'doubletap': // 单击买定(双击按单击)
this._buyUpgrade(UPGRADES[this._shopIdx]);
return 'ok';
case 'up':
this.floor.speak(['voice/upg_open'], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return 'hold';
case 'down': // 退出置办回菜单
this._shopOpen = false;
this._menuIdx = 1;
this.floor.speak(['voice/sys_menu_upg'], {
onDone: () => this.floor.yieldTurn(this._menuWp()),
});
return 'ok';
default:
return 'invalid';
}
}
_buyUpgrade(u) {
const announce = () => this.floor.speak(['voice/' + u.voice], {
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
if (this.meta.upgrades.includes(u.id)) {
announce(); // 已置办:重播报价句(无重复扣款)
return;
}
if (u.repReq && this.meta.rep < u.repReq) {
this.floor.speak(['voice/upg_lock'], {
kind: 'feedback',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return;
}
if (u.needDebtFree && this.meta.debt > 0) {
this.floor.speak(['voice/upg_lock'], {
kind: 'feedback',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return;
}
if (this.meta.purse < u.cost) {
this.floor.speak(['voice/upg_none'], {
kind: 'feedback',
onDone: () => { if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
return;
}
this.meta.purse -= u.cost;
this.meta.upgrades.push(u.id);
this._sfx('shop_buy');
this._sfx('upg_install');
this.floor.speak(['voice/' + u.voice, 'voice/sys_saved'], {
onDone: () => { this._saveAuto(); if (this.floor.waitPoint) this.floor.resumeTurn(); },
});
}
// ================================================================ 存档(§10 / §4.12)
_summaryText() {
const r = this.run;
if (!r) {
return `第${this.meta.nightsClosed}晚打烊 · 口碑·${REP_BAND_NAME(this.meta.rep)} · 存款${this.meta.purse}文` +
(this.meta.debt > 0 ? ` · 欠账${this.meta.debt}文` : '');
}
return `第${r.night}晚 · 客人${r.guestIdx + 1}/4 · 口碑·${REP_BAND_NAME(this.meta.rep)} · 存款${this.meta.purse}文` +
(this.meta.debt > 0 ? ` · 欠账${this.meta.debt}文` : '');
}
_saveData() {
return {
schema: 1,
game: 'teahouse',
updatedAt: new Date().toISOString(),
summary: this._summaryText(),
meta: this.meta,
run: this.run,
visits: this._visits,
story: this.story,
};
}
_writeProgress(withPut) {
const data = this._saveData();
try { localStorage.setItem(SAVE_KEY, JSON.stringify(data)); } catch (_) { /* 隐私模式 */ }
if (!withPut) return;
try {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null');
if (!auth || !auth.token) return;
fetch('/api/progress', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.token },
body: JSON.stringify({ game: 'teahouse', data }),
}).catch(() => { /* 离线静默 */ });
} catch (_) { /* 静默 */ }
}
_saveAuto() { this._writeProgress(true); }
_saveAndHome() {
this._writeProgress(true);
this.floor.speak(['voice/sys_saved'], { onDone: () => this._goHome() });
}
_goHome() {
if (this._goingHome) return;
this._goingHome = true;
this.stop();
this._navigatedHome = '../';
try { window.location.href = '../'; } catch (_) { /* mock 环境无 location */ }
}
_loadProgress() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return;
const d = JSON.parse(raw);
if (d && d.meta) this.meta = { drillDone: false, nightsClosed: 0, rep: 0, purse: 0, debt: 0,
upgrades: [], chapterEnd: false, ...d.meta };
if (d && d.run) this.run = d.run;
if (d && d.visits) this._visits = d.visits;
if (d && d.story) this.story = d.story;
} catch (_) { /* 损坏回退默认 */ }
}
/** 登录态拉回服务器进度(服务器为准);失败静默用本地。 */
_pullServerProgress() {
let auth = null;
try { auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch (_) { /* 静默 */ }
if (!auth || !auth.token) return;
const hadLocalRun = !!this.run; // 本地已有档:不重复播报 continue_ask
fetch('/api/progress?game=teahouse', { headers: { Authorization: `Bearer ${auth.token}` } })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (j && j.data) {
const d = j.data;
if (d.meta) this.meta = { drillDone: false, nightsClosed: 0, rep: 0, purse: 0, debt: 0,
upgrades: [], chapterEnd: false, ...d.meta };
if (d.run) this.run = d.run;
if (d.visits) this._visits = d.visits;
if (d.story) this.story = d.story;
if (!hadLocalRun && this.state === 'TITLE' && this.run) {
this._speakFlow(['sys_continue_ask'], {
critical: true,
onDone: () => this.floor.yieldTurn(this._reopenWp()),
});
}
}
})
.catch(() => { /* 离线静默 */ });
}
}