Matter.js 物理引擎与微信小程序实战

Posted by xyx on 2026-04-10
Words 4.5k and Reading Time 20 Minutes
Viewed Times

Matter.js 物理引擎与微信小程序实战

引言

在前端开发中,物理动效能够极大地提升用户体验——下落的卡片、弹跳的球体、可拖拽的元素等。Matter.js 是一个轻量级的 2D 刚体物理引擎,由 Liam Brummitt 开发,原生 JavaScript 实现(非 C++ 移植),零依赖,MIT 许可证。当前最新版本为 0.20.0(2024-06-23 发布)

但 Matter.js 是为浏览器环境设计的,它内部依赖 windowdocumentDOM 事件等 Web API。当我们想在微信小程序中使用它时,就会遇到一系列环境适配问题。本文将从 Matter.js 的核心概念出发,重点讲解在微信小程序中使用时必须调整的关键变量和注意事项

模块架构

Matter.js 包含 29 个模块,按职责可分为以下几类:

分类 模块 说明
核心物理 Engine, Body, Bodies, Composite, Composites 引擎驱动、刚体创建与管理
碰撞检测 Collision, Detector, Pair, Pairs, Resolver 宽相/窄相碰撞检测与求解
约束与交互 Constraint, MouseConstraint, Mouse 约束关系与鼠标拖拽
渲染与运行 Render, Runner Canvas 渲染与游戏循环
几何与数学 Vector, Vertices, Bounds, Axes 向量运算与几何工具
工具 Events, Sleeping, Plugin, Query, Common 事件系统、休眠、空间查询

在微信小程序中,我们主要使用核心物理碰撞检测模块,而 Render、Runner、Mouse、MouseConstraint 这四个模块需要替换或重写

核心概念

Engine(引擎)

Engine 是整个物理模拟的核心控制器,负责管理世界更新、碰撞检测和求解:

1
2
3
4
5
6
7
const engine = Engine.create({
gravity: { x: 0, y: 1, scale: 0.001 }, // 重力方向与强度
enableSleeping: true, // 启用休眠机制
positionIterations: 6, // 位置修正迭代次数
velocityIterations: 4, // 速度求解迭代次数
constraintIterations: 2, // 约束求解迭代次数
});

关键参数说明:

  • gravity.scale:重力缩放系数,默认 0.001。设为 0 可禁用重力(适合太空场景)。在小程序中如果屏幕尺寸与默认的 800x600 差异较大,可能需要调整此值以获得合适的下落速度
  • positionIterations:值越大碰撞精度越高,但计算开销也越大。小程序中建议降到 4,在精度和性能之间取平衡
  • velocityIterations:同上,小程序中可降到 2
  • timing.timeScale:全局时间缩放,0 为暂停,0.5 为慢动作,1 为正常速度

推进物理模拟的方法:

1
2
// delta 参数为时间步长(毫秒),默认 16.666ms(60fps)
Engine.update(engine, 16.666);

Body 与 Bodies(刚体)

Body 是物理世界中的基本实体,Bodies 是创建预定义形状的工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 矩形
const box = Bodies.rectangle(x, y, width, height, options);

// 圆形(内部是多边形近似)
const ball = Bodies.circle(x, y, radius, options);

// 正多边形
const hex = Bodies.polygon(x, y, 6, radius, options);

// 梯形
const trap = Bodies.trapezoid(x, y, width, height, slope, options);

// 自定义形状(凹多边形需要 poly-decomp 库)
const custom = Bodies.fromVertices(x, y, vertexSets, options);

物理属性速查

这些参数直接决定了物体的物理行为,是调参的重点:

属性 默认值 范围 说明
density 0.001 >0 密度,质量 = 密度 x 面积
friction 0.1 0~1 动摩擦系数,两物体取 Math.min
frictionStatic 0.5 >=0 静摩擦系数,物体从静止开始运动需要克服的阻力
frictionAir 0.01 0~1 空气阻力,0 为太空,0.05 为明显阻力
restitution 0 0~1 弹性恢复系数,两物体取 Math.max。0 为完全无弹性,0.9 为弹力球
slop 0.05 >=0 碰撞容差,允许的轻微穿透量
isStatic false - 静态物体,不受力、不移动
isSensor false - 传感器,触发碰撞事件但无物理反应
sleepThreshold 60 - 近零速度持续多少帧后进入休眠
timeScale 1 >=0 单个物体的时间缩放

典型场景参数参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 弹力球
{ restitution: 0.9, friction: 0.05, frictionAir: 0.01 }

// 重石块
{ density: 0.01, friction: 0.8, restitution: 0.1 }

// 冰面滑块
{ friction: 0, frictionStatic: 0, frictionAir: 0.005 }

// 羽毛(飘落效果)
{ density: 0.0001, frictionAir: 0.08 }

// 阻止旋转的物体
Body.setInertia(body, Infinity);

碰撞过滤器

碰撞过滤系统使用 categorymaskgroup 三个属性控制哪些物体之间可以碰撞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// category 必须是 2 的幂(位标志),最多支持 32 个
const WALL = 0x0001;
const PLAYER = 0x0002;
const ENEMY = 0x0004;
const BULLET = 0x0008;

const player = Bodies.circle(200, 200, 20, {
collisionFilter: {
category: PLAYER,
mask: WALL | ENEMY, // 玩家与墙和敌人碰撞,不与子弹碰撞
}
});

const bullet = Bodies.circle(100, 100, 5, {
collisionFilter: {
category: BULLET,
mask: WALL | ENEMY, // 子弹只与墙和敌人碰撞
}
});

碰撞判定规则:(A.category & B.mask) !== 0 && (B.category & A.mask) !== 0,双向检查都必须通过

group 属性优先级高于 category/mask

  • group > 0:同组物体始终碰撞
  • group < 0:同组物体永不碰撞
  • group === 0:回退到 category/mask 判定

Composite(复合体)

Composite 是 Body、Constraint 和子 Composite 的容器,类似于”分组”的概念:

1
2
3
4
5
6
7
8
9
10
11
// 添加物体到世界
Composite.add(engine.world, [ground, ball, box]);

// 移除物体
Composite.remove(engine.world, body);

// 获取世界中所有物体
const allBodies = Composite.allBodies(engine.world);

// 清空世界(true = 保留静态物体)
Composite.clear(engine.world, true);

Composites 工厂提供了批量创建的快捷方法:

1
2
3
4
5
6
7
8
9
// 网格排列的物体堆
const stack = Composites.stack(100, 100, 10, 5, 5, 5, (x, y) => {
return Bodies.rectangle(x, y, 40, 40);
});

// 链式约束连接
const chain = Composites.chain(stack, 0.5, 0, -0.5, 0, {
stiffness: 0.8,
});

Constraint(约束)

约束维持两个物体(或物体与世界定点)之间的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 两物体之间的弹簧连接
const spring = Constraint.create({
bodyA: bodyA,
bodyB: bodyB,
stiffness: 0.1, // 刚度:1=完全刚性,0.01=非常软
damping: 0.1, // 阻尼:0=无阻尼,0.1=重阻尼
length: 100, // 目标距离(undefined=自动计算)
});

// 将物体固定到世界某点(钟摆效果)
const pin = Constraint.create({
pointA: { x: 400, y: 50 }, // 世界坐标锚点(无 bodyA)
bodyB: pendulum,
length: 200,
stiffness: 1,
});

事件系统

Matter.js 使用标准的发布-订阅模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 碰撞开始
Events.on(engine, 'collisionStart', (event) => {
event.pairs.forEach(pair => {
console.log('碰撞:', pair.bodyA.label, pair.bodyB.label);
});
});

// 碰撞持续 / 碰撞结束
Events.on(engine, 'collisionActive', (event) => { /* ... */ });
Events.on(engine, 'collisionEnd', (event) => { /* ... */ });

// 物理更新前后
Events.on(engine, 'beforeUpdate', (event) => { /* ... */ });
Events.on(engine, 'afterUpdate', (event) => { /* ... */ });

// 休眠事件(在 Body 上监听)
Events.on(body, 'sleepStart', () => { /* 物体进入休眠 */ });

注意:在碰撞事件回调中不要直接移除物体,应先收集需要移除的物体,待事件处理完成后再统一移除,否则可能导致迭代器异常

1
2
3
4
5
6
7
8
9
// 正确的做法:先收集,后移除
Events.on(engine, 'collisionStart', (event) => {
const toRemove = [];
event.pairs.forEach(pair => {
if (pair.bodyA.label === 'bullet') toRemove.push(pair.bodyA);
if (pair.bodyB.label === 'bullet') toRemove.push(pair.bodyB);
});
toRemove.forEach(b => Composite.remove(engine.world, b));
});

微信小程序适配:核心挑战

微信小程序运行在 JSCore 环境中,与浏览器环境存在根本性差异:

浏览器特性 小程序中的情况
window 对象 不存在
document 对象 不存在
DOM 操作 不存在
requestAnimationFrame 仅 Canvas 组件上有 canvas.requestAnimationFrame()
HTMLCanvasElement 使用 <canvas type="2d"> 组件
MouseEvent 只有 TouchEvent

Matter.js 内部多处依赖浏览器环境:

  • Matter.Render:使用 document.createElement('canvas') 创建画布
  • Matter.Runner:使用 window.requestAnimationFrame 驱动循环
  • Matter.Mouse:监听 DOM 的 mousedown/mousemove/mouseup 事件
  • 内部工具函数:部分使用 windowdocument 做特性检测

适配方案

核心思路:只用 Matter.js 的物理计算能力,完全放弃其渲染和运行模块

第一步:环境 Polyfill

在引入 Matter.js 之前,构造一个假的浏览器环境,让 Matter.js 初始化不报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// utils/polyfill.js — 必须在 require('matter') 之前执行

if (typeof window === 'undefined') {
const noop = () => {};

global.window = {
addEventListener: noop,
removeEventListener: noop,
requestAnimationFrame: (cb) => setTimeout(cb, 16),
cancelAnimationFrame: (id) => clearTimeout(id),
devicePixelRatio: wx.getSystemInfoSync().pixelRatio || 2,
};

global.document = {
addEventListener: noop,
removeEventListener: noop,
createElement: (tag) => {
if (tag === 'canvas') {
return {
getContext: () => null,
width: 0, height: 0, style: {},
addEventListener: noop,
removeEventListener: noop,
};
}
return { style: {} };
},
createElementNS: () => ({ style: {} }),
documentElement: { style: {} },
body: { appendChild: noop, removeChild: noop },
};

global.HTMLElement = class HTMLElement {};
global.HTMLCanvasElement = class HTMLCanvasElement {};
}

第二步:Canvas 2D 初始化与 DPR 处理

DPR(设备像素比)处理是小程序中最常踩的坑。处理不当,Canvas 会出现明显的模糊:

1
2
3
4
5
6
7
8
9
<!-- physics.wxml -->
<canvas
type="2d"
id="physicsCanvas"
style="width: {{canvasWidth}}px; height: {{canvasHeight}}px;"
bindtouchstart="onTouchStart"
bindtouchmove="onTouchMove"
bindtouchend="onTouchEnd"
></canvas>

必须使用 type="2d" 的新版 Canvas 组件,旧版 <canvas canvas-id="xxx"> 性能差且 API 不同

1
2
3
4
5
6
7
8
9
10
11
const sysInfo = wx.getSystemInfoSync();
const dpr = sysInfo.pixelRatio; // 通常是 2 或 3
const width = sysInfo.windowWidth; // 逻辑像素宽度
const height = sysInfo.windowHeight;

// 关键:Canvas 实际像素 = CSS 逻辑像素 x DPR
canvas.width = width * dpr;
canvas.height = height * dpr;

// 用 scale 还原坐标系,后续所有绑制均使用逻辑像素坐标
ctx.scale(dpr, dpr);

核心原则:

  • 物理世界坐标 = CSS 逻辑像素坐标(屏幕宽 375px,物理世界就是 375 宽)
  • Canvas 实际像素 = 逻辑像素 x DPR
  • 触摸事件的 touch.x / touch.y 是逻辑像素坐标,可直接用于物理世界

第三步:游戏循环(替代 Matter.Runner)

固定时间步长 + 累加器是最可靠的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
startGameLoop() {
let lastTime = Date.now();
const fixedStep = 1000 / 60; // 16.666ms
let accumulator = 0;

const loop = () => {
const now = Date.now();
let delta = now - lastTime;
lastTime = now;

// 防止掉帧导致物理爆炸(帧间隔超过 100ms 就截断)
if (delta > 100) delta = 100;

accumulator += delta;

// 可能执行 0 次、1 次或多次物理更新
while (accumulator >= fixedStep) {
Engine.update(this.engine, fixedStep);
accumulator -= fixedStep;
}

this.render();
this.rafId = this.canvas.requestAnimationFrame(loop);
};

this.rafId = this.canvas.requestAnimationFrame(loop);
}

为什么不用简单的 Engine.update(engine, delta)?可变时间步长会导致物理模拟不确定——同样的场景在不同设备上表现不同,甚至可能出现物体穿透

第四步:自定义渲染

完全替代 Matter.Render,使用小程序 Canvas 2D API 绑制物体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
render() {
const { ctx, width, height } = this;
ctx.clearRect(0, 0, width, height);

const bodies = Composite.allBodies(this.engine.world);

for (const body of bodies) {
// 跳过不可见区域的物体(性能优化)
if (body.bounds.max.x < 0 || body.bounds.min.x > width ||
body.bounds.max.y < 0 || body.bounds.min.y > height) {
continue;
}

ctx.beginPath();

// 圆形物体用 arc 绑制(比 vertices 更高效更美观)
if (body.circleRadius) {
ctx.arc(body.position.x, body.position.y,
body.circleRadius, 0, Math.PI * 2);
} else {
// 多边形物体用 vertices 绑制
const vertices = body.vertices;
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
}

ctx.fillStyle = body.render?.fillStyle || '#4A90D9';
ctx.fill();
}
}

第五步:触摸事件适配(替代 MouseConstraint)

Matter.js 的 MouseMouseConstraint 依赖 DOM 事件,需要手动实现拖拽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
onTouchStart(e) {
const { x, y } = e.touches[0];
const point = { x, y };

// 查找触摸点下的物体
const bodies = Composite.allBodies(this.engine.world);
for (const body of bodies) {
if (body.isStatic) continue;
if (Matter.Bounds.contains(body.bounds, point) &&
Matter.Vertices.contains(body.vertices, point)) {

// 创建约束将物体"钉"到触摸点
this.dragConstraint = Constraint.create({
pointA: point,
bodyB: body,
pointB: {
x: point.x - body.position.x,
y: point.y - body.position.y,
},
stiffness: 0.1,
damping: 0.1,
length: 0,
});
Composite.add(this.engine.world, this.dragConstraint);
break;
}
}
},

onTouchMove(e) {
if (this.dragConstraint) {
const { x, y } = e.touches[0];
this.dragConstraint.pointA = { x, y };
}
},

onTouchEnd() {
if (this.dragConstraint) {
Composite.remove(this.engine.world, this.dragConstraint);
this.dragConstraint = null;
}
}

小程序关键调参清单

这是在小程序中使用 Matter.js 时最需要关注的变量

引擎层面

参数 浏览器默认值 小程序建议值 原因
positionIterations 6 4 移动端性能有限,降低迭代换取帧率
velocityIterations 4 2 同上
constraintIterations 2 1 同上
enableSleeping false true 静止物体暂停计算,显著提升性能
gravity.scale 0.001 按屏幕比例调整 小程序屏幕尺寸不同于默认 800x600

物体层面

参数 关注点
frictionAir 小屏幕上物体下落很快,适当增大(如 0.02~0.05)让动画更柔和
restitution 弹跳在小屏幕上感知不同,建议实机调试
sleepThreshold 默认 60 帧,快速休眠可降到 30
slop 堆叠抖动时增大到 0.1,精度要求高时减小到 0.01

渲染层面

参数 说明
canvas.width/height 必须 乘以 DPR,否则画面模糊
ctx.scale(dpr, dpr) 必须 设置,否则物理坐标与绘制坐标不一致
动态物体上限 移动端建议 50~100

时间步进

参数 说明
fixedStep 固定使用 1000/60(16.666ms),不要用可变步长
帧间隔上限 超过 100ms 截断,防止物理爆炸

性能优化

1. 启用休眠(最重要)

1
const engine = Engine.create({ enableSleeping: true });

休眠的物体不参与碰撞检测和求解器计算,是对性能最有效的单一优化

2. 控制物体数量

1
2
3
4
5
6
7
8
9
10
const MAX_BODIES = 50;

function addBody(body) {
const dynamics = Composite.allBodies(engine.world)
.filter(b => !b.isStatic);
if (dynamics.length >= MAX_BODIES) {
Composite.remove(engine.world, dynamics[0]);
}
Composite.add(engine.world, body);
}

3. 清理离屏物体

1
2
3
4
5
6
7
8
9
10
11
12
Events.on(engine, 'afterUpdate', () => {
const bodies = Composite.allBodies(engine.world);
const margin = 100;
for (const body of bodies) {
if (body.isStatic) continue;
const { x, y } = body.position;
if (x < -margin || x > width + margin ||
y < -margin || y > height + margin) {
Composite.remove(engine.world, body);
}
}
});

4. 使用简单形状

碰撞检测开销排序:圆形 < 矩形 < 凸多边形 < 凹多边形。尽量避免 Bodies.fromVertices 创建复杂形状

5. 合并静态物体

不要用 100 个小矩形拼一面墙,用一个大矩形即可。静态物体数量多同样会增加宽相检测开销

6. 渲染只画可见物体

利用 body.bounds 做视口裁剪,跳过屏幕外的物体

内存管理

小程序对内存限制严格,页面销毁时必须彻底清理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
onUnload() {
// 1. 停止游戏循环
if (this.rafId) {
this.canvas.cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// 2. 移除事件监听
Events.off(this.engine);
// 3. 清空世界和引擎
Composite.clear(this.engine.world);
Engine.clear(this.engine);
this.engine = null;
// 4. 释放 Canvas 引用
this.canvas = null;
this.ctx = null;
}

可以在 app.js 中监听内存警告,及时减少物体数量:

1
2
3
4
5
App({
onMemoryWarning(res) {
console.warn('内存不足警告,级别:', res.level);
},
});

小程序 vs 小游戏

如果你的需求是做一个独立的物理小游戏而非嵌入小程序页面,微信小游戏(Minigame)是更好的选择:

特性 小程序 小游戏
Canvas 获取方式 组件 query 获取 全局 wx.createCanvas()
requestAnimationFrame canvas.requestAnimationFrame() 全局 requestAnimationFrame()
环境适配 需要手动 polyfill 官方 weapp-adapter 基本覆盖
包体积限制 主包 2MB 主包 4MB
适配难度 较高 较低

小游戏提供了 weapp-adapter.js,会模拟大部分浏览器 API,Matter.js 基本可以直接运行

常见问题

Q:Matter.js 压缩后多大?会超出小程序包体积限制吗?

未压缩约 500KB,Terser 压缩后约 100KB。小程序主包限制 2MB,通常不会超。如果紧张可放在分包中

Q:圆形碰撞不够精确?

Matter.js 中的圆形实际上是多边形近似(默认约 25 条边)。需要更高精度可增加 Bodies.circlemaxSides 参数,但会增加计算开销

Q:物体堆叠时抖动?

增大 positionIterations(10~20)、增大 slop(0.05 -> 0.1)、确保启用 enableSleeping

Q:物体穿过墙壁?

Matter.js 不支持 CCD(连续碰撞检测),小而快的物体可能穿透薄墙。解决方案:增加墙壁厚度、降低物体速度、增加 positionIterations

Q:需要更高性能怎么办?

如果 Matter.js 性能不够,可考虑 Planck.js(Box2D 的 JS 移植,支持 CCD)或 p2.js(更轻量),但同样需要环境适配

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// pages/physics/physics.js
require('../../utils/polyfill');
const Matter = require('../../libs/matter');

const { Engine, Bodies, Body, Composite, Constraint,
Events, Bounds, Vertices } = Matter;

Page({
data: { canvasWidth: 375, canvasHeight: 667 },

onReady() {
const sys = wx.getSystemInfoSync();
this.dpr = sys.pixelRatio;
this.width = sys.windowWidth;
this.height = sys.windowHeight;
this.setData({ canvasWidth: this.width, canvasHeight: this.height });

wx.createSelectorQuery()
.select('#physicsCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
canvas.width = this.width * this.dpr;
canvas.height = this.height * this.dpr;
ctx.scale(this.dpr, this.dpr);

this.canvas = canvas;
this.ctx = ctx;
this.initPhysics();
this.startGameLoop();
});
},

initPhysics() {
this.engine = Engine.create({
gravity: { x: 0, y: 1, scale: 0.001 },
enableSleeping: true,
positionIterations: 4,
velocityIterations: 2,
constraintIterations: 1,
});

const { width, height } = this;
const t = 50;

Composite.add(this.engine.world, [
Bodies.rectangle(width / 2, height + t / 2, width, t, { isStatic: true }),
Bodies.rectangle(-t / 2, height / 2, t, height, { isStatic: true }),
Bodies.rectangle(width + t / 2, height / 2, t, height, { isStatic: true }),
Bodies.rectangle(width / 2, -t / 2, width, t, { isStatic: true }),
]);

for (let i = 0; i < 15; i++) {
Composite.add(this.engine.world, Bodies.circle(
Math.random() * width,
Math.random() * height * 0.4,
10 + Math.random() * 20,
{
restitution: 0.6,
friction: 0.1,
frictionAir: 0.02,
render: { fillStyle: `hsl(${Math.random() * 360}, 70%, 60%)` },
}
));
}
},

startGameLoop() {
let lastTime = Date.now();
const fixedStep = 1000 / 60;
let accumulator = 0;

const loop = () => {
const now = Date.now();
let delta = now - lastTime;
lastTime = now;
if (delta > 100) delta = 100;

accumulator += delta;
while (accumulator >= fixedStep) {
Engine.update(this.engine, fixedStep);
accumulator -= fixedStep;
}
this.render();
this.rafId = this.canvas.requestAnimationFrame(loop);
};
this.rafId = this.canvas.requestAnimationFrame(loop);
},

render() {
const { ctx, width, height } = this;
ctx.clearRect(0, 0, width, height);

for (const body of Composite.allBodies(this.engine.world)) {
if (body.isStatic) continue;
if (body.bounds.max.x < 0 || body.bounds.min.x > width ||
body.bounds.max.y < 0 || body.bounds.min.y > height) continue;

ctx.beginPath();
if (body.circleRadius) {
ctx.arc(body.position.x, body.position.y,
body.circleRadius, 0, Math.PI * 2);
} else {
const v = body.vertices;
ctx.moveTo(v[0].x, v[0].y);
for (let i = 1; i < v.length; i++) ctx.lineTo(v[i].x, v[i].y);
ctx.closePath();
}
ctx.fillStyle = body.render?.fillStyle || '#4A90D9';
ctx.fill();
}
},

onTouchStart(e) {
const { x, y } = e.touches[0];
Composite.add(this.engine.world, Bodies.circle(x, y,
10 + Math.random() * 15, {
restitution: 0.7,
frictionAir: 0.02,
render: { fillStyle: `hsl(${Math.random() * 360}, 70%, 60%)` },
}
));
},

onUnload() {
if (this.rafId) this.canvas.cancelAnimationFrame(this.rafId);
if (this.engine) {
Events.off(this.engine);
Composite.clear(this.engine.world);
Engine.clear(this.engine);
this.engine = null;
}
this.canvas = null;
this.ctx = null;
},
});

参考文献