/** * 《围猎》新手会话剧本(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 转写场景'); } }