站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述;本章给的是可跑、可断言、可在浏览器里真拖一遍的输入抽象层与手感参数模型。区别在写法:那篇把平台差异当成一句「注意兼容」带过,本章把差异摆成一张可统计、可估工时的矩阵,并把它接回前两节的抽象层。
前两节把输入做成了「归一化 + 手势 + 手感」三段可测的代码,但它们都默认了一件事:所有平台的事件都能被同一套归一化函数吃进去。这个假设在浏览器里成立,到了小游戏宿主就要打问号。这一节就来做这件事——把平台差异一条条列清楚,再算清楚适配它们要付多少代价。
差异到底差在哪三个地方
平台输入差异不是「一堆零散的 bug」,它可以归成三个来源。第一是事件模型:浏览器现在给的是 PointerEvent(统一鼠标、触摸、触控笔,事件带 pointerId),而小游戏宿主给的是 TouchEvent(touches 是全部活动触点、changedTouches 是本次变化的触点,没有 pointerId)。这不是命名差异,而是语义差异——前者一根指针一个 id,后者一次事件可能带多个触点。第二是取消事件:浏览器叫 pointercancel,小游戏宿主叫 onTouchCancel,被系统弹窗或来电打断时触发,名字不同但作用一样。第三是坐标与安全区:宿主用设备像素比缩放画布,安全区(刘海、圆角、底部手势条)要通过宿主自己的系统信息 API 取,浏览器里则用 CSS 的 env(safe-area-inset-*)。
把这三点展开成一张表,就是这一节的 D 层产物。注意表格里的平台单元格是公开可观察机制(观察时点 2026-10),真实行为须在真机上核对,因此整体标注为需真机验证(X);而权重与复用系数是本模型自设的输入,不是平台数据。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "平台输入差异矩阵。平台单元格是「公开可观察机制」(观察时点 2026-10),真实行为须真机核对(X)。权重与复用系数是本模型自设的输入,不是平台数据。",
"baseline": "web",
"costPerWeight": 6,
"dimensions": [
{ "id": "eventApi", "name": "指针事件 API 名", "weight": 3 },
{ "id": "multiTouchDelivery", "name": "多点触控投递方式", "weight": 2 },
{ "id": "cancelEvent", "name": "取消事件名", "weight": 2 },
{ "id": "safeArea", "name": "安全区 API", "weight": 3 },
{ "id": "coordOrigin", "name": "画布坐标原点", "weight": 3 }
],
"platforms": [
{
"id": "web", "name": "浏览器", "reusePct": 0,
"attrs": { "eventApi": "PointerEvent", "multiTouchDelivery": "perPointer", "cancelEvent": "pointercancel", "safeArea": "none", "coordOrigin": "topLeft" }
},
{
"id": "wechat", "name": "微信小游戏", "reusePct": 100,
"attrs": { "eventApi": "wx.onTouchStart", "multiTouchDelivery": "touchesArray", "cancelEvent": "onTouchCancel", "safeArea": "systemInfo.safeArea", "coordOrigin": "topLeft" }
},
{
"id": "douyin", "name": "抖音小游戏", "reusePct": 40,
"attrs": { "eventApi": "tt.onTouchStart", "multiTouchDelivery": "touchesArray", "cancelEvent": "onTouchCancel", "safeArea": "systemInfo.safeArea", "coordOrigin": "topLeft" }
},
{
"id": "qq", "name": "QQ 小游戏", "reusePct": 40,
"attrs": { "eventApi": "qq.onTouchStart", "multiTouchDelivery": "touchesArray", "cancelEvent": "onTouchCancel", "safeArea": "systemInfo.safeArea", "coordOrigin": "topLeft" }
},
{
"id": "baidu", "name": "百度小游戏", "reusePct": 60,
"attrs": { "eventApi": "swan.onTouchStart", "multiTouchDelivery": "touchesArray", "cancelEvent": "onTouchCancel", "safeArea": "systemInfo.safeArea", "coordOrigin": "topLeft" }
}
]
}
五个维度各有权重:eventApi(3)、multiTouchDelivery(2)、cancelEvent(2)、safeArea(3)、coordOrigin(3)。coordOrigin 是刻意放进去的对照维度——所有平台都是左上角原点,所以它不产生任何差异,但它留在矩阵里,是为了让「某个平台哪天换成了左下角原点」这件事一眼可见。权重本身是工程判断:改一个事件名(eventApi)比改坐标原点(coordOrigin)影响面小,所以后者权重更高。
S 层:统计差异条目并估算适配工时
有了矩阵,下一步是把它变成数字。下面这段是 S 层(仿真层)脚本:以 web 为参照,逐平台统计在哪些维度上存在差异,按权重换算「基础适配工时」,再乘平台复用系数得到「边际适配工时」。全整数运算,图表用纯 Python SVG。
#!/usr/bin/env python3
# file: sim/e06_inputdiff.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e06_inputdiff.py --config config/ --out out/
"""平台输入差异矩阵统计与适配成本估算。
读 config/e06_inputdiff.json:以 baseline 平台为参照,逐平台统计在哪些
输入维度上存在差异,按维度权重换算「基础适配工时」,再乘平台复用系数得到
「边际适配工时」。全整数运算,图表用纯 Python SVG。
产出:out/e06_inputdiff.json + out/e06_inputdiff_platforms.csv
+ out/e06_inputdiff_dims.csv + out/e06_inputdiff.svg
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "e06_inputdiff.json"), encoding="utf-8") as f:
return json.load(f)
def draw_svg(path, rows, cost_per_weight):
"""纯 Python SVG:每个平台一根横条,长度正比于边际适配工时。"""
W, H, PAD, ROW = 720, 70 + 46 * len(rows), 150, 46
peak = max((r["adaptedHours"] for r in rows), default=1) or 1
svg = ET.Element(
"svg",
{"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"},
)
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
"各平台输入适配边际工时(小时,每权重 %d 小时)" % cost_per_weight
)
bar_max = W - PAD - 30
for i, r in enumerate(rows):
y = 54 + i * ROW
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = r["name"]
w = (bar_max * r["adaptedHours"]) // peak
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
"rx": "4", "fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
"fill": "#6b7280"}).text = "%d 项差异 · %d h" % (r["diffCount"], r["adaptedHours"])
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config)
dims = cfg["dimensions"]
base_id = cfg["baseline"]
cost = cfg["costPerWeight"]
by_id = {p["id"]: p for p in cfg["platforms"]}
base = by_id[base_id]["attrs"]
rows = []
for p in cfg["platforms"]:
if p["id"] == base_id:
continue
diff = [d for d in dims if p["attrs"][d["id"]] != base[d["id"]]]
weight = sum(d["weight"] for d in diff)
base_hours = weight * cost
adapted_hours = (base_hours * p["reusePct"]) // 100
rows.append({
"id": p["id"], "name": p["name"], "reusePct": p["reusePct"],
"diffIds": [d["id"] for d in diff], "diffCount": len(diff),
"diffWeight": weight, "baseHours": base_hours, "adaptedHours": adapted_hours,
})
distinct = sorted({d for r in rows for d in r["diffIds"]})
report = {
"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
"baseline": base_id, "costPerWeight": cost,
"dimensionCount": len(dims), "platformCount": len(cfg["platforms"]),
"distinctDiffDimensions": distinct,
"distinctDiffCount": len(distinct),
"totalBaseHours": sum(r["baseHours"] for r in rows),
"totalAdaptedHours": sum(r["adaptedHours"] for r in rows),
"platforms": rows,
}
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "e06_inputdiff.svg"), rows, cost)
report["svg"] = os.path.join(args.out, "e06_inputdiff.svg")
report["svgBytes"] = svg_bytes
with open(os.path.join(args.out, "e06_inputdiff.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "e06_inputdiff_platforms.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "reusePct", "diffCount", "diffWeight", "baseHours", "adaptedHours", "diffIds"])
for r in rows:
w.writerow([r["id"], r["name"], r["reusePct"], r["diffCount"], r["diffWeight"],
r["baseHours"], r["adaptedHours"], "|".join(r["diffIds"])])
with open(os.path.join(args.out, "e06_inputdiff_dims.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "weight", "webValue"])
for d in dims:
w.writerow([d["id"], d["name"], d["weight"], base[d["id"]]])
print(json.dumps({k: report[k] for k in
("distinctDiffCount", "totalBaseHours", "totalAdaptedHours")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"distinctDiffCount": 4, "totalBaseHours": 240, "totalAdaptedHours": 144}
同时落盘 out/e06_inputdiff_platforms.csv(每平台一行)与 out/e06_inputdiff.svg(工时条形图)。CSV 内容是这样:
id,name,reusePct,diffCount,diffWeight,baseHours,adaptedHours,diffIds
wechat,微信小游戏,100,4,10,60,60,eventApi|multiTouchDelivery|cancelEvent|safeArea
douyin,抖音小游戏,40,4,10,60,24,eventApi|multiTouchDelivery|cancelEvent|safeArea
qq,QQ 小游戏,40,4,10,60,24,eventApi|multiTouchDelivery|cancelEvent|safeArea
baidu,百度小游戏,60,4,10,60,36,eventApi|multiTouchDelivery|cancelEvent|safeArea
维度表 out/e06_inputdiff_dims.csv 记的是每个维度的权重与浏览器侧的参照值,它是矩阵的「另一半」——平台表说明「差在哪」,维度表说明「这些差各值多少」:
id,name,weight,webValue
eventApi,指针事件 API 名,3,PointerEvent
multiTouchDelivery,多点触控投递方式,2,perPointer
cancelEvent,取消事件名,2,pointercancel
safeArea,安全区 API,3,none
coordOrigin,画布坐标原点,3,topLeft
数字读出来的三个结论
第一,四家小游戏平台的差异维度完全相同,都是四项。 distinctDiffCount = 4(eventApi / multiTouchDelivery / cancelEvent / safeArea),每个平台 diffCount 都是 4、diffWeight 都是 10。coordOrigin 没有出现在任何一行的 diffIds 里,因为四家宿主与浏览器一样用左上角原点——这正是那个对照维度的作用:它证明了「原点差异」在当前不是问题,但也把「哪天它变成问题」留在了矩阵里。差异维度完全相同,意味着一套抽象层可以同时覆盖四家,不需要为每家写一套。
第二,基础适配工时是 240 小时,但边际工时只有 144 小时。 totalBaseHours = 240 是「每家平台各付一次 10 权重 × 6 小时 = 60 小时」的朴素加总;totalAdaptedHours = 144 是引入复用系数后的结果。差值 96 小时(40%)就是抽象层的价值:第一个平台(微信,reusePct = 100)承担完整的抽象层建设成本 60 小时,之后抖音 / QQ(reusePct = 40)各只付 24 小时,百度(reusePct = 60)付 36 小时。复用系数不是平台属性,而是工程策略——它衡量的是「新平台的适配有多少能落在已有抽象层上」。
第三,成本分布提示了开发顺序。 微信是 reusePct = 100 的首发平台,成本最高但必须先做,因为抽象层就是为它建的;抖音与 QQ 复用率相同(40%),可以并行推进;百度复用率略高(60%),放在最后做最划算。这条顺序不是拍脑袋定的,而是矩阵算出来的——先做承担抽象层建设的平台,再做复用抽象层的平台,整体成本最低。
这份矩阵的正确用法:X 与真机核对
必须把话说死:矩阵里的平台单元格是公开可观察机制,不是本机实测。本机跑不出微信宿主,也跑不出抖音宿主,所以「微信的取消事件叫 onTouchCancel」这类结论需要在真机上核对,本节把它整体标为 X(不可复现)。矩阵的价值不在于「它一定是准的」,而在于它给出了一个结构化的核对清单:拿到真机后,逐个平台逐个维度打勾,任何一格与预期不符,就说明抽象层要补一条分支。
这也是为什么配置里 note 字段特意写明「真实行为须真机核对(X)」。工程上最危险的做法,是把一份「看起来很像真的」的矩阵当成事实直接排期——权重可以拍、复用系数可以估,但平台行为必须真机验证。第 15 章的兼容性设备矩阵会把这份清单扩展成一张带真机结果的验收表。
把矩阵接回抽象层
矩阵不只是排期工具,它直接对应代码结构。回看 6.1 的 normalizePointer:view.origin 一个字段吸收原点差异,view.scaleNum / scaleDen 吸收缩放差异——这两个字段就是矩阵里 coordOrigin 与 safeArea 两行的落点。而 RawPointer 结构体(id / clientX / clientY / type / tMs)则是为「把 TouchEvent 的 changedTouches 和 PointerEvent 都映射进来」准备的:宿主适配器负责把自家事件字段填进 RawPointer,归一化函数之后的部分一行都不用改。
这就是「一份逻辑、两种宿主」在输入层的最小形态:平台差异被压缩到适配器里,抽象层之上全是平台无关的手势与手感。第 10 章会把这条思路扩展到平台 API 的全局抽象——登录、支付、广告、分享——输入层是它的第一个、也是最容易被验证的样板。
小结与下一步
这一节把平台输入差异从「注意兼容」变成了三个可操作的产物:一张五维矩阵、一份可跑的统计脚本、一组带权重的适配工时估算。四家小游戏平台的差异维度完全一致,抽象层可以一次覆盖;240 小时的朴素成本被复用系数压到 144 小时,差值就是抽象层的价值。
到这里,输入这一章就完整了:6.1 把事件归一化成手势,6.2 把跟随做成可调参数,6.3 把平台差异收进矩阵与适配器。下一节进入物理——但和多数人的直觉相反,《合成小岛》并不需要一套完整物理引擎,它需要的是先给物理需求分级,再决定用多重的方案。下一节就从「物理需求分级」讲起。
阅读导航:上一节:拖拽、滑动与手感参数 · 下一节:小游戏的物理需求分级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。