6.3 平台输入差异适配

把浏览器与小游戏宿主的输入差异摆成一张矩阵:事件 API 名、多点触控投递方式、取消事件、安全区、坐标原点五个维度逐平台对比,用 S 层脚本统计差异条目并估算适配工时,讲清权重模型与复用系数如何把「每家平台都要改一遍」压成一次性抽象层成本。

站内已有一篇小游戏开发综述 ,那篇是引擎选型 / 性能 / 变现的单篇综述;本章给的是可跑、可断言、可在浏览器里真拖一遍的输入抽象层与手感参数模型。区别在写法:那篇把平台差异当成一句「注意兼容」带过,本章把差异摆成一张可统计、可估工时的矩阵,并把它接回前两节的抽象层。

前两节把输入做成了「归一化 + 手势 + 手感」三段可测的代码,但它们都默认了一件事:所有平台的事件都能被同一套归一化函数吃进去。这个假设在浏览器里成立,到了小游戏宿主就要打问号。这一节就来做这件事——把平台差异一条条列清楚,再算清楚适配它们要付多少代价。

差异到底差在哪三个地方

平台输入差异不是「一堆零散的 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 把平台差异收进矩阵与适配器。下一节进入物理——但和多数人的直觉相反,《合成小岛》并不需要一套完整物理引擎,它需要的是先给物理需求分级,再决定用多重的方案。下一节就从「物理需求分级」讲起。

阅读导航:上一节:拖拽、滑动与手感参数 · 下一节:小游戏的物理需求分级 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制