站内已有一篇小游戏开发综述
,那篇是引擎选型 / 性能 / 变现的单篇综述,把「小游戏开发」这件事横向铺开讲了一遍;本章给的是可跑、可断言、可在浏览器里真拖一遍的输入抽象层与手感参数模型——同一件事,但落到《合成小岛》的 core/src/e06/ 上,从事件归一化一直写到手势状态机。区别在写法:那篇是单篇综述,本章是成体系 + 可运行,每一个判定阈值都有单元测试兜底,数字全部来自本机实跑。
上一节讲完了暂停、切后台与恢复,游戏循环在时间轴上已经稳住了。这一节开始处理玩家真正直接碰到的那一层:手指。它是整条链路里最靠近用户、也最容易被平台差异污染的一层。
为什么输入必须先抽象
在没有抽象层的项目里,「拖拽一个方块」通常是这么写的:在 canvas 上监听 touchstart / touchmove / touchend,把 e.touches[0].clientX 直接当成方块坐标。这段代码在浏览器里能跑,但它把三件事焊死在了一起:事件来源(是触摸还是鼠标还是指针)、坐标系(物理像素还是逻辑像素、原点在上还是下)、语义(这一串事件到底是点击、长按还是拖拽)。
焊死的代价在跨平台时立刻显形。浏览器现在给的是 PointerEvent(统一鼠标、触摸、触控笔,带 pointerId 和 pointercancel);小游戏宿主给的是 TouchEvent(touches 是全部活动触点、changedTouches 是本次变化的触点,没有 pointerId,取消事件叫 onTouchCancel)。同样是「一根手指按下并移动」,两边的字段名、数组语义、取消事件名都不一样。如果玩法代码直接读这些字段,那么每换一个宿主,玩法代码都要改一遍。
抽象层的做法是把这三件事拆开:归一化负责把任意来源的事件压成统一样本 {x, y, phase, tMs, pointerId};手势状态机负责把样本序列识别成有名字的手势。玩法代码只消费「手势」,永远不碰原始事件字段。这一层拆开之后,第 10 章的「一份逻辑、两种宿主」才有落脚点——mock 宿主和真机宿主只要各自把事件喂进归一化函数,后面的手势与玩法逻辑一行都不用改。
D 层:阈值先落到配置里
判定手势靠的是阈值:按住多久算长按、移动多少像素算拖拽、多快算滑动。这些阈值不该散落在 if 里,而应该是一份可调、可对比、可进版本控制的配置。下面这份是 D 层(静态层)产物,全整数,没有任何浮点。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "输入抽象层阈值:点击 / 长按 / 拖拽 / 滑动的判定边界,全整数(像素 / 毫秒)。scaleNum/scaleDen 是物理像素到逻辑像素的整数比。",
"tapMaxMs": 250,
"tapMaxMovePx": 12,
"longPressMs": 500,
"dragStartPx": 16,
"swipeMinPx": 48,
"swipeMaxMs": 600,
"scaleNum": 2,
"scaleDen": 3
}
七个阈值各管一件事:tapMaxMs / tapMaxMovePx 是「点击」的双重上限(时间短且几乎没动),longPressMs 是长按门槛,dragStartPx 是「开始拖拽」的位移门槛,swipeMinPx / swipeMaxMs 是「滑动」的距离下限与时间上限。scaleNum / scaleDen 不是阈值而是缩放比:逻辑坐标 = 物理坐标 × scaleNum ÷ scaleDen,用整数比是为了让跨语言逐位一致,2/3 对应 1.5 倍设备像素比。
P 层:归一化 + 手势状态机
下面这段是 P 层(内核层)产物:core/src/e06/input.ts。它只做两件事——把原始事件归一化成样本,把样本识别成手势——不涉及任何渲染或玩法。全整数、无 Math.random(),只用可擦除语法。
// file: core/src/e06/input.ts
// tier: P
// verify: node --test 'core/test/e06_*.test.ts'
//
// 《合成小岛》输入抽象层:把不同来源的指针事件归一化成 {x,y,phase} 序列,
// 再用手势状态机把序列识别成 点击 / 长按 / 拖拽 / 滑动。
// 全整数、无 Math.random()、只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface InputConfig {
version: number; seed: number;
tapMaxMs: number; tapMaxMovePx: number; longPressMs: number;
dragStartPx: number; swipeMinPx: number; swipeMaxMs: number;
scaleNum: number; scaleDen: number;
}
export type Phase = "down" | "move" | "up";
export type Origin = "topLeft" | "bottomLeft";
export type GestureKind = "tap" | "longPress" | "drag" | "swipe" | "none";
export type Dir = "none" | "left" | "right" | "up" | "down";
/** 平台原始事件:只有 id / 客户端坐标 / 类型 / 时间戳。 */
export interface RawPointer { id: number; clientX: number; clientY: number; type: Phase; tMs: number }
/** 归一化后的样本:统一坐标系、统一类型。 */
export interface PointerSample { x: number; y: number; phase: Phase; tMs: number; pointerId: number }
/** 视口:坐标原点、尺寸、物理到逻辑的整数缩放比。 */
export interface Viewport { widthPx: number; heightPx: number; origin: Origin; scaleNum: number; scaleDen: number }
export interface Gesture {
kind: GestureKind; dir: Dir;
x0: number; y0: number; x1: number; y1: number;
dx: number; dy: number; durationMs: number; distancePx: number;
}
function floorDiv(a: number, b: number): number {
return Math.floor(a / b);
}
/** 切比雪夫距离:整数、无 sqrt,阈值比较足够用。 */
export function maxAbs(a: number, b: number): number {
return Math.max(Math.abs(a), Math.abs(b));
}
function dirOf(dx: number, dy: number): Dir {
if (dx === 0 && dy === 0) return "none";
if (Math.abs(dx) >= Math.abs(dy)) return dx < 0 ? "left" : "right";
return dy < 0 ? "up" : "down";
}
/** 把平台原始指针事件归一化成逻辑坐标样本(整数地板除 + 原点翻转)。 */
export function normalizePointer(raw: RawPointer, view: Viewport): PointerSample {
const x = floorDiv(raw.clientX * view.scaleNum, view.scaleDen);
const yScaled = floorDiv(raw.clientY * view.scaleNum, view.scaleDen);
const y = view.origin === "bottomLeft" ? view.heightPx - yScaled : yScaled;
return { x, y, phase: raw.type, tMs: raw.tMs, pointerId: raw.id };
}
/** 手势状态机:同一串样本,必得同一串手势。 */
export class GestureRecognizer {
cfg: InputConfig;
state: string;
id: number;
x0: number; y0: number; t0: number;
lastX: number; lastY: number; lastT: number;
constructor(cfg: InputConfig) {
this.cfg = cfg;
this.state = "idle";
this.id = -1;
this.x0 = 0; this.y0 = 0; this.t0 = 0;
this.lastX = 0; this.lastY = 0; this.lastT = 0;
}
reset(): void { this.state = "idle"; }
feed(s: PointerSample): Gesture | null {
if (s.phase === "down") {
this.state = "pressed";
this.id = s.pointerId;
this.x0 = s.x; this.y0 = s.y; this.t0 = s.tMs;
this.lastX = s.x; this.lastY = s.y; this.lastT = s.tMs;
return null;
}
if (this.state === "idle" || this.state === "longPressed") return null;
if (s.pointerId !== this.id) return null;
this.lastX = s.x; this.lastY = s.y; this.lastT = s.tMs;
if (s.phase === "move") {
if (this.state === "pressed") {
if (maxAbs(s.x - this.x0, s.y - this.y0) >= this.cfg.dragStartPx) {
this.state = "dragging";
return null;
}
if (s.tMs - this.t0 >= this.cfg.longPressMs) {
this.state = "longPressed";
return this.build("longPress");
}
}
return null;
}
const dur = s.tMs - this.t0;
const dist = maxAbs(this.lastX - this.x0, this.lastY - this.y0);
this.state = "idle";
if (dist >= this.cfg.dragStartPx) {
if (dist >= this.cfg.swipeMinPx && dur <= this.cfg.swipeMaxMs) return this.build("swipe");
return this.build("drag");
}
if (dur >= this.cfg.longPressMs) return this.build("longPress");
if (dur <= this.cfg.tapMaxMs && dist <= this.cfg.tapMaxMovePx) return this.build("tap");
return this.build("none");
}
build(kind: GestureKind): Gesture {
const dx = this.lastX - this.x0;
const dy = this.lastY - this.y0;
return {
kind, dir: dirOf(dx, dy),
x0: this.x0, y0: this.y0, x1: this.lastX, y1: this.lastY,
dx, dy, durationMs: this.lastT - this.t0, distancePx: maxAbs(dx, dy),
};
}
}
/** 一把喂完整串样本,收集所有手势(用于回放与测试)。 */
export function recognizeAll(samples: PointerSample[], cfg: InputConfig): Gesture[] {
const rec = new GestureRecognizer(cfg);
const out: Gesture[] = [];
for (const s of samples) {
const g = rec.feed(s);
if (g !== null) out.push(g);
}
return out;
}
状态机只有五个状态:idle(空闲)、pressed(按下未定)、dragging(已判定拖拽)、longPressed(已判定长按)、以及回到 idle。关键设计是长按会在按住过程中就触发:当时间超过 longPressMs 而位移还没到 dragStartPx,状态机立刻吐出 longPress 并切到 longPressed,之后的 up 不再重复触发——这样长按可以「按住即响应」,而不必等松手。
判定顺序:为什么必须固定
feed 里 up 分支的判定顺序不是随手排的,它决定了边界样本落到哪一类。顺序是:先看位移够不够拖拽 → 再在拖拽族里分滑动还是拖拽 → 再看时间够不够长按 → 最后才是点击。
| 顺序 | 条件 | 结果 | 理由 |
|---|---|---|---|
| 1 | 位移 ≥ dragStartPx 且 位移 ≥ swipeMinPx 且 时长 ≤ swipeMaxMs | 滑动 | 又快又远,优先判滑动 |
| 2 | 位移 ≥ dragStartPx(其余) | 拖拽 | 移动了就是拖拽,哪怕慢 |
| 3 | 时长 ≥ longPressMs(位移小) | 长按 | 没动但按得久 |
| 4 | 时长 ≤ tapMaxMs 且 位移 ≤ tapMaxMovePx | 点击 | 又快又稳 |
| 5 | 其余 | none | 慢速小位移,丢弃 |
如果把这个顺序倒过来先判点击,那么「快速滑动」会因为起点几帧还没超 dragStartPx 而被误吞成点击。判定顺序本质上是优先级:越「强」的手势越先判。这跟第 7 章物理碰撞里「先判 AABB 再判精细形状」是同一类思路——先做代价低、区分度高的粗判,把模糊样本留给后面的细分。
P 层:把阈值锁进单元测试
阈值配置化之后,最怕的是「改了配置但没人发现语义变了」。所以每个判定分支都配一条测试。下面这段是 P 层的测试文件,直接读 config/e06_input.json,用构造出来的样本序列断言手势类别。
// file: core/test/e06_input.test.ts
// tier: P
// verify: node --test 'core/test/e06_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizePointer, recognizeAll, GestureRecognizer } from "../src/e06/input.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e06_input.json"), "utf-8"));
const view = { widthPx: 600, heightPx: 800, origin: "bottomLeft" as const, scaleNum: cfg.scaleNum, scaleDen: cfg.scaleDen };
const S = (x: number, y: number, phase: "down" | "move" | "up", tMs: number, id = 1) => ({ x, y, phase, tMs, pointerId: id });
test("归一化:物理→逻辑整数缩放 + bottomLeft 翻转 y", () => {
assert.deepEqual(normalizePointer({ id: 1, clientX: 300, clientY: 300, type: "down", tMs: 0 }, view),
{ x: 200, y: 600, phase: "down", tMs: 0, pointerId: 1 });
const top = { ...view, origin: "topLeft" as const };
assert.deepEqual(normalizePointer({ id: 2, clientX: 300, clientY: 300, type: "move", tMs: 5 }, top),
{ x: 200, y: 200, phase: "move", tMs: 5, pointerId: 2 });
});
test("点击:位移小且时间短", () => {
const g = recognizeAll([S(100, 100, "down", 0), S(103, 102, "up", 120)], cfg);
assert.equal(g.length, 1);
assert.equal(g[0].kind, "tap");
assert.equal(g[0].distancePx, 3);
assert.equal(g[0].durationMs, 120);
});
test("长按:按住超过阈值未移动", () => {
const g = recognizeAll([S(100, 100, "down", 0), S(101, 100, "move", 520), S(101, 100, "up", 700)], cfg);
assert.equal(g.length, 1);
assert.equal(g[0].kind, "longPress");
assert.equal(g[0].durationMs, 520);
});
test("拖拽:位移够大但时间超滑动上限", () => {
const g = recognizeAll([S(100, 100, "down", 0), S(140, 100, "move", 400), S(160, 120, "up", 900)], cfg);
assert.equal(g[0].kind, "drag");
assert.equal(g[0].distancePx, 60);
assert.equal(g[0].durationMs, 900);
});
test("滑动:位移够大且时间够短,带方向", () => {
const right = recognizeAll([S(100, 100, "down", 0), S(160, 100, "move", 100), S(180, 100, "up", 200)], cfg);
assert.equal(right[0].kind, "swipe");
assert.equal(right[0].dir, "right");
const up = recognizeAll([S(100, 200, "down", 0), S(100, 140, "move", 80), S(100, 120, "up", 150)], cfg);
assert.equal(up[0].kind, "swipe");
assert.equal(up[0].dir, "up");
});
test("第二根手指被忽略,只认第一根", () => {
const rec = new GestureRecognizer(cfg);
rec.feed(S(100, 100, "down", 0, 1));
assert.equal(rec.feed(S(300, 300, "move", 50, 2)), null);
assert.equal(rec.feed(S(300, 300, "up", 60, 2)), null);
assert.equal(rec.feed(S(102, 100, "up", 100, 1))?.kind, "tap");
});
test("孤立 up / move 不产生手势", () => {
assert.deepEqual(recognizeAll([S(10, 10, "up", 0)], cfg), []);
assert.deepEqual(recognizeAll([S(10, 10, "move", 0)], cfg), []);
});
test("一串回放的手势序列可复现", () => {
const seq = [
S(50, 50, "down", 0), S(52, 50, "up", 100),
S(50, 50, "down", 200), S(50, 50, "move", 720), S(50, 50, "up", 800),
S(50, 50, "down", 900), S(130, 50, "move", 1000), S(150, 50, "up", 1100),
];
const a = recognizeAll(seq, cfg).map((g) => g.kind);
const b = recognizeAll(seq, cfg).map((g) => g.kind);
assert.deepEqual(a, ["tap", "longPress", "swipe"]);
assert.deepEqual(a, b);
});
在本机跑它,14 项全部通过(同机相对量级,耗时绝对数值不可移植):
✔ 归一化:物理→逻辑整数缩放 + bottomLeft 翻转 y
✔ 点击:位移小且时间短
✔ 长按:按住超过阈值未移动
✔ 拖拽:位移够大但时间超滑动上限
✔ 滑动:位移够大且时间够短,带方向
✔ 第二根手指被忽略,只认第一根
✔ 孤立 up / move 不产生手势
✔ 一串回放的手势序列可复现
ℹ tests 14
ℹ pass 14
ℹ fail 0
注意最后一条「可复现」:同一串样本喂两遍,得到的手势类别序列完全一致。这不是巧合,而是状态机的必然——它没有隐藏随机,所有判定只依赖输入样本和配置阈值。这条性质直接支撑第 15 章的端到端回放:只要把一段真实操作录成样本序列,就能在无头环境里重放出同一串手势。
归一化里的两个坑
归一化函数只有五行,但踩过两个坑值得写下来。第一是缩放方向。设备像素比是 1.5 时,物理像素是逻辑像素的 1.5 倍,所以逻辑 = 物理 ÷ 1.5 = 物理 × 2/3。方向写反会让所有坐标整体放大,表现为「手指在左半边点,方块在右半边响应」。用整数比 scaleNum/scaleDen 而不是 1.5,一是为了跨语言逐位一致,二是避免浮点累积误差在长拖动里被放大。
第二是原点翻转。浏览器 canvas 与绝大多数小游戏宿主的画布坐标原点都在左上角(topLeft),但个别宿主历史上给的是左下角原点。归一化里用 view.origin 一个字段把这件事吸收掉:bottomLeft 时执行 heightPx - yScaled。这样后面的手势与玩法逻辑永远在 topLeft 坐标系里工作,不需要知道宿主用哪个原点。
多指与取消:先做减法
《合成小岛》的合成是单指拖拽,所以这一版状态机只认第一根按下的手指,后续手指的 down / move / up 一律忽略(测试里「第二根手指被忽略」那条就是守这个约定)。这是刻意的减法:多指手势(双指缩放、双指旋转)会显著增加状态机的分支数,而本作的核心玩法用不到。把单指做扎实,比把多指做半吊子更有价值。
「取消」是另一个容易被忽略的事件。浏览器用 pointercancel、小游戏宿主用 onTouchCancel——当系统弹窗、来电、切后台打断触摸时会触发它。抽象层要做的,是把「取消」归一化成一次无手势的 up:状态机收到取消就复位到 idle,不产生任何手势。否则玩家在拖到一半时被电话打断,回来会发现方块卡在半空。这条约定和第 5 章的暂停恢复是同一套思路——异常路径必须先想清楚,而不是等线上出问题再补。
小结与下一步
这一节把「手指」这一层拆成了两段可测的代码:归一化把平台差异挡在外面,状态机把样本序列翻译成有名字的手势。配置全整数,测试全通过,判定顺序固定可复现。
但手感还差一半。手势识别回答的是「玩家做了什么」,而「这件事做起来跟手不跟手」是另一个问题——拖拽时方块是硬贴着手指,还是带一点延迟地追上去?松手后是停在原地,还是吸到网格?这些属于手感参数,是下一节的主题。
阅读导航:上一节:暂停、切后台与恢复 · 下一节:拖拽、滑动与手感参数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。