站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述;本章给的是可跑、可断言、可在浏览器里真拖一遍的输入抽象层与手感参数模型。区别在写法:那篇讲「小游戏开发是怎么回事」,本章把「拖一个方块」这件事拆成可调整数参数,再用无头浏览器真的按下、移动、松开一次鼠标来验证。
上一节把手势识别做完了,输入抽象层能告诉玩法「玩家正在拖拽」。但识别出「拖拽」只是第一步——玩家拖的时候,方块是硬贴着手指走,还是带一点延迟追上去?松手之后是停在原地,还是吸到最近的格子?这两件事决定了手感,而它们不是逻辑问题,是参数问题。
手感不是玄学,是一组可调参数
「手感」常被当成玄学,但它其实可以被拆成几个具体、可测的量。拖拽的手感至少由四个量决定:跟手程度(方块多快追上手指)、死区(手指动多少以内方块不动)、最大速度(方块每帧最多走多少像素,防止瞬移)、松手吸附(离格子多近就对齐)。把这四个量写成配置,手感就从「凭感觉调」变成了「改数字看结果」。
《合成小岛》是网格合成游戏,方块最终必须落在格子上,所以松手吸附几乎是刚需。但「跟手」反而不该做成硬贴:完全硬贴(方块每帧 = 手指位置)在快速拖动时显得生硬、没有重量感;带一点跟随系数,方块会像被弹簧牵引一样追上去,反而更「跟手」。这跟第 12 章的缓动时间轴是同一套审美。
下面这份是 D 层(静态层)产物,全整数,跟随系数用整数比 followNum/followDen,其余都是像素整数。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "拖拽跟随手感参数:跟随系数用整数比 followNum/followDen,死区 / 最小步 / 最大速度 / 吸附半径全为像素整数,禁浮点。",
"followNum": 3,
"followDen": 4,
"deadZonePx": 2,
"minStepPx": 6,
"maxSpeedPx": 40,
"dropSnapPx": 28,
"gridPx": 60
}
P 层:整数跟随模型
下面这段是 P 层(内核层)产物:core/src/e06/feel.ts。它是纯函数式的手感内核——输入当前状态和目标点,输出下一帧状态。不碰渲染、不碰事件,因此既能被浏览器原型调用,也能被单元测试直接跑。全整数、无 Math.random()。
// file: core/src/e06/feel.ts
// tier: P
// verify: node --test 'core/test/e06_*.test.ts'
//
// 《合成小岛》拖拽手感内核:整数跟随模型(跟随系数 + 死区 + 最小步 + 速度上限),
// 松手时按网格吸附。全整数、无 Math.random()、只用可擦除语法。
export interface FeelConfig {
version: number; seed: number;
followNum: number; followDen: number;
deadZonePx: number; minStepPx: number; maxSpeedPx: number;
dropSnapPx: number; gridPx: number;
}
export interface DragState { x: number; y: number; vx: number; vy: number; frames: number }
export interface ReleaseResult { x: number; y: number; snapped: boolean }
function fdiv(a: number, b: number): number { return Math.floor(a / b); }
function fclamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; }
/** 切比雪夫距离(整数,无 sqrt)。 */
function fmaxAbs(a: number, b: number): number { return Math.max(Math.abs(a), Math.abs(b)); }
export function newDrag(x: number, y: number): DragState {
return { x, y, vx: 0, vy: 0, frames: 0 };
}
/**
* 单帧跟随:朝目标移动。
* 死区内不动;否则步长 = floor(dist * followNum / followDen),再夹到 [minStepPx, maxSpeedPx];
* 步长够到目标就直接贴上。全整数,无浮点。
*/
export function stepDrag(s: DragState, tx: number, ty: number, cfg: FeelConfig): DragState {
const dx = tx - s.x, dy = ty - s.y;
const dist = fmaxAbs(dx, dy);
if (dist <= cfg.deadZonePx) return { x: s.x, y: s.y, vx: 0, vy: 0, frames: s.frames + 1 };
const raw = fdiv(dist * cfg.followNum, cfg.followDen);
const step = fclamp(raw, cfg.minStepPx, cfg.maxSpeedPx);
if (step >= dist) return { x: tx, y: ty, vx: dx, vy: dy, frames: s.frames + 1 };
const nx = s.x + fdiv(dx * step, dist);
const ny = s.y + fdiv(dy * step, dist);
return { x: nx, y: ny, vx: nx - s.x, vy: ny - s.y, frames: s.frames + 1 };
}
/** 松手吸附:离最近网格点 <= dropSnapPx 就吸附,否则原地落点。 */
export function onRelease(s: DragState, cfg: FeelConfig): ReleaseResult {
const gx = Math.round(s.x / cfg.gridPx) * cfg.gridPx;
const gy = Math.round(s.y / cfg.gridPx) * cfg.gridPx;
if (fmaxAbs(gx - s.x, gy - s.y) <= cfg.dropSnapPx) return { x: gx, y: gy, snapped: true };
return { x: s.x, y: s.y, snapped: false };
}
/** 从起点朝目标连跑若干帧直到收敛,返回终态(用于仿真与断言)。 */
export function dragTo(s: DragState, tx: number, ty: number, cfg: FeelConfig, maxFrames: number): DragState {
let cur = s;
for (let i = 0; i < maxFrames; i += 1) {
const next = stepDrag(cur, tx, ty, cfg);
if (next.x === cur.x && next.y === cur.y) return next;
cur = next;
}
return cur;
}
单帧跟随的核心是一行:step = floor(dist * followNum / followDen),再用 clamp 夹到 [minStepPx, maxSpeedPx]。dist 用切比雪夫距离(max(|dx|,|dy|))而不是欧氏距离,是为了全程整数、避开 sqrt——对阈值判定来说,两种距离的差别不影响结论。距离在死区内就原地不动;步长够到目标就直接贴上(避免永远差最后几像素);否则沿方向移动 step 像素。
五个参数各管什么
| 参数 | 值 | 作用 | 调大的后果 |
|---|---|---|---|
followNum/followDen | 3/4 | 每帧追上剩余距离的比例 | 更跟手,但过硬、缺重量感 |
deadZonePx | 2 | 目标与当前距离 ≤ 此值不动 | 微抖动被吞,但贴合变松 |
minStepPx | 6 | 超过死区后每帧至少走这么多 | 收敛更快,但低速时略跳 |
maxSpeedPx | 40 | 每帧位移上限 | 快速拖动更跟手,过大则瞬移 |
dropSnapPx | 28 | 松手时离网格点多近算吸附 | 吸附更宽容,过大则误吸 |
minStepPx 是这套模型里最容易被忽略、也最关键的一个。如果只有 followNum/followDen,那么当剩余距离很小时,floor(dist * 3/4) 会取到 0,方块永远差最后几像素贴合不上——表现为「方块总在手指旁边一点点」。加一个最小步,就消除了这个渐近尾巴。这是用整数运算复刻连续跟随时的典型补丁,值得记住。
下面这段是 P 层测试文件 core/test/e06_feel.test.ts 的实跑输出,它把每个参数的行为都锁进断言(同机相对量级,耗时不可移植):
✔ 死区内不移动(vx/vy 归零)
✔ 单帧位移被速度上限截断
✔ 跟随收敛到目标:从 (120,120) 到 (240,180) 六帧贴合
✔ 松手吸附:落在网格点附近就对齐
✔ 同一输入两遍结果一致(无隐藏随机)
ℹ pass 14
ℹ fail 0
第三行是关键:从 (120,120) 拖到 (240,180),模型在六帧内贴合到目标。第一帧走 maxSpeedPx=40(因为剩余距离大,跟随系数算出的步长被上限截住),之后每帧按 3/4 比例收窄,最后一步直接贴上。第六帧就到位,意味着在 60fps 下大约 100ms 内方块就追上了手指——这个延迟肉眼几乎察觉不到,但足以产生「被牵引」的观感。
W 层:单文件 HTML 可玩原型
手感光看数字不直观,得能在浏览器里真拖一遍。下面这段是 W 层(网页层)的构建脚本 web/build_e06.mjs:它复用 web/pack.mjs 的擦除逻辑,把 input.ts 与 feel.ts 内联进一个单文件 HTML,产物是 web/dist/e06.html。注意它没有自己重写 stripTypeScriptTypes,而是 import { bundle, page, writePage, inlineConfig } from "./pack.mjs"——这是本套所有 W 层的统一约定。
#!/usr/bin/env node
// file: web/build_e06.mjs
// tier: W
// verify: node web/build_e06.mjs
// 把 core/src/e06/{input,feel}.ts 的输入抽象 + 手感内核打成单文件 HTML 可玩原型。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/e06.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 inputCfg = JSON.parse(readFileSync(join(ROOT, "config", "e06_input.json"), "utf8"));
const feelCfg = JSON.parse(readFileSync(join(ROOT, "config", "e06_feel.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e06/input.ts", "core/src/e06/feel.ts"]);
const script = `const inputCfg = ${inlineConfig(inputCfg)};
const feelCfg = ${inlineConfig(feelCfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d"), GRID = feelCfg.gridPx;
let state = newDrag(2 * GRID, 2 * GRID);
let target = { x: state.x, y: state.y };
const rec = new GestureRecognizer(inputCfg);
let gesture = "none", snapped = false, dragging = false, followFrames = 0;
function sample(e, phase) {
const r = cv.getBoundingClientRect();
return normalizePointer({ id: 1, clientX: e.clientX - r.left, clientY: e.clientY - r.top, type: phase, tMs: Math.round(e.timeStamp) },
{ widthPx: cv.width, heightPx: cv.height, origin: "topLeft", scaleNum: 1, scaleDen: 1 });
}
function draw() {
g.clearRect(0, 0, cv.width, cv.height);
g.strokeStyle = "#1e293b"; g.lineWidth = 1;
for (let x = 0; x <= cv.width; x += GRID) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, cv.height); g.stroke(); }
for (let y = 0; y <= cv.height; y += GRID) { g.beginPath(); g.moveTo(0, y); g.lineTo(cv.width, y); g.stroke(); }
g.strokeStyle = "#0ea5e9"; g.setLineDash([4, 4]); g.strokeRect(target.x - 24, target.y - 24, 48, 48); g.setLineDash([]);
g.fillStyle = "#38bdf8"; g.beginPath(); g.roundRect(state.x - 24, state.y - 24, 48, 48, 8); g.fill();
document.getElementById("pos").textContent = state.x + "," + state.y;
document.getElementById("ges").textContent = gesture;
globalThis.__pebble = { x: state.x, y: state.y, target: { x: target.x, y: target.y }, frames: followFrames,
gesture, snapped, dragging, gridPx: GRID, canvasDrawn: true, api };
}
cv.addEventListener("pointerdown", (e) => {
const s = sample(e, "down"); rec.feed(s); target = { x: s.x, y: s.y }; dragging = true; draw();
});
cv.addEventListener("pointermove", (e) => {
const s = sample(e, "move"); const gs = rec.feed(s); if (gs) gesture = gs.kind;
if (dragging) { target = { x: s.x, y: s.y }; state = stepDrag(state, target.x, target.y, feelCfg); draw(); }
});
cv.addEventListener("pointerup", (e) => {
const s = sample(e, "up"); const gs = rec.feed(s); if (gs) gesture = gs.kind;
if (dragging) { state = dragTo(state, target.x, target.y, feelCfg, 60); followFrames = state.frames; const rel = onRelease(state, feelCfg); snapped = rel.snapped; state = newDrag(rel.x, rel.y); dragging = false; draw(); }
});
const api = {
set(x, y) { state = newDrag(x, y); target = { x, y }; draw(); },
step(tx, ty) { state = stepDrag(state, tx, ty, feelCfg); draw(); return { x: state.x, y: state.y, frames: state.frames }; },
release() { const rel = onRelease(state, feelCfg); snapped = rel.snapped; state = newDrag(rel.x, rel.y); draw(); return rel; },
get() { return { x: state.x, y: state.y, gesture, snapped, frames: state.frames }; },
};
draw();
`;
const body = ` <h1>《合成小岛》· 拖拽手感原型</h1>
<div class="sub">输入抽象 + 手感内核来自 core/src/e06/:可播种、全整数、无 Math.random</div>
<div class="card row"><div><div class="k">落点 (x,y)</div><div class="big" id="pos">120,120</div></div><div><div class="k">识别手势</div><div class="big" id="ges">none</div></div></div>
<div class="card"><canvas id="stage" width="480" height="360"></canvas></div>
<div class="sub">用鼠标按住方块拖动:松手后按 60px 网格吸附</div>`;
const css = "#stage { width: 480px; height: 360px; }";
const bytes = writePage(join(ROOT, "web/dist/e06.html"), page({ title: "《合成小岛》拖拽手感原型 · e06", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e06.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
W 层:无头浏览器真跑一次拖拽
光有 HTML 还不够,得能自动验证它。下面这段是 W 层验证脚本 web/verify_e06.mjs:它用 Playwright 打开单文件 HTML,真的模拟一次拖拽——mouse.move 到位、mouse.down 按下、分三段 mouse.move 拖动、mouse.up 松开——然后读回 globalThis.__pebble,断言落点、吸附、手势,并截图存档。注意 playwright 是用 createRequire(import.meta.url) 加载的,因为 NODE_PATH 对 ESM 的 import 无效。
#!/usr/bin/env node
// file: web/verify_e06.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e06.mjs web/dist/e06.html
// W 层验收:Playwright 无头打开单文件 HTML,用真实鼠标事件模拟一次拖拽
// (mouse.down / mouse.move / mouse.up),断言对象最终位置与状态 + 截图。
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/e06.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
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;
};
// 一次真实拖拽:从 (120,120) 拖到 (240,180),慢速移动让时长超过滑动上限,识别为 drag
async function doDrag(page) {
const box = await page.locator("#stage").boundingBox();
const at = (x, y) => ({ x: box.x + x, y: box.y + y });
const p0 = at(120, 120), p1 = at(160, 140), p2 = at(200, 160), p3 = at(240, 180);
await page.mouse.move(p0.x, p0.y);
await page.mouse.down();
await page.mouse.move(p1.x, p1.y);
await page.waitForTimeout(260);
await page.mouse.move(p2.x, p2.y);
await page.waitForTimeout(260);
await page.mouse.move(p3.x, p3.y);
await page.waitForTimeout(260);
await page.mouse.up();
}
const page = await open();
await doDrag(page);
const snap = await page.evaluate(() => {
const s = globalThis.__pebble;
const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 480, 360).data;
let painted = 0;
for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) painted += 1;
return { x: s.x, y: s.y, target: s.target, gesture: s.gesture, snapped: s.snapped, frames: s.frames, gridPx: s.gridPx, painted };
});
const domPos = (await page.textContent("#pos")).trim();
const domGes = (await page.textContent("#ges")).trim();
await page.screenshot({ path: join(ROOT, "out/shots/e06_drag.png"), fullPage: true });
await page.close();
// 第二遍:全新加载,做同一次拖拽,验可复现
const page2 = await open();
await doDrag(page2);
const again = await page2.evaluate(() => ({ x: globalThis.__pebble.x, y: globalThis.__pebble.y, snapped: globalThis.__pebble.snapped }));
await page2.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(snap.gridPx, 60, "网格应为 60px");
assert.equal(snap.x, 240, "拖拽后 x 应吸附到 240");
assert.equal(snap.y, 180, "拖拽后 y 应吸附到 180");
assert.equal(snap.snapped, true, "松手应吸附到网格");
assert.equal(snap.gesture, "drag", "慢速拖拽应识别为 drag");
assert.ok(snap.frames > 0, `应经历跟随帧,实测 ${snap.frames}`);
assert.equal(domPos, "240,180", "DOM 落点应显示 240,180");
assert.equal(domGes, "drag", "DOM 手势应显示 drag");
assert.ok(snap.painted > 500, `canvas 应有绘制内容,实测 ${snap.painted} px`);
assert.deepEqual(again, { x: 240, y: 180, snapped: true }, "同一次拖拽应可复现");
console.log(JSON.stringify({
page: "web/dist/e06.html", finalPos: { x: snap.x, y: snap.y }, target: snap.target, gesture: snap.gesture,
snapped: snap.snapped, followFrames: snap.frames, domPos, domGes, canvasPaintedPx: snap.painted,
rerun: again, screenshot: "out/shots/e06_drag.png",
}, null, 2));
console.log("SMOKE-W6: PASS");
先构建、再验证,本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ node web/build_e06.mjs
{ "html": "web/dist/e06.html", "htmlBytes": 11523, "bundleLines": 162 }
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e06.mjs web/dist/e06.html
{
"page": "web/dist/e06.html",
"finalPos": { "x": 240, "y": 180 },
"gesture": "drag",
"snapped": true,
"followFrames": 6,
"domPos": "240,180",
"domGes": "drag",
"canvasPaintedPx": 13520,
"rerun": { "x": 240, "y": 180, "snapped": true },
"screenshot": "out/shots/e06_drag.png"
}
SMOKE-W6: PASS
这段验证到底验证了什么
无头脚本里的拖拽不是「调 API 改状态」,而是从真实鼠标事件走了一遍完整链路:mouse.down 触发页面的 pointerdown → 归一化 → 手势状态机记下起点;三段 mouse.move 触发 pointermove → 每段调用一次 stepDrag,方块朝手指追;mouse.up 触发 pointerup → dragTo 收敛 → onRelease 吸附。断言读到 finalPos = (240,180),正好是拖拽终点所在的网格点,snapped = true 说明吸附生效,gesture = "drag" 说明手势状态机把「慢速拖拽」正确判成了拖拽而非滑动(脚本特意在每段移动间 waitForTimeout(260),累计超过 swipeMaxMs)。
followFrames = 6 与上一节 P 层测试里的「六帧贴合」对上了——同一个内核,在浏览器里跑出的帧数与在单元测试里跑出的帧数一致。这不是巧合,而是同一份 feel.ts 被两处消费的必然结果:单元测试直接调它,浏览器原型内联它,两者输入相同、输出就相同。这正是本套反复强调的「一份逻辑、两种宿主」。
小结与下一步
这一节把「手感」拆成了五个可调整数参数,并让它在浏览器里真的能被拖一次、被断言一次。手感从主观感受变成了「改数字、看断言、看截图」的闭环。
但到这里,所有讨论都建立在同一个假设上:所有平台的事件都能被同一套归一化函数吃进去。这个假设在浏览器里成立,在小游戏宿主里未必——不同宿主的事件名、多点触控投递方式、安全区 API 都不一样。下一节就把这些差异摆到一张矩阵上,算一算把它们适配齐要付多少代价。
阅读导航:上一节:触控输入抽象与手势识别 · 下一节:平台输入差异适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。