Files
SoundGame/web/teahouse/game.js
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1426 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《深夜茶馆》—— 经营养成纯音频游戏(批次 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(() => { /* 离线静默 */ });
}
}