做H5游戏这几年我经常被问到同一个问题写游戏为什么用Phaser直接用Canvas画不就行了问这句话的人通常还没踩过真正的坑。用Canvas画一个会动的方块很容易难的是把动画循环、物理碰撞、资源加载、状态切换、声音播放、不同设备的兼容性这些事同时管起来。早年我硬是用原生Canvas写过一个小游戏代码越写越乱到最后自己都分不清当前是哪个游戏状态。后来切到Phaser基本就回不去了。Phaser是一个基于WebGL和Canvas渲染的HTML5 2D游戏框架渲染循环、场景管理、Arcade和Matter两套物理引擎、Tween动画、粒子系统、音频管理都已经内置你只需要把精力放在玩法本身。这篇文章会从原理层面讲清楚Phaser的骨架是怎么转起来的再带大家用Phaser 3从零落地一个能玩的接球小游戏最后把我在真实项目里踩过的坑和性能优化经验一并抖出来。适合谁看一种是前端开发者想转游戏方向另一种是已经入了门但看着满屏API不知道从哪下手的新手。这篇应该都能帮到你。1. 为什么是Phaser从游戏开发选型说起1.1 网页游戏开发的三座大山先理解一个底层差异普通Web页面是事件驱动的页面平时是静止的点击、滚动、数据请求来了才动一下。游戏完全不同它是帧驱动的哪怕玩家什么也不做画面里的角色、粒子、阴影也在一刻不停地运动。这意味着你要维护一个永不停止的主循环每一帧都要计算位置、检测碰撞、刷新渲染。用原生Canvas写过游戏的朋友应该深有体会第一个绕不开的是循环怎么写。用setInterval吗间隔时间受浏览器性能和标签页状态影响帧率忽高忽低切后台还在空转耗电。用requestAnimationFrame吗动画循环、时间间隔、物理更新这些逻辑都要自己搭。第二座大山是资源管理图片、音频、字体都需要在游戏开始前预加载加载失败还要处理。第三座大山是输入兼容鼠标、键盘、触摸屏的事件各有各的坑你要自己归一化成游戏里统一的按下/松开信号。这三座大山就是Phaser存在的理由。1.2 Phaser的定位与社区现状Phaser 3是目前的主流大版本开源项目底层渲染首选WebGL降级到Canvas也能跑。框架内部把游戏循环、场景生命周期、物理引擎、动画系统、音频系统都串好了理论上你只需要做一件事向场景里添加游戏对象告诉它们怎么动。Phaser的社区在HTML5游戏框架里算是非常活跃的官方文档、示例库、第三方插件都很齐全。常见的应用场景包括H5休闲游戏、营销互动页面、教育类课件、游戏玩法原型。很多你玩过的小游戏和互动H5背后都是Phaser在跑。对独立开发者和团队来说选Phaser一个很现实的理由是它能把一个demo从零到跑通的时间压缩到几小时内这对验证玩法和快速迭代特别重要。1.3 同类型框架对比PixiJS、Three.js与Phaser经常有人把Phaser和PixiJS、Three.js放在一起问选哪个。其实它们根本不是同一层的东西用错场景会非常别扭。框架定位渲染基础上手成本适合的场景PixiJS2D渲染引擎WebGL为主中高性能图形应用但只解决渲染不管游戏循环、物理Three.js3D渲染引擎WebGL高3D场景、模型展示、3D游戏Phaser2D游戏全栈框架WebGL/Canvas中低2D游戏、互动内容、游戏原型PixiJS是做渲染的它可以把几百个精灵渲染得飞快但它不管游戏循环、不提供物理引擎、没有场景管理你要自己搭一整套游戏框架。Three.js的强项在3D硬要用它做2D游戏坐标系、光照、材质这些概念全是负担。Phaser更适合2D游戏的原因是人家已经帮你把游戏这个事做了大半你要的是填玩法而不是从零垒地基。1.4 什么情况下不该用Phaser聊选型光说优点不够还得说实话。如果你的需求只是一次性的小动效、一个营销页面里的粒子飞舞效果用CSS动画或者PixiJS反而更轻Phaser的整套框架会用不上的重量拖累你。如果你的项目是重度3D老老实实上Three.js或专用3D引擎。如果你所在团队强依赖可视化编辑器和拖拽式工作流Phaser虽然有Phaser Editor这样的配套工具但生态和成熟度跟Cocos Creator这类商业化工具还是有差距。我的建议是把Phaser用在游戏这个场景里而不是把游戏框架当成万能工具。2. 游戏循环与场景生命周期Phaser的底层骨架2.1 帧循环是怎么转起来的很多人用Phaser的第一周都不会去想游戏到底是怎么动起来的这个问题。其实好事说明框架把复杂逻辑藏得很好。但理解底层原理会让你在处理掉帧、卡顿、状态错乱时更有底气。Phaser的游戏循环核心是requestAnimationFrame这是浏览器提供的原生机制它保证回调函数的执行频率和屏幕刷新率同步一般是60Hz。浏览器标签页切到后台时会自动暂停rAF回调这对游戏来说是好事后台就不再消耗CPU和电量。Phaser会在每一帧内部执行一整套固定流程先处理输入系统的拾取状态然后更新所有激活中的场景每个场景内部又分成preUpdate、update、postUpdate三个顺次阶段最后执行WebGL或Canvas渲染器把画面真正画出来。为什么要把更新拆成三个阶段因为在同一帧里你要保证输入状态、逻辑更新、物理计算、渲染输出有一个稳定的先后顺序。如果你在一个阶段里改了对象的坐标下个阶段又改一次就可能出现改了个寂寞或者位置抖动的现象。这套分阶段流水线就是Phaser稳定性的来源。理解这一点后很多诡异bug的排查方向就会清晰很多。2.2 场景生命周期init、preload、create、updatePhaser用Scene这个概念来组织游戏模块。一个场景就是一个独立的世界可以是一个主菜单、一个战斗关卡、一个结算界面。每个场景类里有几个特别的方法它们的调用顺序是固定的。init(data)场景加载时最先执行适合接收外部传入的参数并初始化变量。比如从主菜单进入关卡时把选定的关卡编号传过来存到this.level里。preload()执行资源加载图片、音频、图集都在这阶段用this.load系列方法排队加载。preload执行完之前后面的create不会开始。create()所有资源加载完成后进入create在这里创建精灵、文字、物理对象、注册碰撞器等也可以读取preload中加载好的资源。update(time, delta)每一帧都会执行是游戏逻辑的主战场。time是从游戏启动到当前的总毫秒数delta是上一帧到这一帧的间隔毫秒数。这里要特别提醒一个常见误区preload里不能创建需要依赖资源的游戏对象。我见过新手在preload里直接this.add.image运行到一半才报错因为资源根本没加载完纹理还不存在。资源的加载和对象的创建在Phaser里是严格分阶段的。2.3 场景切换与数据传递游戏不可能只有一个场景场景之间的跳转和数据共享是基本功。Phaser的场景管理器提供了多种切换方式this.scene.start(sceneKey, data)停止当前场景启动新场景并可以把data对象传给新场景的init。this.scene.launch(sceneKey)在保持当前场景运行的同时并行启动另一个场景适合做悬浮在游戏上层的UI场景。this.scene.pause(sceneKey)和this.scene.resume(sceneKey)暂停和恢复场景更新但场景仍然存在。this.scene.restart()重启当前场景常用于返回关卡重来。如果你希望数据跨越多个场景共享比如玩家的总分数或者设置项有两个选择一是每次scene.start时手动传递data二是在游戏全局的Registry中存储this.registry.set(score, 100)任意场景用this.registry.get(score)读取。我项目里通常把角色属性这类常驻数据放Registry把这一关的临时道具作为start参数传递两者职责清晰。2.4 最小可运行项目从空页面到第一个方块看再多的原理不如先跑起来。下面是最小可运行的Phaser 3 HTML页面直接复制保存成html文件就能在浏览器里打开。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titlePhaser 3 最小示例/title script srchttps://cdn.jsdelivr.net/npm/phaser3.60.0/dist/phaser.min.js/script /head body script class DemoScene extends Phaser.Scene { constructor() { super(demo); } create() { this.add.rectangle(400, 300, 100, 100, 0xff8800); } update() {} } const config { type: Phaser.AUTO, width: 800, height: 600, backgroundColor: #2c3e50, scene: DemoScene, }; new Phaser.Game(config); /script /body /html这个代码干了什么呢new Phaser.Game(config)创建游戏实例config指定了渲染方式、画布尺寸和场景列表。场景类里只需要实现一个createthis.add.rectangle在画布中央生成了一个橙色方块。你可以试着把update里加一行this.children.list[0].angle 1重新打开就会发现方块旋转起来了——这就是游戏循环驱动对象更新的直观体验。3. 游戏对象、动画与交互理解Phaser的一切皆对象3.1 GameObject与容器层级Phaser里的可见元素都继承自GameObject这个基类。Sprite、Text、Image、Graphics、Zone都是它的子类。每个场景内部维护了两套列表一套是Display List管渲染顺序另一套是Update List管每帧更新。只要你把对象add到场景它就会自动进入这两套列表这也是Phaser会帮你更新一切的底层机制。但对象多了以后你还会需要Container。Container可以把多个GameObject打包成一个组然后对Container做移动、缩放、旋转里面的子对象会整体跟着变。比如一个角色由身体、头盔、武器三个Sprite组成把它们放进同一个Container之后只需要移动Container就能让整个角色走起来维护起来非常清爽。还有一个我早期忽略的细节setDepth。如果你不设置深度渲染顺序基本就是创建顺序后创建的对象会盖在先创建对象上面。在动态创建的UI和粒子混在一起时不控制深度就等着画面乱套吧。实践经验是凡是有固定层叠需求的元素创建完立刻setDepth别偷懒。3.2 常用GameObject与动态纹理不同对象类型有不同用途。Sprite是带动画能力的图片载体角色、敌人、道具都用它。Image是轻量版Sprite适合不带动画的静态贴图性能更好。Text是文本对象适合分数、提示等动态文字。Graphics则用于程序化绘制比如画线条、填充多边形。没有美术资源时怎么起步Phaser提供了一个很好用的机制动态纹理。你可以用textures.createCanvas在运行时创建一个Canvas纹理然后像操作Canvas一样画图。我经常用它生成圆形、矩形、进度条背景这类基础素材极大降低了做原型时对美术资源的依赖。第4章的实战就完全用动态纹理来画挡板和球。3.3 动画与补间Tween和Sprite Animation的分工Phaser里的动画其实有两条路线一个是给Sprite用的纹理动画一个是给任意属性用的补间动画很多人一开始会搞混。纹理动画适合人物走路火焰燃烧这类循环动作。用法是把一张包含多帧的SpriteSheet加载进来然后注册动画this.anims.create({ key: walk, frames: this.anims.generateFrameNumbers(player, { start: 0, end: 5 }), frameRate: 10, repeat: -1, }); sprite.play(walk);补间动画则适合位置、透明度、缩放这类数值过渡。比如让一个提示文字淡出this.tweens.add({ targets: hintText, alpha: 0, duration: 1000, ease: Power2, onComplete: () hintText.destroy(), });Tween的本质是在一段时间内对目标对象的某个属性做插值你不用自己写缓动函数。一个容易踩的坑是Tween的目标对象被销毁后Tween仍可能在执行回调和更新逻辑。我习惯在执行destroy前先tween.stop()或者通过onComplete清理避免对已经不在场景里的对象继续操作。3.4 输入事件键盘、鼠标、触摸的统一处理Phaser把输入系统统一了。场景里监听鼠标和触摸用的是同一套事件接口。比如点击或触摸屏幕任意位置让物体移动过去this.input.on(pointerdown, (pointer) { player.x pointer.x; player.y pointer.y; });键盘方向键用createCursorKeys很方便this.cursorKeys this.input.keyboard.createCursorKeys(); // update中判断 this.cursorKeys.left.isDown对于移动端稍微注意一下很多浏览器会有双击缩放、长按选中等默认行为可以配合viewport设置来禁用这些干扰。后面第5章我会专门提兼容性问题。3.5 渲染管线与贴图集为什么重要Phaser在WebGL渲染时采用批处理机制。简单解释就是如果连续绘制的多个对象使用同一张纹理GPU可以一次调用把这几个对象全画出来这被称为一个Draw Call。但如果你有一堆零散小图每画一张都要切换一次纹理Draw Call数量就会暴涨渲染性能会受到明显影响。把很多小图合并成一张大图就是贴图集Texture Atlas。比如把50个角色的帧图塞进一张1024x1024的PNG里渲染时GPU只需要切换一次纹理就能连着画完50个帧。Phaser通过this.load.atlas(key, texture.png, atlas.json)加载图集再根据JSON里的帧名拿到对应区域。生产环境中的游戏资源我建议全部走图集这不仅是为了性能也方便资源管理。常用的图集生成工具包括TexturePacker和开源的Free Texture Packer问题都不大。4. 落地实战用Phaser 3从零做一个接球游戏4.1 项目准备CDN还是npm第4章开始我们做一个经典的接球游戏屏幕下方一个挡板玩家用左右方向键控制挡板接住从上方弹跳的小球每次接住得10分球掉地扣一条命3条命用完就重启。如果你只是想快速跑通直接用CDN引入Phaser是最省事的方案不用配Webpack、Vite一个HTML文件就能完成。如果你的项目会持续变大、需要用到TypeScript、模块化拆分代码那建议用npm安装并配合打包工具。实战中我个人的建议是先CDN写demo确定玩法可行再迁移工程化方案。不要一上来就搭一套复杂构建流程框架都还没跑通配置倒先劝退了一大半热情。4.2 零美术资源起步用Canvas纹理生成图形接球游戏不需要外部的美术图片。我们直接用动态纹理在代码里画出挡板和小球。核心API是textures.createCanvas它会在全局纹理管理器中注册一个Canvas纹理然后可以在Canvas上下文上画任何内容最后调用refresh()生效。下面这块代码有个必须注意的细节我做了存在性判断if (!this.textures.exists(paddle)) { const paddleTex this.textures.createCanvas(paddle, 120, 24); const pctx paddleTex.getContext(); pctx.fillStyle #3498db; pctx.fillRect(0, 0, 120, 24); paddleTex.refresh(); }为什么一定要判断存在性因为场景重启后纹理仍然保留在纹理管理器中这是全局资源不会随着场景销毁。如果再次调用createCanvas创建同名纹理它会认为key已存在而返回null你拿到null再调getContext()就直接报错了。这个坑很多文章不会提但在实战里非常常见。小球纹理同理画一个圆即可。4.3 物理引擎选型Arcade就够用Phaser内置了两个物理引擎Arcade和Matter。Arcade适合绝大多数休闲游戏它处理的是轴对齐的矩形/圆形碰撞速度快、使用简单Matter则支持多边形碰撞体、旋转、关节、弹簧等复杂物理模拟。接球游戏里只有矩形挡板和圆形小球的碰撞Arcade是天然的合适选择。物理引擎的配置写在game config里physics: { default: arcade, arcade: { gravity: { y: 0 }, debug: false, }, }这个游戏不需要重力所以gravity设为0。小球的运动完全靠初始速度和边界反弹来维持。这里还有一个关键配置物理世界边界。球需要在上、左、右三个边界反弹但底部要留出缺口让球能掉出去触发掉命逻辑。Phaser的setBoundsCollision可以分别控制四条边的碰撞开关this.physics.world.setBounds(0, 0, 800, 600); this.physics.world.setBoundsCollision(true, true, true, false);四个参数对应左、右、上、下。前三个是true意味着球撞到左、右、上边界会反弹最后一个是false意味着底部不设碰撞球可以继续向下掉落直到我们检测到出局。这个模式是掉落类游戏最常用的边界方案。4.4 游戏规则实现碰撞计分、生命值与状态重置挡板用physics.add.image创建它直接返回一个带Arcade物理体的Image对象。设置setCollideWorldBounds(true)防止挡板被移出屏幕再设置setImmovable(true)让挡板和小球碰撞时不会被撞跑。this.paddle this.physics.add.image(400, 580, paddle); this.paddle.setCollideWorldBounds(true); this.paddle.setImmovable(true);小球同样用physics.add.image创建。由于Physics World本身已经配置了边界碰撞方向小球只需要setCollideWorldBounds(true)和setBounce(1)它就会在允许碰撞的边界上完全弹性反弹。setVelocity(200, 260)给了它一个初始速度保证开局小球立刻动起来。this.ball this.physics.add.image(400, 300, ball); this.ball.setCollideWorldBounds(true); this.ball.setBounce(1); this.ball.setVelocity(200, 260);小球和挡板发生碰撞时要加分使用Arcade碰撞器this.physics.add.collider(this.ball, this.paddle, () { this.score 10; this.scoreText.setText(Score: this.score); });碰撞回调在物理step检测到两个对象发生碰撞时触发。有一点要留意如果球以很低的角度滑过挡板可能造成多个物理帧内的连续重叠理论上存在连续加分的可能性。我在实际项目里习惯加一个时间冷却例如只在距离上一次计分超过300毫秒后才允许再次加分。这样即使有连续碰撞的情况也不会导致分数刷爆。小球掉出底部后怎么处理我在update里做一个简单的阈值判断if (this.ball.y this.physics.world.bounds.height 20) { this.loseLife(); }为什么不用Phaser的worldbounds事件因为那个事件的回调参数在不同版本之间有过行为差异与其踩版本的坑不如自己用坐标判断逻辑更透明、更好调试。loseLife里把命数减一更新Text显示。如果命数归零就重启场景否则重置小球回中心位置重新发球。键盘控制挡板在update里实现。通过cursorKeys.left.isDown和cursorKeys.right.isDown判断方向给挡板设置水平速度。左右都没按时速度归零这样挡板就能停在原地。4.5 完整代码与运行说明下面是完整可运行的HTML保存后用浏览器打开即可。你可以试着把小球速度从200改成300体验难度提升也可以把setBounce(1)改成setBounce(1.05)体会一下球越弹越快的感觉。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno / titlePhaser 接球小游戏/title script srchttps://cdn.jsdelivr.net/npm/phaser3.60.0/dist/phaser.min.js/script /head body script class GameScene extends Phaser.Scene { constructor() { super(game); } create() { // 动态生成挡板纹理 if (!this.textures.exists(paddle)) { const paddleTex this.textures.createCanvas(paddle, 120, 24); const pctx paddleTex.getContext(); pctx.fillStyle #3498db; pctx.fillRect(0, 0, 120, 24); paddleTex.refresh(); } // 动态生成小球纹理 if (!this.textures.exists(ball)) { const ballTex this.textures.createCanvas(ball, 32, 32); const bctx ballTex.getContext(); bctx.fillStyle #e74c3c; bctx.beginPath(); bctx.arc(16, 16, 14, 0, Math.PI * 2); bctx.fill(); ballTex.refresh(); } // 物理世界边界左右上碰撞底部留空 this.physics.world.setBounds(0, 0, 800, 600); this.physics.world.setBoundsCollision(true, true, true, false); // 挡板 this.paddle this.physics.add.image(400, 580, paddle); this.paddle.setCollideWorldBounds(true); this.paddle.setImmovable(true); // 小球 this.ball this.physics.add.image(400, 300, ball); this.ball.setCollideWorldBounds(true); this.ball.setBounce(1); this.ball.setVelocity(200, 260); // 计分 this.score 0; this.scoreText this.add.text(16, 16, Score: 0, { fontSize: 28px, fill: #fff }); this.physics.add.collider(this.ball, this.paddle, () { this.score 10; this.scoreText.setText(Score: this.score); }); // 生命值 this.lives 3; this.livesText this.add.text(16, 56, Lives: 3, { fontSize: 28px, fill: #fff }); // 键盘输入 this.cursorKeys this.input.keyboard.createCursorKeys(); } loseLife() { this.lives - 1; this.livesText.setText(Lives: this.lives); if (this.lives 0) { this.scene.restart(); } else { this.resetBall(); } } resetBall() { this.ball.setPosition(400, 300); this.ball.setVelocity(200, 260); } update() { const speed 420; if (this.cursorKeys.left.isDown) { this.paddle.setVelocityX(-speed); } else if (this.cursorKeys.right.isDown) { this.paddle.setVelocityX(speed); } else { this.paddle.setVelocityX(0); } // 小球掉出底部 if (this.ball.y this.physics.world.bounds.height 20) { this.loseLife(); } } } const config { type: Phaser.AUTO, width: 800, height: 600, backgroundColor: #2c3e50, physics: { default: arcade, arcade: { gravity: { y: 0 }, debug: false, }, }, scene: GameScene, }; new Phaser.Game(config); /script /body /html跑通之后可以继续加东西给小球加点随机初始角度、给每次碰撞加分加上连击提示、在游戏结束时弹出结算界面、给掉落的小球加一个粒子炸裂效果。Phaser里做这些扩展都不需要动框架底层算是把一个Demo变成完整游戏的过程。5. 性能优化与实战避坑那些文档里不会写的细节5.1 渲染性能从Draw Call到图集回到第3章说的图集问题。很多新手会在游戏里直接加载上百张零散的小图结果在移动端帧率掉得厉害。原因就是每张图都是一次纹理切换Draw Call数量多了GPU忙不过来。解决办法也很成熟使用图集。我在真正项目里的做法是美术资源导出时就直接按照角色、场景、UI三个维度打图集。运行时加载图集后所有Sprite通过帧名引用既方便替换也让渲染批处理效率提升明显。如果你看到游戏在Chrome上挺流畅、手机上有点卡先别急着怀疑框架打开Performance面板看看Draw Call数量多半会发现问题出在纹理管理上。另一个高频性能杀手是Text对象。Phaser的Text通过Canvas渲染文本并动态生成纹理每次setText都会重新生成。如果你在update里每帧更新一个显示帧数的Text那就是每帧都在重建纹理对性能的拖累远比想象中大。优化方案有两个一是只在数值变化时调用setText二是改用BitmapText位图字体它渲染成本低很多适合频繁变化的文本。5.2 物理引擎的细节与性能Arcade物理对多数2D休闲游戏来说足够快但使用姿势很重要。第一开发阶段可以打开physics: { arcade: { debug: true } }它会画出所有碰撞体轮廓能直观看到物理体是否和贴图大小一致。发布前务必关掉否则这些调试线会一直渲染白白浪费性能。第二一个场景里同时存在大量物理对象时每条碰撞规则都要开销。比如50个敌人互相碰撞那就是几千次两两检测。Phaser允许你给collider传入具体的对象或数组范围不要无条件让所有物体互碰。用Group管理敌人再用physics.add.collider(player, enemies)统一处理性能会好很多。第三不是所有对象都需要物理体。纯背景装饰、简单的UI用普通Image放在场景里就够了。物理体数量越少物理step的计算压力越小。很多卡顿问题并不是渲染问题而是物理体的数量失控。5.3 场景重启后的资源清理第4章里我们处理了动态纹理重复创建的问题。其实类似的资源清理问题遍布整个生命周期场景restart时会自动销毁该场景内创建的全部GameObject但不会销毁全局纹理、Registry数据、全局事件监听器。如果你在某个场景里注册了this.game.events.on(someEvent)场景重启前不手动移除事件回调就会一直留在游戏全局事件系统里甚至在你已经离开那个场景后继续执行导致越界访问已销毁对象。我的习惯是在场景的shutdown事件里做统一清理例如this.events.on(shutdown, () { this.physics.world.shutdown(); this.textures.remove(paddle); });这样每次场景结束相关物理世界和临时纹理都会被清理干净不会把上一个场景的遗骸带进新场景。这种清理习惯能帮你避开大量隐蔽的偶发bug。5.4 移动端与浏览器兼容性注意点Phaser项目经常要跑在手机上。viewport设置是第一道坎记得在HTML里写上user-scalableno避免玩家在游戏过程中误触双击缩放排版和操作都会出问题。游戏画布本身可以用Scale管理器适配屏幕常见做法是设置scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }让游戏宽高比自适应不同屏幕。音频是另一个容易被移动端坑的地方。浏览器要求用户手势之后才能播放音频Phaser的音频系统也受这个限制。如果游戏启动就自动播放背景音乐在iOS上很可能一声不吭。正确做法是让玩家先点击一次开始按钮在按钮的回调里再this.sound.play。实战中任何音频解锁逻辑都放在用户交互回调中处理最稳妥。输入方面移动端没有键盘如果你希望同款游戏在手机上也玩就得给挡板接上触摸或拖拽逻辑。Phaser的input.setDraggable可以很轻松地让挡板跟随手指移动这部分可以当成很好的扩展练习。5.5 我见过的几个翻车现场这些年看过的Phaser新人报错翻来覆去就那几类。第一个是preload里写this.add。资源还没加载完就创建对象要么报找不到纹理要么对象显示不出来。正确的顺序永远是preload加载create创建。第二个是回调函数里this指向错乱。在setInterval、原生事件回调里用this而忘了绑定结果拿到的this不是场景实例。解决办法很简单要么用箭头函数要么在Phaser的回调API里传入context参数。Phaser的大多数回调函数都支持最后一个参数传入this比如this.physics.add.collider(objA, objB, callback, null, this)。第三个是把旧对象引用存在全局变量里。场景重开之后你手里还攥着一个已经被销毁的Sprite调用它的setPosition时控制台会报一堆错。经验法则是只要场景有重启的可能任何对象引用都要在场景内部被覆盖或清空别相信那些保存在场景外部的旧朋友。第四个是忽略setDepth。多个UI层和游戏层混在一起时没有清晰的层级管理画面就会出现血条被角色头像盖住这种诡异情况。创建对象后就明确设置深度能省下大量调样式的时间。最后再补一个过度依赖update里的setText。每次文本内容变更都会触发纹理重建这不只是性能问题频繁重建还会造成偶发的字体闪动。把文本更新频率降下来游戏体验会稳定很多。最后说点我自己的体会Phaser是我做H5游戏最顺手的框架之一但它的顺不是天上掉的。你越理解游戏循环和场景生命周期写出来的代码就越有信心。我平时做项目会坚持几个小习惯把每个功能拆成清晰的update函数给每个GameObject起可读的名字用Container整理层级关系在场景结束时做好资源清理。这些习惯看起来不起眼但在项目规模变大之后会无数次救你于水火。如果你刚接触Phaser我特别建议别急着买一堆教程先把上面的接球游戏改出花来给球加速、加双挡板、做关卡切换、加音效和粒子特效。跑通一个功能再学下一个收获会比被动刷文档大得多。框架永远是工具真正值钱的是你对游戏循环、状态、交互这些底层逻辑的理解。