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