/** * 《深夜茶馆》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(() => { /* 离线静默 */ }); } }