Phaser 4 扛玩法,Vue 3 扛界面:一个俯视角 Roguelike 原型的分层做法
介绍
一个「房间图推进 + 自动战斗」的俯视角 Roguelike 原型:Phaser 4 管场景和物理,Vue 3 管全部 UI,中间只留一张事件总线。记录房间图约束、Tiled 地图取舍、顿帧与存档几个实现细节。
想做的到底是什么
目标不复杂:像《杀戮尖塔》那样在一张房间图上逐层推进,进房间后像割草游戏一样自动战斗,清场后从三张海克斯卡里选一张,最终打 Boss。原型叫 Rouge,页面标题是「古堡回响」。
技术栈直接照搬仓库里的 package.json:
- 引擎:Phaser
^4.2.1,用 Arcade 物理、瓦片地图、Tween 和相机 - UI:Vue
^3.5.44+ TypeScript + SCSS - 构建:Vite
^8.3.4,Sass^1.106.0 - 素材:Kenney 的 CC0 像素包(Tiny Battle / Tiny Dungeon / Rune Pack 等)
选 Phaser 是因为它把 tilemap、Arcade body、相机跟随这些「必须自己写一遍」的东西都给了;选 Vue 是因为选卡、背包、商店这类界面用模板写比用 Canvas 画一堆 Graphics 快得多。真正的问题只剩下一个:两个框架怎么分工。
两棵根节点,各自挂载
入口只有 src/main.ts,但它做了两件事:创建 Phaser 实例挂到 #app,再新建一个 #ui-root 把 Vue 挂上去。
// src/main.ts(精简)
function bootGame(mode: BootMode) {
if (game || booting) return
booting = true
if (mode === 'new') localStorage.removeItem(RUN_SAVE_KEY)
game = new Phaser.Game({
type: Phaser.AUTO,
parent: 'app',
pixelArt: true,
antialias: false,
audio: { noAudio: true },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: [RoomScene],
})
;(window as any).__game = game
bus.emit(EVENTS.GAME_STARTED)
}
bus.on(EVENTS.START_GAME, (mode: BootMode) => bootGame(mode))
createApp(App).mount('#ui-root')
注意 Phaser 实例是懒创建的:欢迎页点击「开始」或「继续」之后才 new Phaser.Game。这样欢迎页阶段不会白跑一个游戏循环,也不会有后台的打斗声和物理步进。Vue 侧则始终存在,只是用 started 控制局内 UI 是否渲染。
游戏画布是 800x576,但场景里把主相机 setZoom(2),逻辑坐标其实只有 400x288。像素素材放大后不糊,roundPixels: true 配合 pixelArt: true,文字也还能看清。
一张事件总线,两种读取方式
src/ui/bridge.ts 里没有引入 mitt 之类的库,就是一个 Map<string, Set<Listener>>,唯一的巧思是 on 返回取消订阅函数:
on(event: string, fn: Listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
this.listeners.get(event)!.add(fn)
return () => this.off(event, fn)
}
App.vue 把每个返回值存成 unbindCardOpen、unbindMapOpen 这样的变量,onUnmounted 时统一调用。UI 组件会被卸载重挂,少了这一步就会重复监听,一次选卡触发两次结算。
方向是双向的:Phaser 通过 OPEN_CARD_SELECT / OPEN_MAP_SELECT / STATUS_CHANGED 推数据,Vue 通过 CARD_SELECTED / NODE_SELECTED / SWITCH_WEAPON 回传。除此之外,Vue 组件还会直接读场景状态:
const s = (window as any).__game?.scene?.getScene('RoomScene')
s.getInventory() // 装备背包
s.getGold() // 金币
s.getSkillInfo() // 冷却、图标、名称
用 window 上的全局实例换掉类型安全,是这里最不优雅的一刀,但它让 UI 组件不必等事件、随时能取快照,原型阶段值得。
房间图:先写死约束,再写生成器
src/rooms/roomGraph.ts 生成的是「起点 → 若干层中间房 → Boss」,每层 2–5 个节点。生成时用轮询把下一层节点分配父节点,再补足、再裁剪,最后跑一遍校验:
// 进 Boss 那一步只允许单选项,其余节点必须 2–3 个可选
const bossOnly = nextNodes.length === 1 && nextNodes[0].type === 'boss'
if (bossOnly) continue
if (nextNodes.length < 2 || nextNodes.length > 3) {
problems.push(`节点 ${node.id}(第 ${node.floor} 层)可选 ${nextNodes.length} 个,应为 2-3`)
}
校验只 console.warn,不 throw。理由很实际:图生成是每次开局都要跑的,偶发一条异常连接不应该让整个游戏起不来;但日志必须显眼,否则玩家点不到下一层才发现。裁剪「超过 3 个」时还特意保留别处已有的入边,否则会制造孤立节点——这种连锁约束,写生成器时比写渲染逻辑更费脑子。
地图:从程序化生成退回手工 Tiled
这是整个项目最值得记的一次取舍。
仓库里还留着 src/rooms/roomGenerator.ts:mulberry32 种子随机、矩形/柱阵/L 形/十字五种布局、从中心向每个门挖两格宽的通道、墙面按四邻虚空方向自动拼接墙皮。读起来挺完整,但我在 src 里 grep 过,generateRoom 只有定义处、没有任何模块 import 它。现在的实际路径是 RoomScene.enterRoom() 直接加载 Tiled 的 JSON:
const map = this.make.tilemap({ key: node.mapKey })
this.currentLayer = map.createLayer('图块层 1', tilesets, 0, 0)
this.currentLayer.setCollisionByProperty({ collides: true })
if (map.getLayerIndex('图块层 2') !== -1) {
const occlusionLayer = map.createLayer('图块层 2', tilesets, 0, 0)
if (occlusionLayer) occlusionLayer.setDepth(this.currentLayer.depth + 10)
}
「图块层 2」是遮挡层,人物走到建筑后面会被盖住,这是纯程序化拼图很难做好的层次感。另外对象层 Physics 里的矩形会被转成 staticBody,用来挡住城门入口这类不该穿越的区域。
代价也很直接:房间布局不再无限变化,只能靠三张手工地图轮换;自动拼接那套代码成了死代码,assets.json 里一堆瓦片坐标也失去了用武之地。像素原型阶段,好看和可控比「每次都不一样」更重要,但这条路一旦走上去,就回不去了。
战斗手感,本质上是几个字段
六种武器没有各自的攻击类,只是 src/weapons/weaponData.ts 里六份配置。伤害公式、命中判定、特效都复用同一套逻辑,靠 style 分叉:
sword: {
baseAttack: 5, range: 60, arc: 90, cooldown: 350,
knockback: 120, style: 'melee', hitStopMs: 60, shakeIntensity: 0.006,
},
dagger: { baseAttack: 3, range: 32, cooldown: 200, style: 'melee', hitStopMs: 20 },
伤害统一在这一行收敛,保证全链路都是整数:
const baseDmg = Math.round((w.baseAttack + s.attack + s.onHitDamage) * (1 + s.damageBoost / 100))
顿帧踩过一个坑,注释里也写死了:Phaser 4 里 world.timeScale 控制的是物理步进频率,而不是步进效果,设成 0.05 会让每帧步进约 18 次、角色瞬移。正确做法是完全暂停:
this.hitStopCount++
world.isPaused = true
this.time.delayedCall(durationMs, () => {
this.hitStopCount = Math.max(0, this.hitStopCount - 1)
if (this.hitStopCount === 0) world.isPaused = false
})
计数的存在是因为多段伤害可能在顿帧期间叠加,直接 false 会提前恢复。
弹道还有一个反直觉的点:setScale 只改显示,不改 Arcade body,所以「把特效缩小」等于让碰撞盒比看起来大一圈。解决办法是进场景时先用 canvas 把纹理缩到 90x90 再创建精灵,setSize 单独调。
存档:只存「有进度」的那一局
单局进度整个序列化进 localStorage,每 5 秒一次。但写之前会先判断这一局是否真的开始过:
export function hasRunProgress(d: any): boolean {
const nodes: any[] = Array.isArray(d?.graph?.nodes) ? d.graph.nodes : []
if (nodes.length === 0) return false
const start = nodes.find((n: any) => n.type === 'start')
if (start && d.currentNodeId !== start.id) return true
// 起点节点出厂即 cleared,不能算作进度
if (nodes.some((n: any) => n.cleared === true && n.type !== 'start')) return true
return typeof d.kills === 'number' && d.kills > 0
}
起点节点出厂就是 cleared: true,如果把它算作进度,玩家刚开局刷新就会出现「继续上次」这种假进度。存档的 key 和调试面板的配置 key 也刻意分开,清存档不会把调试开关一起清掉。
两个 Vue 侧的小技巧
技能栏的扇形冷却没有用 SVG,也没有用 Canvas,而是一层 conic-gradient 遮罩:
const maskStyle = computed(() => {
const r = cdRatio.value * 360
if (r <= 0) return { background: 'transparent' }
return { background: `conic-gradient(rgba(0,0,0,0.65) ${r}deg, transparent ${r}deg)` }
})
组件用 setInterval 80ms 轮询场景拿冷却,比让 Phaser 每帧 push 更省心。而血条、状态、Boss 血条这类高频数据走的是反向策略——Phaser 每帧拼一个签名串,只有内容变了才发事件:
const sig = `${Math.round(s.hp)}/${s.maxHp}|${debuffSig}|${boss ? boss.getData('hp') : '-'}|${this.currentWeapon}`
if (sig === this.statusSig && now - this.lastStatusAt < 300) return
同一条数据源被血条、状态栏、Boss 血条共用,省掉了三套各自轮询的定时器。
最后几点
- 「快」和「一致」会打架:README 里写的程序化房间、种子确定性,在当前代码里已经不是主路径;文档漂移比 bug 更容易误导后来的人。目录里还列着一个
CardSelectScene.ts,实际场景只注册了RoomScene。 - 生成器出问题应该看得见但不该阻塞,所以房间图校验只告警。
- UI 与游戏分离之后,最容易忽略的是输入权:打开任何覆盖层都要
input.enabled = false并把 canvas 的pointerEvents设为none,否则点击会穿透到游戏里触发攻击。 - 平衡性、实际手感这类东西无法从代码判断,我没有实测,这里只记录实现方式,不评价数值好坏。
原型还在长,但「引擎负责玩法、框架负责界面、两者只通过一条窄接口说话」这个结构目前没让我后悔。
