上一篇文章我们把PlayCanvas的Application实例成功挂到了Vue3组件里,也画出了一个能转起来的简单场景,算是把“Vue3 + PlayCanvas到底能不能配合干活”这个技术问题验证通了。这一篇继续往下推,目标非常具体:做出一款能玩、能分、能死的3D障碍物躲避游戏。如果你正在找一套可以直接复用的前端Web 3D小游戏实现方案,或者已经跟着本系列前两篇完成了工程搭建,那这篇文章刚好接住你——我会把障碍物生成、碰撞判定、计分规则、移动端触控、性能优化这几块完整拆开讲,代码直接抄,调参逻辑也直接说。
我得先说一个比较反直觉的结论:障碍物躲避类游戏看着简单,真写起来大头根本不在地图或模型,而在“主循环职责划分”和“对象池回收”这两件事上。这两件事没想透,后面加什么功能都会打架。所以这篇文章会花不少篇幅讲设计思路,不只是给代码。
1. 前两篇留下的工程骨架,第三篇从哪接上
1.1 当前目录结构与新增模块
按照系列惯例,我们始终用 Vue3 + Vite 做应用外壳,PlayCanvas 以 npm 包方式引入,完全不用 PlayCanvas 自带的编辑器。前两篇完成的工程,核心目录大概是这样的:
src/ ├── components/ │ └── GameCanvas.vue # 承载canvas容器,负责挂载/销毁 ├── engine/ │ ├── createApp.ts # 初始化PlayCanvas Application │ └── scene.ts # 创建灯光、地面、相机 ├── App.vue # 打开页面后的入口 └── main.ts到第二篇结束时,GameCanvas.vue的onMounted里已经能正常执行:
const canvas = containerRef.value; const app = createPlayCanvasApp(canvas); buildBaseScene(app);createApp里做了几件固定的事:创建pc.Application、启用pc.Mouse、pc.TouchDevice、pc.Keyboard,然后app.start()启动渲染循环。buildBaseScene则是把相机、平行光、地面网格放进去,地面我习惯用pc.ENTITYTYPE的model组件加一个 box 模型压扁,再铺一层standard材质,深灰色,配合tiling参数贴一段重复纹理。
第三篇要做的事,就是在这个骨架上增加一套完整的玩法系统。我规划了下面几个新文件:
src/engine/ ├── GameManager.ts # 游戏状态机:idle / running / gameover ├── PlayerController.ts # 玩家实体、移动逻辑、触控映射 ├── ObstacleFactory.ts # 对象池、障碍物生成与回收 ├── CollisionSystem.ts # 碰撞回调注册、得分/死亡判定 └── uiEvents.ts # 把游戏内事件抛给Vue3层文件数量看着多,但每个文件职责很单一。这也是我想强调的第一点:不要把所有逻辑塞进GameCanvas.vue里。Vue 组件只负责生命周期和 UI 状态,游戏逻辑全部抽到engine目录。否则过两天你就会发现,Vue 的响应式更新跟 PlayCanvas 的每帧循环在同一个文件里纠缠,改个计分规则能把页面整个搞崩。
1.2 生命周期钩子怎么配合
GameCanvas.vue的完整生命周期应该是:
<script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; import { createPlayCanvasApp } from '../engine/createApp'; import { buildBaseScene } from '../engine/scene'; import { GameManager } from '../engine/GameManager'; const containerRef = ref<HTMLDivElement>(); let game: GameManager | null = null; onMounted(() => { const canvas = document.createElement('canvas'); containerRef.value?.appendChild(canvas); const app = createPlayCanvasApp(canvas); buildBaseScene(app); game = new GameManager(app); game.start(); }); onUnmounted(() => { game?.destroy(); }); </script> <template> <div ref="containerRef" class="game-canvas-container" /> </template>注意onUnmounted里destroy是关键。PlayCanvas 的Application如果不清干净,页面切路由后渲染循环还在跑,GPU 资源也不释放,这在单页应用里是重大事故。我见过有人把游戏嵌进后台管理系统,反复进出页面后标签页直接卡死,最后发现是app.destroy()从没被调用。所以GameManager.destroy()里一定要执行:
destroy() { this.app.destroy(); window.removeEventListener('keydown', this.handleKey); window.removeEventListener('pointermove', this.handlePointer); }后面所有事件监听器都要在这个方法里成对移除。养成“每加一个 addEventListener 就想好对应的 removeEventListener”的习惯,Web 3D 页面才能长跑。
2. 把游戏主循环想清楚再动手
2.1 为什么不让 Vue3 去驱动每帧更新
很多从 Three.js 转过来的朋友,习惯自己写requestAnimationFrame,然后在 Vue 里用onBeforeUnmount取消。PlayCanvas 不建议这么干,因为它内部已经管理了自己的 rAF 循环和固定步长逻辑,你再套一层 rAF 就是双重驱动,帧率不稳定,还容易出现 dt 异常导致物体“瞬移”。
正确做法是把自定义逻辑挂到 PlayCanvas 的update事件上:
app.on('update', (dt: number) => { this.update(dt); });dt是经过引擎处理的帧间隔时间,单位是秒,大概在 0.016 左右。这个值直接喂给所有移动逻辑,保证不同刷新率的屏幕上跑起来速度一致。
2.2 玩家、障碍物、裁判:三类模块的职责边界
躲避游戏看起来只有“玩家动、障碍物飞过来”两件事,但代码组织上我建议拆成三个角色:
- 玩家模块(PlayerController):只负责回答“玩家现在应该在哪”。输入源可能是键盘、鼠标,也可能是触摸屏,但它不需要关心障碍物长什么样。
- 障碍物模块(ObstacleFactory):只负责生成障碍物、让障碍物沿指定方向移动、回收越界对象。它不需要知道玩家的位置,甚至不需要知道玩家是否存在。
- 裁判模块(GameManager):握有游戏状态,负责把前两者连接起来。比如玩家撞到障碍物时,通知 GameManager 切换到 gameover;障碍物被玩家成功躲过时,通知 GameManager 加分。
这个划分最大的好处是:以后你想加“双人模式”“随时间加速”“道具系统”,都只需要动某一个模块。我在实际项目里吃过不拆模块的亏,一开始图快把移动、生成、碰撞全部塞到一个update里,后面加特效时,改一行碰撞逻辑就能引发各种奇怪 bug,定位都无从下手。
2.3 主循环里的更新顺序
GameManager.update(dt)里的执行顺序我固定为四步:
private update(dt: number) { if (this.state !== GameState.Running) return; this.player.update(dt); // 1. 根据输入更新玩家位置 this.obstacles.update(dt); // 2. 移动所有障碍物 this.obstacles.recycle(); // 3. 回收出界的障碍物 this.difficulty.update(dt); // 4. 动态调整难度曲线 }顺序很重要。玩家先动,障碍物再动,然后检查回收,最后调难度。如果你先移动障碍物再移动玩家,理论上没问题,但碰撞检测如果放在中间某个位置,先后顺序会影响一帧内的判定结果。固定顺序后,就算出现极端情况,问题也是可复现的,能复现就能修。
3. 障碍物工厂:对象池与随机生成的工程实现
3.1 为什么用对象池而不是随建随毁
第一版我图省事,每生成一个障碍物就new pc.Entity加到场景里,被躲过就entity.destroy()。性能上确实也能跑,但有一个很烦的副作用:内存碎片。PlayCanvas 场景里的实体、组件、材质每帧都在创建和销毁,GC 频繁触发,帧率会出现周期性掉到个位数的“卡顿症”。尤其是在移动端 WebView 上,SSR(这里指的是内存垃圾回收)一跑,肉眼可见的掉帧。
对象池的思路很朴素:场景初始化时一次性创建 N 个障碍物实体的“空壳”,全部隐藏在场景外面。需要生成时,从池子里取一个,设置位置、大小、旋转,然后启用;飞出边界后,把它重置并“归还”给池子,而不是销毁。
export class ObstacleFactory { private pool: pc.Entity[] = []; private active: pc.Entity[] = []; private spawnZ = -30; private recycleZ = 10; constructor(private app: pc.Application, private size: number) { for (let i = 0; i < size; i++) { const ent = this.createObstacleEntity(); ent.enabled = false; this.pool.push(ent); } } private createObstacleEntity(): pc.Entity { const ent = new pc.Entity('obstacle'); ent.addComponent('model', { type: 'box', }); ent.addComponent('collision', { type: 'box', halfExtents: new pc.Vec3(0.5, 0.5, 0.5), }); ent.addComponent('rigidbody', { type: 'kinematic', }); this.app.root.addChild(ent); return ent; } }这里有个细节:我给障碍物加的是 kinematic 类型的刚体,而不是 static 或 dynamic。Kinematic 意味着引擎不会用物理来推它,但我可以手动改position,物理学系统还会参与碰撞检测。这对“障碍物自动飞向玩家”的需求来说是最合适的选择——我不想要重力影响,也不想要碰撞冲量反推障碍物。
3.2 生成位置的随机策略:保证“能躲”而不只是“随机”
随机生成最忌讳的是无脑随机。如果你完全随机决定障碍物的 X 坐标和 Z 坐标,玩家的运气因素会无限放大:可能连着三个障碍物堵在一条直线上,根本没法过。玩家会觉得“不是我不行,是游戏针对我”。
我的做法是控制 Z 轴最小间隔 + X 轴偏移限制:
spawn(lastX: number, lastZ: number): void { const ent = this.pool.pop(); if (!ent) return; // 保证和上一个障碍物的Z间隔不小于2.5 const z = lastZ - pc.math.random(2.5, 4.0); // X轴偏移限制在[-4, 4],且和上一个障碍物的水平距离不小于1.2 let x = pc.math.random(-4, 4); if (Math.abs(x - lastX) < 1.2) { x = x > 0 ? x + 1.5 : x - 1.5; } x = pc.math.clamp(x, -4.5, 4.5); ent.setPosition(x, 0.5, z); ent.enabled = true; this.active.push(ent); }关键在注释里那两个数字:Z 轴最小间隔决定“反应时间”,间隔越小越难;X 轴偏移限制决定“可操作性”,水平距离太近等于没躲开。这两个参数在调难度的时候,比单纯调移动速度更有效。我最终的版本里,新手阶段 Z 间隔是[3.0, 4.5],后期压缩到[1.8, 2.6],压迫感一下就出来了。
3.3 更新与还池
障碍物的移动很简单,update里统一朝玩家方向推进。因为玩家固定在 Z=0 附近,障碍物从负 Z 往正 Z 移动:
update(dt: number) { const speed = this.currentSpeed; for (const ent of this.active) { const pos = ent.getPosition(); ent.setPosition(pos.x, pos.y, pos.z + speed * dt); } } recycle() { for (let i = this.active.length - 1; i >= 0; i--) { const ent = this.active[i]; if (ent.getPosition().z > this.recycleZ) { ent.enabled = false; this.active.splice(i, 1); this.pool.push(ent); } } }注意两个细节:
第一,遍历 active 数组要倒序。因为splice会改变数组长度,正序遍历容易跳过元素。这是数组原地删除的老坑,实战中被我碰到过不止一次。
第二,回收阈值recycleZ应该大于障碍物能碰到的玩家的 Z 位置。玩家在 Z=0,所以回收线设在 Z=10 就足够了。如果你的地形很大或者视角不同,记得跟着调。
3.4 障碍物看起来不重样
纯盒子的障碍物看久了很乏味。但如果我们给每个障碍物都随机分配不同的模型,对象池里的实体类型就不好统一,而且加载多个模型又会影响启动速度。我的折中方案是:统一用 box 模型,通过 scale 和 rotation 让碰撞盒不可预测。
const scaleY = pc.math.random(0.8, 2.2); ent.setLocalScale(0.8, scaleY, 0.8); ent.setEulerAngles(0, pc.math.random(0, 90), 0); ent.collision.halfExtents = new pc.Vec3(0.4, scaleY / 2, 0.4);这样同样是 box,高矮不一、旋转角度不同,玩家的视觉新鲜感就有了。而且因为碰撞体跟着 scale 走,判定的真实感也在线。
4. 碰撞不是玄学:PlayCanvas 触发器和碰撞回调实战
4.1 CollisionComponent 与 rigidbody 的搭配
可能有人会问,前面ObstacleFactory里我已经加了collision和rigidbody,玩家那边要怎么配?我的玩家实体是这样创建的:
const player = new pc.Entity('player'); player.addComponent('model', { type: 'sphere', }); player.addComponent('collision', { type: 'sphere', radius: 0.5, }); player.addComponent('rigidbody', { type: 'kinematic', }); this.app.root.addChild(player);注意玩家也是 kinematic。两个 kinematic 刚体在 PlayCanvas 里默认是会发生碰撞回调的,因为引擎的窄相位碰撞检测会计算它们的位置重叠。障碍物也是 kinematic,所以玩家和障碍物的碰撞事件能触发。
4.2 用 trigger 还是 contact
PlayCanvas 的碰撞事件有两种思路:
- contact 事件(碰撞接触):
collisionstart、collisionend这类,适合物理交互明显的情况。 - trigger 事件(触发器):把碰撞组件设置为
triggerEnabled = true,然后监听triggerenter,适合“只要碰到就算”的场景。
躲避游戏的判定其实只要一种:玩家碰障必死或扣血。理论上两者都行,但我推荐用 trigger。原因很简单:trigger 只需要一方把triggerEnabled设为 true 就能触发,而且触发后不会产生物理推挤,逻辑更干净。我实际用下来,让障碍物作为 trigger 主体,玩家作为常规碰撞体,没什么坑。
ent.addComponent('collision', { type: 'box', halfExtents: new pc.Vec3(0.4, scaleY / 2, 0.4), triggerEnabled: true, }); ent.collision.on('triggerenter', (other: pc.Entity) => { if (other.name === 'player') { // 通知 GameManager 处理碰撞结果 } });4.3 碰撞判定里的一个重要修正
这里有一个我在实际开发中调了很久才搞明白的点:碰撞体的尺寸不要和视觉模型完全一样。玩家视觉球体 radius 是 0.5,但碰撞 sphere 的 radius 我只给了 0.35。也就是说,玩家“看起来”碰到了障碍物,实际上碰撞还没发生。
为什么要缩?因为人的视觉判断存在“缓冲错觉”——玩家总觉得自己已经躲开了,但按真实碰撞边界来算就是判定被击中。你把碰撞体比视觉边界缩一圈,玩家会感觉“操作更宽容”,挫败感大幅下降。这在快节奏躲避游戏里尤其重要,慢 0.1 秒都可能是致命的。
反过来,如果你做的是惩罚性极强的硬核游戏,那你就可以把碰撞体放大,让难度更真实。碰撞体尺寸就是游戏手感的一部分,不是物理属性,是关卡设计参数。
4.4 碰撞后的完整处理流程
碰撞发生后,玩家的表现我建议走一个简单的状态切换,而不是立刻销毁玩家模型。如果有人看着屏幕,突然画面定格会很突兀。我的做法是:
- 玩家碰到障碍物,GameManager 收到回调。
- 玩家实体播放一个缩放动画(比如 0.2 秒内 scale 缩到 0.1)。
- GameManager 状态切到
gameover,把玩家实体直接隐藏。 - Vue 层收到
gameover消息,显示结算面板。 - 玩家点击“重新开始”后,GameManager reset,玩家实体恢复初始位置和 scale。
缩放到消失这个小动画花不了多少时间,但游戏体验立刻不一样。类似的“碰撞表现”还有相机抖动、场景闪红、障碍物炸开粒子,根据精力取舍即可。
5. 得分、手感与难度曲线:让躲避游戏“好玩”的调教
5.1 计分方式:按存活时间 vs 按躲过数量
两种计分我都试过:
| 计分方式 | 优点 | 缺点 |
|---|---|---|
| 按存活时间 | 实现简单,逻辑稳定 | 玩家看不出“进步”点,和难度曲线绑定太紧 |
| 按躲过障碍物数量 | 每次“ 活过一波”都有正反馈 | 需要准确识别“成功躲过”的时刻 |
我最终选了第二种:每成功躲过一个障碍物 +10 分。判定方式是在 ObstacleFactory 里,障碍物 Z 坐标越过玩家的 Z 坐标后,标记为“已结算”。这样能天然地把“失败”和“成功”分得很清楚:
if (!ent.isScored && ent.getPosition().z > 0.5) { ent.isScored = true; this.onObstacleCleared?.(); // 回调给 GameManager }这里的isScored标志位很关键,避免同一个障碍物在一帧里反复加分。如果放在碰撞系统里判断,还得处理玩家已经死亡的场景,逻辑容易乱。
5.2 速度曲线:前期慢、中期压迫、后期拼反应
难度不能线性拉到底。线性加速的结果是“前期无聊,后期瞬间暴毙”。我实际用的曲线是分段线性:
private difficulty: DifficultyConfig = { baseSpeed: 4.0, // 初始速度 m/s maxSpeed: 12.0, // 最大速度 m/s speedRampTime: 90, // 多少秒后达到最大速度 spawnInterval: [2.5, 4.0], // 初始生成间隔 minSpawnInterval: [1.2, 2.0], // 极限间隔 intervalRampTime: 60, };每帧更新难度时:
const progress = Math.min(elapsed / this.speedRampTime, 1); this.currentSpeed = baseSpeed + (maxSpeed - baseSpeed) * progress; const intervalProgress = Math.min(elapsed / this.intervalRampTime, 1); const minInterval = lerp(2.5, 1.2, intervalProgress); const maxInterval = lerp(4.0, 2.0, intervalProgress);效果是:前 30 秒很休闲,玩家能熟悉操作;30~60 秒明显感受到速度被提起来了;90 秒以后才是真正的硬核反应阶段。这种曲线对新手友好,又能给老玩家挑战空间。
5.3 手感滑参数:移动速度、加速度与阻尼
玩家的手感主要由三个参数决定:
playerSpeed = 7.0; // 最大横向速度 m/s playerAccel = 40.0; // 横向加速度 m/s2 playerDamping = 8.0; // 松开输入时的阻尼系数键盘左右键移动不是直接改位置,而是改“速度”,再用速度改位置。这样才有加减速的平滑感:
private update(dt: number) { const inputX = (this.isLeftDown ? -1 : 0) + (this.isRightDown ? 1 : 0); if (inputX !== 0) { this.velocityX += playerAccel * inputX * dt; this.velocityX = clamp(this.velocityX, -playerSpeed, playerSpeed); } else { // 阻尼衰减,让玩家滑行一小段后停下 this.velocityX *= Math.pow(1 - playerDamping * dt, 1 / Math.max(dt, 0.016)); } this.entity.translate(this.velocityX * dt, 0, 0); this.clampPlayerX(); }那个阻尼公式你直接抄就行,1 - damping * dt的写法是基于帧率无关的指数衰减,比简单的velocityX *= 0.9在 60fps 和 30fps 下都要靠谱得多。
玩家移动范围也要限制:
private clampPlayerX() { const pos = this.entity.getPosition(); const minX = -4.2; const maxX = 4.2; this.entity.setPosition(clamp(pos.x, minX, maxX), pos.y, pos.z); }边界值稍微比障碍物生成范围小一圈,给玩家留一点“擦边杀手”的体验空间。
5.4 怎么快速调出“合适的手感”
说一个我自己的调参方法:用一个新玩家视角测试,不要自己开无敌来测。每次调整参数后,至少完整跑 3 次,每次收集两个数据——第几次障碍物死掉、死的时候障碍物速度多少。如果十次有八次死在同一个难点,那就是生成规则的问题,不是速度或手感的问题。
碰撞体缩小、控制间隔、合理边界,这几项通常能解决 80% 的“这个游戏不合理”的抱怨。剩下 20% 是视觉反馈问题,需要靠音效、闪烁、粒子来提示。
6. 移动端触控与 Vue3 状态页面的联动
6.1 从键盘到触摸:pointer 事件映射
开发时用键盘很方便,但真机测试一打开手机就露馅。移动端现在很自然的方案是监听pointer系列事件,因为鼠标、触摸屏统一归一化了。我做了两种模式:
- 松手式:手指按住任意位置,玩家自动向手指的方向移动。
- 跟随式:手指左右滑动带动玩家移动。
实测下来,跟随式(相对位移)手感更好,因为不像松手式那样会把手指遮挡在屏幕中央,玩家的手可以在屏幕边缘任意滑动。实现很简单:
private bindTouchEvents(canvas: HTMLCanvasElement) { let lastX = 0; let activePointer = false; canvas.addEventListener('pointerdown', (e) => { activePointer = true; lastX = e.clientX; }); canvas.addEventListener('pointermove', (e) => { if (!activePointer) return; const dx = e.clientX - lastX; lastX = e.clientX; this.targetX += dx * 0.12; }); canvas.addEventListener('pointerup', () => { activePointer = false; }); }dx * 0.12是一个灵敏度系数,你可以放在配置对象里让策划随时调。targetX是玩家目标位置,在实际 update 中做 lerp:
const cur = this.entity.getPosition(); const newX = lerp(cur.x, clamp(this.targetX, -4.2, 4.2), 1 - Math.exp(-12 * dt)); this.entity.setPosition(newX, cur.y, cur.z);这套“相对位移 + 阻尼跟随”的方案,从 iPhone 到 Android 中端机我都测过,手感比直接拖拽实体要顺滑得多。
6.2 用 Vue3 状态控制开始/结束 UI
游戏内状态和 Vue 组件隔离很重要,但 UI 总要有人管。我的方案是做一个简单的消息总线:
type UIMessage = | { type: 'gameover'; score: number } | { type: 'score'; value: number } | { type: 'ready' }; export function emitUI(msg: UIMessage) { window.dispatchEvent(new CustomEvent('game-ui', { detail: msg })); }然后在 Vue 组件里监听:
onMounted(() => { window.addEventListener('game-ui', (e: CustomEvent) => { const msg = e.detail; if (msg.type === 'gameover') { gameOverScore.value = msg.score; gameState.value = 'over'; } }); });这里有个实践里非常重要的问题:千万别把每帧的分数同步到 Vue 的响应式变量。Vue3 的响应式原理是 Proxy 代理,高频赋值性能压力会随组件复杂度上升。正确的做法是:游戏内分数直接维护在 GameManager 里,UI 层只有在分数变化跨越整数或游戏状态切换时才通知。比如得分到 10、20、30 这种整数位再触发emitUI({type:'score'}),配合一个简单的 CSS 动画把数字滚上去,性能毫无压力。
6.3 页面销毁时的资源释放清单
前面提到了destroy里要移除监听器,但还有几个 PlayCanvas 特有的坑:
app.destroy()前,先把所有场景实体从app.root上移除再销毁。- 如果有
Asset加载,记得调用asset.unload()释放。 - Vue 组件里创建的
window.addEventListener必须在onUnmounted里成对移除。
我自己写过一个 checklist,每次加功能都过一遍:
实体资源:app.root.removeChild(entity),再 entity.destroy() 事件资源:window.removeEventListener / app.off 定时器:clearInterval / clearTimeout 第三方库:inAppPurchase 等插件 release多数 Web 3D 项目死因不是显卡不行,是内存泄漏。页面上开开关关,帧率一点一点往下掉,最后用户只能强杀浏览器。
7. 性能与内存:长跑游戏不能越玩越卡
7.1 draw call 与实体数量的平衡
PlayCanvas 的 WebGL 渲染对 draw call 比较敏感。障碍物虽然多,但因为是同一个 box 模型、同一个材质,引擎可以自动合并 instancing(实例化绘制)。前提是不要给每个障碍物生成独立的材质。如果你想改变颜色,用材质 property 的 uniform 批量处理,而不是clone。
我验证过一个对比:30 个障碍物,如果每个都用独立材质,iPh ones 上功耗明显偏高,发热明显;如果共用一份蓝色材质,0082 个 draw call 能稳定在几十,帧率稳如老狗。
7.2 在 Vue3 组件里埋一个 FPS 探针
不要“觉得卡”,要“测出卡”。我在GameCanvas.vue里临时加过一个小探针:
let frameCount = 0; let lastTime = performance.now(); app.on('update', () => { frameCount++; const now = performance.now(); if (now - lastTime >= 1000) { fps.value = frameCount; frameCount = 0; lastTime = now; } });把fps渲染成一排小字显示在角落里。真机测试时盯着它调参数,比任何性能分析工具都直观。注意这个探针本身也有性能开销,体验完记得删掉。
7.3 移动端降温:降阴影、降粒子、降分辨率
移动端 WebGL 最大的敌人是发热。一旦降频,游戏就明显变卡,体验直接崩盘。我的优化顺序是:
- 关阴影。
light组件里castShadow = false,少一大截 GPU 开销。 - 降分辨率。用
app.graphicsDevice.maxPixelRatio = Math.min(window.devicePixelRatio, 1.5)。iPhone 的 DPR 是 3,全分辨率的渲染开销极大,限制到 1.5 之后画质肉眼看不出明显变化,功耗降一半。 - 减粒子。碰撞后的粒子爆炸控制在 100 个以内,并且设
autoPlay = true, loop = false。
这三个做完,中端 Android 机也能稳定在 50fps 以上。
8. 最后补充几个实测踩坑记录
这篇文章写到这里,核心内容其实已经完整了。再分享几个我实际开发里踩过、后面成为宝贵经验的坑。
第一个是关于碰撞体尺寸的:一开始我完全按照模型边界设置碰撞,结果玩家在手机端会觉得“明明擦边了还算我死”,愤怒卸载率飙升。后来看了知名 3D 平台跳跃游戏的做法,发现它们普遍会把玩家碰撞体缩小 10%~20%,把障碍物碰撞体放大 5%~10%。碰撞体不是物理真相,是玩家体验的设定。
第二个是对象池数量的选择。我最初给池子设了 80,结果移动端内存峰值高了不少,关掉浏览器才释放。后来改成按“最大同时存活数量 +20% 余量”来定,也就是根据最密的生成间隔和最长存活时间算出来,这个项目里是 36。理论依据很简单:最密的间隔是 1.2 秒,障碍物从生成到回收大约需要 8 秒,那同时存活最多也就 8÷1.2≈7 个,池子给 36 已经非常富裕了。你如果想要极致的省内存,甚至可以用 20。
第三个是触控灵敏度。0.12 这个数值是我在真机上反复试出来的。如果调太大,手指轻轻一晃玩家就飞出银河系;调太小,玩家会觉得自己手指都划酸了,角色还在原地。灵敏度必须和屏幕宽度联动,不可以用固定像素比,否则 iPad 和 iPhone 的手感差距会非常大。
做这个小游戏的完整过程,其实就是把 Vue3 的成熟生态和 PlayCanvas 的 3D 能力用工程化方式衔接起来的过程。你不需要去买任何可视化编辑器,代码写清楚,程序结构自然清晰。以后想加子弹、加道具、加多人在线,都能在这套骨架上直接生长,不需要推倒重来。