站内那篇 小游戏开发综述 是平台能力与留存综述,本节给的是可跑、可断言的渐进式教学内核与浏览器原型:那篇讲「引导重要」,本节把「提示该多强」变成一个按失败次数抬档的整数状态机,并用 Playwright 无头断言它真的抬了。上一节用漏斗测量了玩家在哪一步掉,本节给出救法。
新手引导里最尴尬的一种状态是:玩家反复做同一件事却总失败。硬塞一段长教程会打扰已经会玩的人,完全不管又会让不会的人流失。渐进式教学的原则是按需给力:玩家自己顺就少提示,玩家连续失败就把提示往上抬一档——轻提示(nudge,一个箭头)、高亮指引(guide,把目标高亮)、自动演示(auto,直接演一遍)。再加一条「卡住不动」的兜底:玩家既不成功也不失败、就是停在那儿,时间一到自动给提示。容错侧再补一个撤销栈:误操作能原路撤回。
三档提示 + 两个容错
「三档」不是拍脑袋定的,它对应三种不同强度的介入:nudge 只指方向、不夺操作权;guide 把正确目标高亮、仍由玩家自己点;auto 直接演示一遍、玩家只需看着。强度递增,代价也递增(auto 最像「替玩家玩」,用多了会剥夺成就感),所以阈值要留足间隔:本节的配置是连续失败 1 / 2 / 3 次分别抬到三档。两个容错是:卡住自动提示(sinceProgressMs 超过 stuckMs 就把提示至少抬到 guide)和误操作撤销(一个深度受限的栈,后进先出)。
D 层:教学参数
下面这段是 D 层(静态层)配置 config/e13_teach.json:三档阈值、卡住判定时长、撤销栈深度,全是整数。改阈值就能调「提示来得多快」,不必动内核。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "渐进式教学与容错参数:连续失败 1/2/3 次分别抬到 nudge/guide/auto 三档提示;卡住 stuckMs 后自动给提示;撤销栈深度 undoDepth。全整数。",
"nudgeAfterFails": 1,
"guideAfterFails": 2,
"autoAfterFails": 3,
"stuckMs": 8000,
"undoDepth": 5
}
P 层:渐进式教学内核
下面这段是 P 层(内核层)产物 core/src/e13/teach.ts。tierForFails 把连续失败次数映射到档位;recordFail 失败数 +1 并按阈值抬档;recordSuccess 成功清零、提示回落到 none 并把动作压入撤销栈(超过 undoDepth 丢最旧);tick 累计「无进展」时长,到 stuckMs 就把提示至少抬到 guide,且不会把已有更高档拉低(higher 取两者较强者);undo 后进先出,栈空拒绝且不改状态。全整数、无 Math.random()。
// file: core/src/e13/teach.ts
// tier: P
// verify: node --test 'core/test/e13_*.test.ts'
//
// 《合成小岛》渐进式教学与容错:同一处玩法,按玩家连续失败次数把提示强度往上抬三档
// (nudge 轻提示 → guide 高亮指引 → auto 自动演示),并在玩家「卡住不动」时自动给提示;
// 容错侧给一个有限深度的撤销栈,误操作可原路撤回。全整数计数、无 Math.random()。
export type HintTier = "none" | "nudge" | "guide" | "auto";
export interface TeachConfig {
version: number;
seed: number;
nudgeAfterFails: number; // 连续失败达到此数 → nudge
guideAfterFails: number; // → guide
autoAfterFails: number; // → auto
stuckMs: number; // 无进展超过此时长即判定卡住,自动给提示
undoDepth: number; // 撤销栈深度上限
}
export interface TeachState {
fails: number; // 连续失败次数(成功后清零)
sinceProgressMs: number; // 距上次进展的毫秒数
hint: HintTier;
applied: string[]; // 已生效动作(同时是撤销栈,栈顶在末尾)
}
export interface UndoResult { ok: boolean; undone: string; remaining: number; reason: string }
const ORDER: HintTier[] = ["none", "nudge", "guide", "auto"];
export function newTeach(cfg: TeachConfig): TeachState {
return { fails: 0, sinceProgressMs: 0, hint: "none", applied: [] };
}
/** 连续失败次数对应的提示档(三档阈值,整数比较)。 */
export function tierForFails(cfg: TeachConfig, fails: number): HintTier {
if (fails >= cfg.autoAfterFails) return "auto";
if (fails >= cfg.guideAfterFails) return "guide";
if (fails >= cfg.nudgeAfterFails) return "nudge";
return "none";
}
function rank(t: HintTier): number { return ORDER.indexOf(t); }
function higher(a: HintTier, b: HintTier): HintTier { return rank(a) >= rank(b) ? a : b; }
/** 记录一次失败:失败数 +1,重置「无进展」计时,提示档按阈值上抬。 */
export function recordFail(state: TeachState, cfg: TeachConfig): TeachState {
const fails = state.fails + 1;
return { fails, sinceProgressMs: 0, hint: tierForFails(cfg, fails), applied: state.applied.slice() };
}
/** 记录一次成功:失败数清零、提示回落到 none,动作入撤销栈(超深丢最旧)。 */
export function recordSuccess(state: TeachState, cfg: TeachConfig, actionId: string): TeachState {
const applied = state.applied.concat(actionId);
while (applied.length > cfg.undoDepth) applied.shift();
return { fails: 0, sinceProgressMs: 0, hint: "none", applied };
}
/** 推进时间:无进展累计到 stuckMs 后,至少把提示抬到 guide(自动给提示)。 */
export function tick(state: TeachState, cfg: TeachConfig, dtMs: number): TeachState {
const sinceProgressMs = state.sinceProgressMs + dtMs;
let hint = state.hint;
if (sinceProgressMs >= cfg.stuckMs) hint = higher(hint, "guide");
return { fails: state.fails, sinceProgressMs, hint, applied: state.applied.slice() };
}
/** 撤销最近一次生效动作(误操作可回退)。栈空则拒绝且不改状态。 */
export function undo(state: TeachState, cfg: TeachConfig): { state: TeachState; result: UndoResult } {
if (state.applied.length === 0) {
return { state, result: { ok: false, undone: "", remaining: 0, reason: "nothing-to-undo" } };
}
const applied = state.applied.slice();
const undone = applied.pop() as string;
return { state: { ...state, applied }, result: { ok: true, undone, remaining: applied.length, reason: "undo" } };
}
/** 稳定快照:hint=guide fails=2 applied=merge,shop */
export function dump(state: TeachState): string {
return "hint=" + state.hint + " fails=" + state.fails + " applied=" + state.applied.join(",");
}
下面是 P 层测试 core/test/e13_teach.test.ts 的实跑输出(同机相对量级,耗时不可移植):
✔ 阈值映射:连续失败 0/1/2/3 次 → none/nudge/guide/auto
✔ 连续失败逐次抬档:none → nudge → guide → auto
✔ 成功清零失败并回落到 none
✔ 卡住自动给提示:无进展到 stuckMs 至少抬到 guide
✔ 卡住不会把已有更高档拉低
✔ 成功重置卡住计时
✔ 误操作撤销:后进先出,栈空拒绝且不改状态
✔ 撤销栈封顶:超过 undoDepth 丢最旧
✔ 同一序列两遍结果一致(无隐藏随机)
ℹ tests 9
ℹ pass 9
ℹ fail 0
W 层:单文件 HTML 原型
把内核内联进一个 HTML,浏览器打开就能点着看提示档怎么抬。下面这段是 W 层(网页层)产物 web/build_e13.mjs,它 import { bundle, page, writePage, inlineConfig } from "./pack.mjs" 复用主控的擦除逻辑,把 core/src/e13/teach.ts 打成单文件 HTML。
#!/usr/bin/env node
// file: web/build_e13.mjs
// tier: W
// verify: node web/build_e13.mjs
// 把 core/src/e13/teach.ts 打成单文件 HTML 原型;复用 web/pack.mjs 的擦除逻辑,产物 web/dist/e13.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e13_teach.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e13/teach.ts"]);
const script = `const CFG = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("hint"), g = cv.getContext("2d"), W = cv.width, H = cv.height;
const TIER_ORDER = ["none", "nudge", "guide", "auto"];
let state = newTeach(CFG);
function draw() {
const idx = TIER_ORDER.indexOf(state.hint);
g.clearRect(0, 0, W, H);
const bw = (W - 40) / 4;
for (let i = 0; i < 4; i += 1) {
const x = 20 + i * bw;
g.fillStyle = i <= idx ? "#38bdf8" : "#1e293b";
g.fillRect(x + 6, 34, bw - 12, 56);
g.fillStyle = "#94a3b8"; g.font = "12px system-ui"; g.textAlign = "center";
g.fillText(TIER_ORDER[i], x + bw / 2, 24);
}
document.getElementById("tier").textContent = state.hint;
document.getElementById("fails").textContent = String(state.fails);
document.getElementById("applied").textContent = state.applied.join(",") || "-";
globalThis.__pebble = { hint: state.hint, fails: state.fails, applied: state.applied.slice(), canvasDrawn: true, api };
}
const api = {
fail() { state = recordFail(state, CFG); draw(); return { hint: state.hint, fails: state.fails }; },
succeed(a) { state = recordSuccess(state, CFG, a); draw(); return { hint: state.hint, applied: state.applied.slice() }; },
wait(ms) { state = tick(state, CFG, ms); draw(); return { hint: state.hint, sinceProgressMs: state.sinceProgressMs }; },
undo() { const r = undo(state, CFG); state = r.state; draw(); return { ok: r.result.ok, undone: r.result.undone, applied: state.applied.slice() }; },
sample() { return { hint: state.hint, fails: state.fails, applied: state.applied.slice(), sinceProgressMs: state.sinceProgressMs }; },
reset() { state = newTeach(CFG); draw(); return { hint: state.hint }; },
};
draw();
`;
const body = ` <h1>《合成小岛》· 渐进式教学与容错原型</h1>
<div class="sub">提示档与撤销栈来自 core/src/e13/teach.ts:全整数计数、无 Math.random</div>
<div class="card row"><div><div class="k">提示档</div><div class="big" id="tier">none</div></div><div><div class="k">连续失败</div><div class="big" id="fails">0</div></div></div>
<div class="card"><canvas id="hint" width="480" height="110"></canvas></div>
<div class="card"><div class="k">已生效动作(撤销栈)</div><div id="applied">-</div></div>
<div class="sub">连续失败抬档:1 次 nudge、2 次 guide、3 次 auto;误操作可 undo 原路撤回</div>`;
const css = "#hint { width: 480px; height: 110px; }";
const bytes = writePage(join(ROOT, "web/dist/e13.html"), page({ title: "《合成小岛》渐进式教学原型 · e13", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e13.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
产出 HTML 后,用 Playwright 无头驱动它、断言状态迁移。下面这段是 W 层验收脚本 web/verify_e13.mjs:它用 createRequire 加载 playwright(NODE_PATH 对 ESM import 无效),模拟「连续失败 → 提示升级」「误操作 → 撤销」与「卡住 → 自动提示」三个场景并截图。
#!/usr/bin/env node
// file: web/verify_e13.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e13.mjs web/dist/e13.html
// W 层验收:Playwright 无头打开单文件 HTML,模拟「连续失败 → 提示升级」「误操作 → 撤销」
// 与「卡住 → 自动提示」,断言状态迁移,并截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e13.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const errors = [];
const open = async () => {
const p = await browser.newPage({ viewport: { width: 800, height: 900 } });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(url);
await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
return p;
};
// 场景一:连续失败,提示逐档升级 none → nudge → guide → auto。
const failRun = (page) => page.evaluate(() => {
const a = globalThis.__pebble.api; a.reset();
const tiers = [a.sample().hint];
for (let i = 0; i < 3; i += 1) tiers.push(a.fail().hint);
return { tiers, sample: a.sample() };
});
// 场景二:误操作(多应用一步)后撤销,栈后进先出。
const undoRun = (page) => page.evaluate(() => {
const a = globalThis.__pebble.api; a.reset();
a.succeed("merge"); a.succeed("shop");
const before = a.sample().applied;
const u1 = a.undo();
const u2 = a.undo();
const u3 = a.undo();
return { before, u1, u2, u3, after: a.sample().applied };
});
// 场景三:卡住不动 → 自动给提示(至少 guide)。
const stuckRun = (page) => page.evaluate(() => {
const a = globalThis.__pebble.api; a.reset();
const early = a.wait(4000).hint;
const late = a.wait(4000).hint;
return { early, late, sample: a.sample() };
});
const page = await open();
const fail = await failRun(page);
const undoRes = await undoRun(page);
const stuck = await stuckRun(page);
await page.screenshot({ path: join(ROOT, "out/shots/e13_teach.png"), fullPage: true });
await page.close();
const page2 = await open();
const again = await failRun(page2);
await page2.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.deepEqual(fail.tiers, ["none", "nudge", "guide", "auto"], "连续失败应逐档升级");
assert.equal(fail.sample.fails, 3, "三次失败后 fails=3");
assert.deepEqual(undoRes.before, ["merge", "shop"], "先应用两步");
assert.equal(undoRes.u1.ok, true, "第一步撤销应成功");
assert.equal(undoRes.u1.undone, "shop", "撤销应后进先出取 shop");
assert.equal(undoRes.u2.undone, "merge", "第二次撤销取 merge");
assert.equal(undoRes.u3.ok, false, "空栈撤销应被拒绝");
assert.deepEqual(undoRes.after, [], "撤销完栈应为空");
assert.equal(stuck.early, "none", "未到 stuckMs 不给提示");
assert.equal(stuck.late, "guide", "卡住后自动给提示至少到 guide");
assert.deepEqual(again.tiers, fail.tiers, "同一序列应可复现");
console.log(JSON.stringify({ page: "web/dist/e13.html", failTiers: fail.tiers, failSample: fail.sample,
undo: { before: undoRes.before, u1: undoRes.u1, u2: undoRes.u2, u3: undoRes.u3, after: undoRes.after },
stuck: { early: stuck.early, late: stuck.late }, rerunMatch: JSON.stringify(again.tiers) === JSON.stringify(fail.tiers),
screenshot: "out/shots/e13_teach.png" }, null, 2));
console.log("SMOKE-W13: PASS");
本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ node web/build_e13.mjs && NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e13.mjs web/dist/e13.html
{ "html": "web/dist/e13.html", "htmlBytes": 6920, "bundleLines": 60 }
{
"failTiers": ["none", "nudge", "guide", "auto"],
"undo": { "u1": { "ok": true, "undone": "shop" }, "u2": { "undone": "merge" }, "u3": { "ok": false } },
"stuck": { "early": "none", "late": "guide" },
"rerunMatch": true,
"screenshot": "out/shots/e13_teach.png"
}
SMOKE-W13: PASS
三个断言分别对应三条设计规则:连续失败 1/2/3 次必须依次到 nudge/guide/auto;撤销必须后进先出(先撤 shop 再撤 merge,第三次空栈拒绝);卡住后提示必须至少到 guide。rerunMatch: true 说明同一操作序列两遍结果一致——W 层原型和 P 层内核跑的是同一段逻辑,没有浏览器专属的隐藏随机。
小结与下一步
本章三节合起来是一条完整的引导工程链:13.1 把引导写成可断言的步骤状态机(合法性),13.2 用漏斗量化玩家在哪一步掉、跳过值多少(测量),13.3 用渐进式教学与容错给出救法(干预)。三节读同一份 config/e13_tutorial.json 或各自的 config/e13_teach.json,内核、仿真、原型三处数字对得上。引导做完,玩家的第一分钟就稳了;接下来进入性能优化——让这一分钟在弱机上也不卡。
阅读导航:上一节:引导漏斗埋点与仿真 · 下一节:首包与冷启动优化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。