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 置完成。
195 lines
9.9 KiB
JavaScript
195 lines
9.9 KiB
JavaScript
/**
|
||
* 《围猎》新手会话剧本(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 转写场景');
|
||
}
|
||
}
|