四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
1426 lines
54 KiB
JavaScript
1426 lines
54 KiB
JavaScript
/**
|
||
* 《深夜茶馆》—— 经营养成纯音频游戏(批次 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(() => { /* 离线静默 */ });
|
||
}
|
||
}
|