Files
SoundGame/web/harness/sessions/hunt.mjs
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

195 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《围猎》新手会话剧本(26 号重构版):清空进度走首局教学房——T0 点一下 →
* T1 朝狼嚎滑砍(先故意滑错听一次「在另一边。」)→ T2 呼喊听 bot 应答 →
* T3 长按问路;接第 1 波滑砍+呼喊+长按状态,波间休整开一次菜单(左右换项、
* 单击确认),第 2 波后收终局结算,单击再来一局收工。
*
* 联机注记:转写环境无真实服务器——会话注入内存回路 WS mock(23 §6 协议),
* join/act 由 mini 服务器应答(spawn/kill/miss/callout/wave/end);手势经
* makeInputPipeline 管道(与 index.html 同一接线,回执/打断/防连点均参与)。
*/
import { makeInputPipeline } from '../../engine/pipeline.js';
export function describe(game) {
if (game._menuOpen) return `菜单里第 ${game._menuIdx + 1}/3 项(左右滑换项,单击确认)`;
switch (game.phase) {
case 'title': return '围猎大厅,点一下进入匹配';
case 'matching': return '正在匹配队友';
case 'lobby': return '房间已开好,点一下立即开局(也可以按住听在场人数)';
case 'teach':
return game._tutDoneWaiting ? '教学讲完了,等第一波'
: ['教学 T0:点一下', '教学 T1:朝狼嚎的方向滑', '教学 T2:点一下呼喊队友',
'教学 T3:按住屏幕听营地'][game._tut] || '教学中';
case 'wave':
return game._inRest() ? `第 ${game.wave} 波守住了,休整 8 秒(下滑开菜单)`
: `第 ${game.wave} 波战斗中:朝声音滑=出击,点一下=呼喊,按住=我在哪`;
case 'end': return '结算播完了,单击再猎一局(按住重听最佳战绩)';
case 'lost': return '掉线回不去了,点一下回大厅';
default: return '……';
}
}
/** 内存回路 WS mock + mini 服务器(对 act 应答 ev;23 §6 字段契约)。 */
function makeAutoServer(log) {
const conns = [];
const arcs = { N: ['NW', 'N', 'NE'], E: ['NE', 'E', 'SE'], S: ['SE', 'S', 'SW'], W: ['SW', 'W', 'NW'] };
const srv = { mobs: new Map(), nextId: 1, kills: 0, started: false, wave: 0 };
const send = (conn, msg) => conn.handlers.onmessage(msg);
const transport = (url, handlers) => {
const conn = {
url, handlers, opened: false, closed: false,
send(s) {
let m = null;
try { m = JSON.parse(s); } catch (_) { return; }
if (!m || m.t !== 'act') return;
if (m.kind === 'start' && !srv.started) {
srv.started = true;
setTimeout(() => send(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 1 }), 400);
} else if (m.kind === 'attack') {
const hit = [...srv.mobs.values()].find((x) => (arcs[m.dir] || []).includes(x.dir));
if (hit) {
srv.mobs.delete(hit.id);
srv.kills++;
setTimeout(() => send(conn, { t: 'ev', kind: 'kill', ts: Date.now(), id: hit.id, slot: 0, dir: hit.dir, mkind: hit.mkind }), 120);
} else {
setTimeout(() => send(conn, { t: 'ev', kind: 'miss', ts: Date.now(), slot: 0, dir: m.dir }), 120);
}
} else if (m.kind === 'callout') {
setTimeout(() => send(conn, { t: 'ev', kind: 'botline', ts: Date.now(), slot: 2, line: 'help_ack' }), 1500);
}
},
close() { if (!conn.closed) { conn.closed = true; handlers.onclose(); } },
};
conns.push(conn);
return conn;
};
return {
transport, srv, conns,
last: () => conns[conns.length - 1],
open: (conn) => { conn.opened = true; conn.handlers.onopen(); },
inject: send,
joined: (conn, over = {}) => send(conn, {
t: 'joined', room: 'LIVE', slot: 0, resume: 'r0', char: 'yan',
sector: [['N', 'NE', 'E'], ['SE', 'S', 'SW'], ['W', 'NW']][0], seed: 7,
state: { phase: 'lobby', wave: 0, dur: 10 },
roster: [
{ slot: 0, name: '试玩者', bot: false, char: 'yan', sector: ['N', 'NE', 'E'] },
{ slot: 1, name: '小筝', bot: true, char: 'zheng', sector: ['SE', 'S', 'SW'] },
{ slot: 2, name: '老周', bot: true, char: 'zhou', sector: ['W', 'NW'] },
],
...over,
}),
spawn: (conn, mkind, dir, eta) => {
const id = 'm' + srv.nextId++;
srv.mobs.set(id, { id, mkind, dir });
send(conn, { t: 'ev', kind: 'spawn', ts: Date.now(), id, mkind, dir, eta });
return id;
},
};
}
export async function session(ctx, mock) {
const { game } = ctx;
// 首局新手:清进度 + 全新手阶梯(双击折算单击),手势接管为 26 号管道
try {
localStorage.removeItem('soundgame.progress.hunt');
localStorage.removeItem('soundgame.gestures');
} catch (_) { /* mock 缺 localStorage 时静默 */ }
const pipe = makeInputPipeline({
floor: game.floor, confusion: game.confusion, receipts: game.receipts,
invalidHints: game.invalidHints, game, ladder: game.ladder,
opts: game.PIPELINE_OPTS || {},
});
const rawGesture = ctx.gesture;
ctx.gesture = async (g) => { pipe(g); await mock.flush(); };
// 注入内存服务器(transcribe mock 环境无 WS 服务器;真实浏览器走默认传输连 /ws/hunt)
if (mock) {
const server = makeAutoServer((x) => ctx.note(x));
game._transport = server.transport;
ctx.status('大厅:篝火夜的音乐,先听一耳朵');
await ctx.gesture('tap'); // TITLE 单击 → 匹配
const conn = server.last();
server.open(conn);
await ctx.waitCond(() => server.conns.includes(conn) && conn.opened);
ctx.note('点了开始匹配,教开局的那两句在播——试着点了一下,它说稍等(critical 期 busy)');
await rawGesture.call(ctx, 'tap'); // critical 期点按 → busy 回执(不打断指令)
await ctx.waitIdle();
ctx.status('进教学房了:第一句就是「点一下屏幕」');
server.joined(conn, { state: { phase: 'teach', wave: 0, dur: 10 } });
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 0);
await ctx.gesture('tap'); // T0 作答 → T1
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 1);
server.spawn(conn, 'wolf', 'N', 12); // 教学狼从北边叫了一声
ctx.note('北边一声狼嚎——指令说「朝叫声,滑」,我偏先往左滑一下试试');
await ctx.gesture('left');
await ctx.waitCond(() => game.phase === 'teach' && (game._tut === 1));
ctx.note('挥空了:一声风声加「在另一边。」——真话归因,不糊弄');
await ctx.waitVirtual(3000); // 过出击冷却(miss 归因句也在这段里播完)
await ctx.gesture('up'); // 朝叫声滑 → 首杀
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 2);
ctx.status('杀掉了:它说「对,就是这样。」然后教呼喊');
server.spawn(conn, 'wolf', 'E', 10);
await ctx.gesture('tap'); // T2 呼喊(等待点上作答)
ctx.note('我喊了一嗓子,老周从西边应了我一声——协作闭环');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN' && game._tut === 3);
await ctx.gesture('longpress'); // T3 长按问路
await ctx.waitCond(() => game._tutDoneWaiting);
ctx.status('按住听了下营地状态:固若金汤,朝叫声滑就行');
// 第 1 波:滑砍 + 呼喊 + 长按
server.inject(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 1 });
await ctx.waitCond(() => game.phase === 'wave');
server.spawn(conn, 'wolf', 'NE', 9);
server.spawn(conn, 'wolf', 'W', 10);
await ctx.waitVirtual(5000); // 听 loop 从两侧逼近
await ctx.gesture('up');
ctx.note('朝东北那声滑了一刀(冷却 2.5 秒,节奏感出来了)');
await ctx.waitVirtual(2600);
await ctx.gesture('left');
await ctx.gesture('tap');
await ctx.gesture('longpress');
await ctx.waitVirtual(2000);
// 波间休整:开一次菜单,换项后确认回猎场
server.inject(conn, { t: 'ev', kind: 'waveclear', ts: Date.now(), wave: 1 });
server.srv.mobs.clear();
await ctx.waitCond(() => game._inRest() && game.floor.state === 'YOUR_TURN');
ctx.status('这一波守住了,8 秒休整——下滑开菜单');
await ctx.gesture('down');
await ctx.waitCond(() => game._menuOpen && game.floor.state === 'YOUR_TURN');
await ctx.gesture('right'); // 换到「重新听帮助」
await ctx.waitCond(() => !ctx.voiceBusy());
await ctx.gesture('left'); // 换回「回到猎场」
await ctx.waitCond(() => !ctx.voiceBusy() && game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap'); // 单击确认关闭
ctx.note('菜单是左右滑换项、单击确认——比旧的「双击确认」顺手,不会误触');
await ctx.waitVirtual(4000);
// 第 2 波 → 终局
server.inject(conn, { t: 'ev', kind: 'wave', ts: Date.now(), wave: 2 });
await ctx.waitCond(() => game.phase === 'wave' && !game._inRest());
server.spawn(conn, 'wolf', 'S', 8);
server.spawn(conn, 'bear', 'SE', 11);
await ctx.waitVirtual(6000);
await ctx.gesture('down');
await ctx.gesture('up');
ctx.note('熊的脚步沉得多,还从身后发闷——南边压力最大');
await ctx.waitVirtual(6000);
server.inject(conn, { t: 'ev', kind: 'end', ts: Date.now(), win: true, dur: 7, grade: 'B', kills: { '0': server.srv.kills, '1': 6, '2': 5 } });
await ctx.waitCond(() => game.phase === 'end');
ctx.status('天亮了:评级 B 的结算链在播,尾句教了「向下滑可以打开菜单」');
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('longpress'); // 重听最佳战绩
await ctx.waitCond(() => !ctx.voiceBusy() && game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap'); // 再来一局
await ctx.waitCond(() => game.phase === 'matching');
ctx.status('收工:单击再猎一局,回匹配了');
} else {
ctx.note('真实浏览器环境:WS 走默认传输连 /ws/hunt,本剧本只覆盖 mock 转写场景');
}
}