/** * 《深夜茶馆》—— 经营养成纯音频游戏(批次 3 新模式款 X3)。 * 设计:docs/design/20-teahouse.md(状态机 §4 / 泡茶小局 §3 / 经济 §7 / 断言 §11) * 台词表:docs/design/20-teahouse-voice-ids.md(旁白 98 ×2 性别 + NPC 平铺 65) * * 架构要点: * - 宏观状态 TITLE/OPENING/SERVE/BREW/CLOSE + 模态菜单 overlay(§4.1); * SERVE 子相位=WELCOME/TALK/REACT(自动链),BREW 子相位=TEA/WATER/STEEP。 * - 数据单源(§6.1):点单句→茶参数→时机判定→三档结果→方向值→结局,全从 * TEAS/GUESTS/NIGHTS 推导;播报值=运行值(§7.3 对账铁律)。 * - 候汤/闷泡时间线(§3.2)= 预生成 one-shot + setTimeout 事件表调度(无实时 * WebAudio 节点,mock 契约);下滑菜单冻结(取消全部定时器、记录 elapsed)、 * 关闭从冻结点续走。听觉分层(§3.3):时机窗内零语音,看门狗只发 earcon。 * - 经济(§7.2):CLOSE 时 income 入账→先还债(清零播 debt_clear)→房租夜扣 * 租(不足差额入 debt,播 rent_short + danger 签名);错茶=免单(pay 0、rep−1), * 无 game over;rep clamp ≥0,跨档双向播报(chime/fall + st_rep_N)。 * - 弧线(§6.2):方向值 good+1/plain0/wrong−1 累计三晚,sum≥2→A / −1..1→B / * ≤−2→C;晚 3 结局句替换反应句。茶柜锁=设计内经营重量(§7.4)。 * - N1-N5 剧本夜固定客序;N6+ 无尽营业 = mulberry32(seed) 氛围客轮换(§6.1 * nightsEndless),同 seed 两次构造 _schedule 深相等(§11 断言 3)。 * - CLOSE 结算链独占:800ms 死窗(CLOSE_GRACE_MS)+ 链锁(§4.4,dungeon H1 / * detective VERDICT 内化);链完成后单击=睡下(settle_night 言行一致)。 * - 存档(§10):localStorage + 登录态 PUT /api/progress(data 必带 summary); * 客人粒度恢复:当前客人从进门链重放 + 局面复述链(≤5s 回可操作)。 * - 教学 DRILL(§5)寄生首晚第 1 位客人:D0-D6 逐步,90s 自动替打,下滑跳过 * (meta.drillDone 持久化);首晚时机窗 ×1.3。 * * opts.seed 注入可复现(默认 Date.now());测试用虚拟时钟驱动。 */ import { Game } from '../engine/game.js'; import { Guide } from '../engine/guide.js'; import { vibrate } from '../engine/haptics.js'; const SAVE_KEY = 'soundgame.progress.teahouse'; const AUTH_KEY = 'soundgame.auth'; // ---------------- 数值(§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 VOICE_GUARD_MS = 8000; // §4.3 单句 8s 无 onComplete → 告警续链 const GAP_MS = 800; // 语音链句间静默后递(§4.3 0.8s) 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 }; // ---------------- 数据单源(§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, turn_tick: 0.12, }; 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); this.guide = new Guide(audio); 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._chainSeq = 0; this._chain = null; // { ids, i, onDone, protect, skippable, queueFirst } this._resumeChain = null; // 菜单挂起时的续链点 this._lastChainIds = []; // 重听用 // 客人/泡茶 this._guest = null; // { kind:'arc'|'amb', id, visit, tea, orderId, sig } this._brew = null; // { sub:'TEA'|'WATER'|'STEEP', teaIdx, tea, water, steep, ... } this._brewTimers = []; this._brewEvents = []; this._brewBase = 0; this._brewElapsed = 0; this._tickLog = []; // 断言用:{ id, t } sfx 调度记录 this._voiceWindows = []; // 断言用:boil/tick 活跃区间 {t0,t1} // 教学 this._drill = null; // { step, misses, timers:{...} } this._resetArm = 0; // TITLE 右滑二次确认 // CLOSE this._graceUntil = 0; this._closeChainActive = false; this._closeDone = false; this._closeChain = []; // 看门狗 this._wdTimer = null; // 无操作轨(手势复位) this._stuckTimer = null; // 无进展轨(只有进展事件复位) this._stuckLevel = 0; this._stuckBase = 0; this._progressAt = 0; // 最近进展时刻 this._orderReplayCount = 0; // L3 重播点单句全局限 2 次 // 菜单置办二级 this._shopOpen = false; this._shopIdx = 0; this._schedToken = 0; // 自动推进调度令牌(测试可取消) // 节奏日志(playtest 断言用) this._playLog = []; } // ================================================================ 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._chainSeq++; this._clearBrewTimers(); this._clearWatchdogs(); if (this._drill) this._clearDrillTimers(); this.audio.stopAll(); } onGesture(g) { if (this._goingHome) return; this.guide.disarm(); if (g === 'tapcancel') return; // CLOSE 死窗(§4.4):ghost 手势不算输入、不碰看门狗 if (this.state === 'CLOSE' && performance.now() < this._graceUntil) return; if (this._menuOpen) { this._onMenuGesture(g); return; } if (g === 'longpress' || g === 'longpressend') { // SERVE 长按=引擎快进(不豁免);其余态 error(BREW 态 E3 关闭快进) if (this.state === 'SERVE') { /* 引擎快进接管 */ } else if (this.state !== 'CLOSE' || this._closeChainActive) this._invalid(); return; } switch (this.state) { case 'TITLE': this._onTitleGesture(g); break; case 'OPENING': this._onOpeningGesture(g); break; case 'SERVE': this._onServeGesture(g); break; case 'BREW': this._onBrewGesture(g); break; case 'CLOSE': this._onCloseGesture(g); break; default: break; } this._armIdleWatchdog(); } // ================================================================ 工具:语音链 /** * 语音链(§4.3):onComplete 串行 + 8s 单句看门狗(资产缺失静默跳过不卡死)。 * protect:'chain' 泛保护(菜单/链互斥用);skippable=false 的关键句 tap 不跳。 */ _playChain(ids, { onDone = null, queueFirst = false, skippable = true, track = false } = {}) { if (!ids.length) { if (onDone) onDone(); return; } const token = ++this._chainSeq; const chain = { ids, i: 0, onDone, skippable, queueFirst, track }; this._chain = chain; this._lastChainIds = ids.slice(); const step = (i) => { if (token !== this._chainSeq || this._chain !== chain) return; // 链被作废 if (i >= ids.length) { this._chain = null; if (chain.track) this._progressTrack(); // 进展事件只认相位推进链(§9.3) this._armIdleWatchdog(); // 链播完的空闲窗口立即看护(不等手势) if (onDone) onDone(); return; } chain.i = i; let done = false; let guardId = null; const finish = () => { if (done) return; done = true; if (guardId !== null) clearTimeout(guardId); setTimeout(() => step(i + 1), GAP_MS); }; const guard = () => { if (done || token !== this._chainSeq || this._chain !== chain) return; if (this.audio.isVoicePlaying()) { guardId = setTimeout(guard, 2000); return; } finish(); }; guardId = setTimeout(guard, VOICE_GUARD_MS); this.audio.play('voice/' + ids[i], { queue: queueFirst && i === 0, onComplete: finish }); }; step(0); return chain; } /** 当前句是否可跳(E2:res_ 与 rent_ 前缀关键句不可跳)。 */ _currentSkippable() { const c = this._chain; if (!c || !c.skippable) return false; const id = c.ids[c.i]; return !(id && (/^(res_|rent_)/.test(id))); } /** SERVE 单击跳句(E2 口径):在播且可跳 → skipVoice 促链推进。 */ _trySkipSentence() { if (this._chain && this.audio.isVoicePlaying() && this._currentSkippable()) { this.audio.skipVoice(); return true; } return false; } /** 无效手势:error 震 + full 档 gd_invalid(onceKey 同局 1 次)。 */ _invalid() { vibrate('error'); this.guide.error({ voice: 'gd_invalid', onceKey: 'teahouse_invalid', skipHaptic: true }); } _progressTrack() { this._progressAt = performance.now(); this._armStuckWatchdog(); // 无进展轨只由进展事件复位(§9.3) } // ================================================================ 工具:看门狗双轨(§9.2/§9.3) /** 无操作轨:任何手势复位(guide.disarm 已在 onGesture 调);各态参数不同。 */ _armIdleWatchdog() { if (this._menuOpen || this._shopOpen) return; const st = this.state; let at = null; let say = null; let earconOnly = false; if (st === 'SERVE' && !this._chain) { at = [10000, 25000]; say = ['gd_serve', null]; } else if (st === 'BREW' && this._brew && this._brew.sub === 'TEA') { at = [12000, 30000]; say = ['gd_brew', null]; } else if (st === 'CLOSE' && this._closeDone) { at = [15000, 35000]; say = ['gd_close', null]; } if (!at) { this.guide.watchdog({ arm: false }); return; } // BREW 时机窗内只 earcon 不语音(§3.3 分层铁律) if (st === 'BREW' && this._brew && this._brew.sub !== 'TEA') earconOnly = true; this.guide.watchdog({ at, say: earconOnly ? [null] : say, onEscalate: earconOnly ? () => this._sfx('turn_tick') : null, cond: () => !this._menuOpen && !this._shopOpen && (earconOnly ? (this.state === 'BREW' && this._brew && this._brew.sub !== 'TEA') : this.state === st), }); } /** 无进展轨(§9.3):只由进展事件复位;乱按攒不满。45s earcon → 90s 短句 → 150s 重播点单句(全局限 2 次)。 */ _armStuckWatchdog() { if (this._stuckTimer !== null) { clearTimeout(this._stuckTimer); this._stuckTimer = null; } if (this.guide.level() === 'off') return; // off:零定时器 this._stuckBase = performance.now(); this._stuckTimer = setTimeout(() => this._stuckStep(1), 45000); } _stuckStep(level) { this._stuckTimer = null; if (this.guide.level() === 'off') return; // 时机窗内(WATER/STEEP)语音一律不发,只 earcon(§3.3/§9.2 分层铁律) const voiceMute = this.state === 'BREW' && this._brew && this._brew.sub !== 'TEA'; if (level === 1 || voiceMute) { this._sfx('turn_tick'); // L1 纯 earcon,不占台词通道 } else if (level === 2) { const id = (this.state === 'BREW') ? 'hint_water' : 'gd_serve'; this.audio.play('voice/' + id, { queue: true }); } else { // L3+:重播当前客人点单句(真话提示:把线索原句还给玩家) if (this._orderReplayCount < 2 && this._guest) { this._orderReplayCount++; if (this._guest.kind === 'arc') this._sfx(this._guest.sig); this._playChain([this._guest.orderId], { queueFirst: true }); } } this._stuckTimer = setTimeout(() => this._stuckStep(level + 1), 45000); } _clearWatchdogs() { if (this._stuckTimer !== null) { clearTimeout(this._stuckTimer); this._stuckTimer = null; } this.guide.disarm(); } // ================================================================ 工具:sfx / BREW 时间线 _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; } /** 时机窗(§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); const until = performance.now() + (SFX_DUR[e.id] || 0.5) * 1000; this._voiceWindows.push({ id: e.id, t0: performance.now(), t1: until }); }, 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; } } this._brewTimers = []; } /** 当前 BREW 子相位已进行的毫秒(判定输入用)。 */ _brewNow() { if (!this._brew) return 0; if (this._menuOpen) return this._brewElapsed; return this._brewElapsed + (performance.now() - this._brewBase); } // ================================================================ TITLE _enterTitle() { this.state = 'TITLE'; this._resetArm = 0; this.audio.play('bgm/menu'); this.guide.state('menu'); this._orderReplayCount = 0; if (this.run) this._playChain(['sys_continue_ask']); } _onTitleGesture(g) { if (g === 'tap') { // 单击=继续/新店(§2.4):sys_continue_ask 是提问句,单击即作答,不走跳句 this._chainSeq++; this._chain = null; if (this.run) this._continueRun(); else this._newGame(); return; } if (g === 'doubletap') { if (this.run) this._playChain(['sys_continue_ask']); return; } if (g === 'right') { if (!this.run) { this._invalid(); return; } if (this._resetArm && performance.now() < this._resetArm) { this._resetRun(); return; } this._resetArm = performance.now() + 3000; this._playChain(['sys_reset_ask']); return; } if (g === 'up') { // 店铺概况:口碑档 → 存款档 → 已置办句重播 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._playChain(ids, { queueFirst: true }); return; } if (g === 'down') { this._openMenu(); return; } this._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 }; 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._playChain(ids, { track: true, onDone: () => this._startGuest(r.guestIdx) }); } // ================================================================ OPENING _enterOpening() { this.state = 'OPENING'; this.audio.play('bgm/serve'); this.guide.state('explore'); const n = this.run.night; const ids = []; // 点灯 → 晚数句(首晚 intro 三句 + tut_d0 寄生于进 BREW 时) if (n === 1 && !this.meta.drillDone) ids.push('intro_1', 'intro_2', 'intro_3'); this._sfx('lamp_light'); this._playChain([nightId(n), ...ids], { track: true, onDone: () => { const tok = ++this._schedToken; setTimeout(() => { if (tok === this._schedToken) this._startGuest(0); }, SERVE_GAP_MS); }, }); this._progressTrack(); this._armStuckWatchdog(); } _onOpeningGesture(g) { if (g === 'tap') { this._trySkipSentence(); return; } // 开场链可跳 if (g === 'doubletap') { if (this._lastChainIds.length) this._playChain(this._lastChainIds); return; } if (g === 'up') { this._statusChain(); return; } if (g === 'down') { this._openMenu(); return; } this._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'); this.guide.state('explore'); // 客人链 = 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._playChain(ids, { track: true, onDone: () => { const tok = ++this._schedToken; setTimeout(() => { if (tok === this._schedToken) this._enterBrew(); }, SERVE_GAP_MS); }, }); this._armStuckWatchdog(); } _onServeGesture(g) { if (g === 'tap') { if (this._trySkipSentence()) return; if (!this._chain) { this._enterBrew(); return; } // 空闲单击=提前切下一段(加速) return; } if (g === 'doubletap') { if (this._chain) { // 重听当前句 const c = this._chain; this._playChain(c.ids.slice(c.i), { onDone: c.onDone, skippable: c.skippable }); } else if (this._lastChainIds.length) { this._playChain(this._lastChainIds); // 空闲重听末句 } return; } if (g === 'left' || g === 'right') return; // 静默忽略(纯聆听保护) if (g === 'up') { this._statusChain(); return; } if (g === 'down') { this._openMenu(); return; } } /** 上滑状态链(§2.3:BREW 第一段=sig+点单句重播,需求随时可查)。 */ _statusChain() { const ids = []; if (this.state === 'BREW' && this._guest) { if (this._guest.kind === 'arc') { this._sfx(this._guest.sig); ids.push(this._guest.orderId); } else ids.push(this._guest.orderId); } if (this.run) ids.push(stGuestId(this.run.guestIdx + 1)); ids.push(stRepId(this.meta.rep), stPurseId(this.meta.purse)); this._playChain(ids, { queueFirst: true }); } // ================================================================ BREW _enterBrew() { this.state = 'BREW'; this.audio.play('bgm/brew'); this.guide.state('battle'); this.audio.setRateBoostEnabled(false); // E3:BREW 态长按=无效输入,防误触快进 const g = this._guest; this._brew = { sub: 'TEA', teaIdx: 0, tea: null, water: null, steep: null, judged: false }; this.run.inBrew = true; this._playChain(['brew_start'], { onDone: () => { if (this.meta.drillDone) { this._previewTea(); } else { this._startDrill(0); } }, }); this._armStuckWatchdog(); } _previewTea() { const shelf = this._shelf(); this._brew.teaIdx = 0; this._sfx('tea_jar'); this._playChain([TEAS[shelf[0]].name]); } _onBrewGesture(g) { const b = this._brew; if (!b) return; if (this._drill && this._onDrillGesture(g)) return; // 教学接管 if (b.sub === 'TEA') { if (g === 'left' || g === 'right') { this._cycleTea(g === 'right' ? 1 : -1); return; } if (g === 'tap') { this._selectTea(); return; } if (g === 'doubletap') { // 宪法语义:重听当前茶名句 const shelf = this._shelf(); this._playChain([TEAS[shelf[b.teaIdx]].name]); return; } if (g === 'up') { this._statusChain(); return; } if (g === 'down') { this._openMenu(); return; } this._invalid(); return; } // WATER / STEEP:时机步(E1 豁免:双击=提壶/出汤,非重听) if (g === 'doubletap') { if (b.sub === 'WATER') this._waterJudge(false); else this._steepJudge(false); return; } if (g === 'left' || g === 'right') { vibrate('error'); this.guide.error({ voice: 'gd_invalid', onceKey: 'teahouse_brew_swipe', skipHaptic: true }); return; // 不判错步(§3.1 错手势不判错步) } if (g === 'tap') { // 等时机纠错(§2.3):error 震 + 等时机提示 vibrate('error'); this.guide.error({ voice: 'gd_invalid', onceKey: 'teahouse_brew_tap', skipHaptic: true }); return; } if (g === 'up') { this._statusChain(); return; } // 链排队;水声时间线照走 if (g === 'down') { this._openMenu(); return; } // 冻结 this._invalid(); } _cycleTea(dir) { const shelf = this._shelf(); this._brew.teaIdx = (this._brew.teaIdx + dir + shelf.length) % shelf.length; this._sfx('tea_jar'); this._playChain([TEAS[shelf[this._brew.teaIdx]].name]); } _selectTea() { const shelf = this._shelf(); this._brew.tea = shelf[this._brew.teaIdx]; this._sfx('tea_scoop'); this._progressTrack(); this._startWater(); } _startWater() { const b = this._brew; b.sub = 'WATER'; b.water = null; this._startBrewTimeline(this._waterEvents()); this._armIdleWatchdog(); } _waterJudge(auto, onSteep = null) { const b = this._brew; if (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._progressTrack(); // 判定即归因(真话律):water_early/ok/late 按真实时机 this._playChain(['water_' + b.water], { onDone: () => { this._startSteep(); if (onSteep) onSteep(); }, }); } _startSteep() { const b = this._brew; b.sub = 'STEEP'; b.steep = null; this._startBrewTimeline(this._steepEvents()); this._armIdleWatchdog(); } _steepJudge(auto, onSettled = null) { const b = this._brew; if (b.steep !== null) return; const [, hi] = this._steepWindow(); const now = auto ? hi + STEEP_AUTO_PAD : this._brewNow(); const [lo] = this._steepWindow(); b.steep = (!auto && now < lo) ? 'early' : (now <= hi ? 'ok' : 'late'); if (auto) b.steep = 'late'; this._clearBrewTimers(); this._sfx('pour_cup'); this._progressTrack(); this._playChain(['steep_' + b.steep], { onDone: () => { this._settleGuest(); if (onSettled) onSettled(); }, }); } /** 判定三元组 → 三档结果(§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)); this._playChain(ids, { track: true, onDone: () => { 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._playChain([stRepId(this.meta.rep)], { onDone }); } else { if (onDone) onDone(); } }, }); } /** 客人结算(§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._playChain(['res_' + result], { track: true, skippable: false, onDone: () => { this._drillGoto(4); this._playChain(['tut_serve']); }, }); return; } this.state = 'SERVE'; // 判定链播完进反应相位 this.audio.play('bgm/serve'); this.guide.state('explore'); this.audio.setRateBoostEnabled(true); this._playChain(['res_' + result], { track: true, skippable: false, // res_* 关键句不可跳 onDone: () => this._reactionChain(() => this._nextGuest(), eco), }); } _nextGuest() { this._brew = null; const plan = this._schedule(this.run.night); const tok = ++this._schedToken; // 合成测试可 bump 取消挂起的自动推进 if (this.run.guestIdx + 1 < plan.length) { setTimeout(() => { if (tok === this._schedToken) this._startGuest(this.run.guestIdx + 1); }, SERVE_GAP_MS); this._armStuckWatchdog(); } else { setTimeout(() => { if (tok === this._schedToken) this._enterClose(); }, SERVE_GAP_MS); } } // ================================================================ 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; this._playChain(chain, { track: true, skippable: false, // 结算链独占(§4.3):res_ 与 rent_ 关键句均不可跳 onDone: () => { this._closeChainActive = false; this._closeDone = true; if (isN5) this.meta.chapterEnd = true; this._metaAfterNight(); this._playChain(['settle_night'], { // 链锁解除前不落在 _closeChain 里 onDone: () => this._saveAuto(), }); if (rentShort) { // danger 签名当刻(每欠账事件 1 次) this.guide.state('danger'); vibrate('warning'); } }, }); this._progressTrack(); } /** 打烊后 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) { if (this._closeChainActive) return; // 链锁:一律吞掉 if (g === 'tap') { this._sleepNext(); return; } // settle_night 言行一致:单击睡下 if (g === 'doubletap') { this._playChain(this._closeChain.concat(['settle_night'])); return; } if (g === 'up') { // 重听账目链(st_rep→st_purse→置办清单) 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._playChain(ids, { queueFirst: true }); return; } if (g === 'down') { this._openMenu(); return; } this._invalid(); // left/right/longpress } _sleepNext() { this._sfx('lamp_off'); const night = this.meta.nightsClosed + 1; this.run = { night, guestIdx: 0, results: [], incomeTonight: 0, guestStory: {}, rentCharged: false, inBrew: false }; this._enterOpening(); } // ================================================================ 教学 DRILL(§5) _startDrill(step) { const d = this._drill = { step, misses: 0, timers: [] }; const say = ['tut_d0', 'tut_tea', 'tut_water', 'tut_steep', 'tut_serve', 'tut_status', 'tut_done'][step]; if (say) this._playChain([say]); this._armDrillTimers(step); } _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._sfx('turn_tick'); }, 30000)); d.timers.push(setTimeout(() => { if (this._drill !== d) return; const say = ['tut_d0', 'tut_tea', 'tut_water', 'tut_steep', 'tut_serve', 'tut_status', null][step]; if (say) this._playChain([say]); }, 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 = []; } } /** 教学手势接管:返回 true=已消费。 */ _onDrillGesture(g) { const d = this._drill; if (!d) return false; switch (d.step) { case 0: // D0:左右滑看茶柜 if (g === 'left' || g === 'right') { vibrate('confirm'); const shelf = this._shelf(); this._sfx('tea_jar'); this._playChain([TEAS[shelf[0]].name, 'tut_tea'], { onDone: () => this._drillGoto(1) }); return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; case 1: // D1:单击选定 if (g === 'tap') { this._selectTea(); this._playChain(['tut_water'], { onDone: () => this._drillGoto(2) }); return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; case 2: // D2:双击提壶(将沸窗内;偏了真话归因+重教,≤2 次后自动演示通过) if (g === 'doubletap') { const [lo, hi] = this._waterWindow(); const now = this._brewNow(); if (now >= lo && now <= hi) { this._waterJudge(false, () => this._playChain(['tut_steep'], { onDone: () => this._drillGoto(3) })); } else if (d.misses < 2) { d.misses++; this._clearBrewTimers(); this._sfx('kettle_lift'); const miss = now < lo ? 'early' : 'late'; this._playChain(['water_' + miss, 'tut_water'], { onDone: () => this._restartWaterForDrill() }); } else { this._drillAuto(2); } return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; case 3: // D3:双击出汤(滴答密时;容错同 D2) if (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._playChain(['steep_' + miss, 'tut_steep'], { onDone: () => this._restartSteepForDrill() }); } else { this._drillAuto(3); } return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; case 4: // D4:单击端茶 → 反应链 → tut_status if (g === 'tap') { this._sfx('pour_cup'); this._reactionChain(() => { this.state = 'SERVE'; this.audio.play('bgm/serve'); this.guide.state('explore'); this.audio.setRateBoostEnabled(true); this._playChain(['tut_status'], { onDone: () => this._drillGoto(5) }); }); return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; case 5: // D5:上滑状态链 → tut_done → 完成 if (g === 'up') { this._statusChain(); this._playChain(['tut_done'], { onDone: () => this._drillFinish() }); return true; } if (g === 'down') { this._skipDrill(); return true; } this._invalid(); return true; default: return false; } } _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 位客人门铃,自由营业 } _skipDrill() { this._clearDrillTimers(); this._drill = null; this.meta.drillDone = true; this._writeProgress(false); // 回到正常流:卡在教学的等待步按当前子相位恢复可玩性 const b = this._brew; const needPreview = b && b.sub === 'TEA' && !b.tea; // R1-F2:跳过者补最小帮助(与茶柜预览串成单链,互不争抢) this._playChain(['tut_skip'], { onDone: () => this._playChain(['sys_help'], { onDone: () => { if (needPreview) this._previewTea(); }, }), }); if (!b) return; if (needPreview) { /* 预览在链尾接播 */ } else if (b.judged) { // 已判定未端茶:直接接反应链 this.state = 'SERVE'; this.audio.play('bgm/serve'); this.guide.state('explore'); this.audio.setRateBoostEnabled(true); this._reactionChain(() => this._nextGuest()); } // WATER/STEEP 等待中:时间线照走,E1 双击即可动手 } /** 90s 自动替打(演示通过不卡流程,§5)。 */ _drillAuto(step) { const d = this._drill; if (!d) return; switch (step) { case 0: vibrate('confirm'); this._sfx('tea_jar'); this._playChain([TEAS[this._shelf()[0]].name, 'tut_tea'], { onDone: () => this._drillGoto(1) }); break; case 1: this._selectTea(); this._playChain(['tut_water'], { onDone: () => this._drillGoto(2) }); break; case 2: { const [lo, hi] = this._waterWindow(); this._brewElapsed = (lo + hi) / 2; // 演示:拨到窗中点 this._brewBase = performance.now(); this._waterJudge(false, () => this._playChain(['tut_steep'], { onDone: () => this._drillGoto(3) })); 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.state = 'SERVE'; this.audio.play('bgm/serve'); this.guide.state('explore'); this.audio.setRateBoostEnabled(true); this._playChain(['tut_status'], { onDone: () => this._drillGoto(5) }); }); break; case 5: this._statusChain(); this._playChain(['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'); this._chainSeq++; // 停链/冻结计时 if (this._chain) { this._resumeChain = { ids: this._chain.ids, i: this._chain.i, onDone: this._chain.onDone, skippable: this._chain.skippable }; this._chain = null; } this.audio.stopVoice(); if (this.state === 'BREW' && this._brew && this._brew.sub !== 'TEA') this._freezeBrewTimeline(); const items = this._menuItems(); this._playChain(['sys_menu', items[0], 'settings_hint']); } _closeMenu() { this._menuOpen = false; this._shopOpen = false; this.guide.disarm(); // 恢复签名 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'); // 恢复被停链/冻结时间线(A2 count-in 口径,§2.3) 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._playChain([recap]); return; } if (this._resumeChain) { const rc = this._resumeChain; this._resumeChain = null; this._playChain(rc.ids.slice(rc.i), { onDone: rc.onDone, skippable: rc.skippable }); } } _onMenuGesture(g) { const items = this._menuItems(); if (this._shopOpen) { this._onShopGesture(g); return; } if (g === 'tap') { this._menuIdx = (this._menuIdx + 1) % items.length; this._playChain([items[this._menuIdx]]); return; } if (g === 'doubletap') { const item = items[this._menuIdx]; if (item === 'sys_menu_resume') this._closeMenu(); else if (item === 'sys_menu_help') this._playChain(['sys_help']); else if (item === 'sys_menu_upg') this._openShop(); else this._saveAndHome(); return; } if (g === 'up') { this._playChain(['sys_help']); return; } if (g === 'down') { this._closeMenu(); return; } this._invalid(); } // ---------------- 打烊置办二级列表(§2.5) ---------------- _openShop() { this._shopOpen = true; this._shopIdx = 0; this._playChain(['upg_open', UPGRADES[0].voice]); } _onShopGesture(g) { if (g === 'left' || g === 'right') { this._shopIdx = (this._shopIdx + (g === 'right' ? 1 : -1) + UPGRADES.length) % UPGRADES.length; this._playChain([UPGRADES[this._shopIdx].voice]); return; } if (g === 'tap') { // 单击买定 this._buyUpgrade(UPGRADES[this._shopIdx]); return; } if (g === 'doubletap') { // 重听当前项 this._playChain([UPGRADES[this._shopIdx].voice]); return; } if (g === 'down') { this._shopOpen = false; this._menuIdx = 1; this._playChain(['sys_menu_upg']); return; } if (g === 'up') { this._playChain(['sys_help']); return; } this._invalid(); } _buyUpgrade(u) { if (this.meta.upgrades.includes(u.id)) { this._playChain([u.voice]); // 已置办:重播报价句(无重复扣款) return; } if (u.repReq && this.meta.rep < u.repReq) { this._playChain(['upg_lock']); return; } if (u.needDebtFree && this.meta.debt > 0) { this._playChain(['upg_lock']); return; } if (this.meta.purse < u.cost) { this._playChain(['upg_none']); return; } this.meta.purse -= u.cost; this.meta.upgrades.push(u.id); this._sfx('shop_buy'); this._sfx('upg_install'); this._playChain([u.voice, 'sys_saved'], { onDone: () => this._saveAuto() }); this._progressTrack(); } // ================================================================ 存档(§10) _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._playChain(['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._playChain(['sys_continue_ask']); } }) .catch(() => { /* 离线静默 */ }); } }