跳到主要内容

当我想做一款吹羽毛小游戏……

· 阅读需 25 分钟

logo

背景

最近在B站有一款「李佳琦吹羽毛」的游戏非常火,我也去详细看了,觉得脑洞大开,非常有趣。

image-20230925090726811

无奈该款游戏UP主由于框架选型原因只有pc版的,很不方便。

考虑到我很喜欢基于 React 做些网页端随时可用的工具,因此打算也试着实现一番,其中诞生了一些思考与经验,特此分享一下。

目标设计

按照 UP 主的设计,目标是一款 2D 游戏,里面有游戏状态区、玩家、天空(一排花西子眉笔),和道具(分羽毛和金币)两种,以及一些其他的模型与动画效果,比如吹的时候会有气流等。

值得注意的是,羽毛在默认情况下,会缓慢降落,目测是固定的速度,这说明我们不需要基于重力系统实现这款游戏,这也是我们第一版实现的时候采取的方案,但是有个缺点,那就是依旧要处理羽毛被吹时的加速度变化(以看起来像是真地被吹了一样)。

V1 梦开始的地方

我们一开始是手写了这样的加速度变化,具体而言,我们首先给定每片羽毛的初始速度为比如说 0.3,然后一旦以力 F 吹了之后,速度变为 0.3 - F (比如说 -0.2,这样就反方向运动了),接着再在每一个 tick 不断回升其速度到原始速度,这样就模拟出了一个抛物增速的效果。

这是在纯手动实现上唯一一个比较细节的点,其他就还好了。就这样,我们很快设计出了第一版有加减速的版本。

IMG_2928

V2 伟大但失败的多人混战模型

当然,以上的游戏设计还远不能让我们感到满意,倒不是 UI 层面,而是逻辑层面,觉得太简单了。其实如果细心的话可以看到,我们的第一版就和 UP 主的设计不一样,区别在于没有了花西子眉笔,这样就没有了羽毛撞到花西子眉笔变成花西币的机制了,只能玩家之间互相吹来吹去,因此需要做多人在线。

此时,我心中一个大型多人在线的想法萌动了出来,既然大家只需要把羽毛吹来吹去,那其实人数(在带宽允许的情况下)可以无限多,我们只需要所有人等分一个圆即可!

于是,一个自觉天才的想法就诞生了:

35da072b4f37e1f4cf43376ee9636e9d

上述模型可以分成服务端和客户端两部分,其中服务端是个圆的世界模型,所有用户均匀分散在圆环上,而客户端(也就是用户界面)上是一个用户视角的矩形,其中下半矩形对应世界模型玩家侧的 1/N 区域,上班矩形则对应世界模型对手侧的 (N-1)/N 区域。

IMG_2953

但由于我们时间仓促,这个模型,虽然实现了,但是有bug,就是各种角度换算啥的,其实没算明白。

结果就被我们的产品经理给砍了,因为她首先非常不同意把 UI 做成矩形,因为这是个 2D 游戏,2D游戏一般要让所有玩家看到相同的游戏界面(例如三国杀之类),这么搞,每个用户的视角不一样,一般是 3D 的做法。

其次,也会让用户误会,以为自己是 1 VS (N-1)。

其实仔细看我们的世界模型可以发现,如果我们的羽毛每次从世界中心随机方向发出,然后被玩家吹后又逆向返回,则所有的玩家平均下来其实都是 1 v 1,其中玩家数量为偶数时正好对称,为奇数时则形成环。(我一开始就考虑过这个问题,并准备设计如果玩家是偶数,则变成组队模式(因为可以正好分成两队)。)

综合分析下来确实得不偿失,我们暂时没有必要做成这样,复杂还不一定买单。

不过我们的产品经理倒也并不反对有一个圆形的世界模型,但她更建议在客户端上做成一个圆或者椭圆。而现在的矩形……虽然我觉得只是曲率的区别……

我甚至想过给用户提供一个一键切换 UI 模式的按钮,让用户可以在矩形 UI 与圆形 UI 之间切换,但必须承认,这确实是偏技术角度的思考,矩形 UI 真地有很大的问题。

我自己举个觉得别扭的地方吧,我们刚刚说了矩形上下区域对应的世界模型面积是不一样的( 1 : n-1 ),这导致一个什么问题呢?如果我们的程序运行无误,在多人模式下,羽毛从自己这边飞过“楚河汉界”(也就是世界中心)后,羽毛会瞬间变小为 1/(n-1),并且会产生 x 轴的 shift,因为比例被压缩了,我觉得这是体验很不好的。

最后考虑到时间成本,我们暂时先搁置了换算 bug,开始 V3 的计划。

V3 只要肯放弃,万事无难事

在产品经理的耳提面命下,我们深刻地吸取了 V2 的教训,决定先完美复刻 UP 主的产品,守住初心。

于是,吭吭吭,很快,我们有了 UI 最酷炫的一版。

IMG_3007

在这一版中,我们深刻分析了游戏需要绝对位置的概念,因此自己封装了一些“游戏组件”,并且为了测试各种 translate,还额外加了 debug 的 UI 功能:

IMG_2988

只要开启测试模式,就可以实时显示每个元素的准心与边界,非常好用。

(不过后来我在使用 phaser 时发现人家也有这种设计,并且还显示物体的速度与加速度,更专业!)

这点给我的启示,真地是,世间的智慧是相通的,比如看这个人的评论:

947a5e4cd03b1cbd20ddeb7d023edd50

ok,暂时先不扯太远,总之,如您们所见,第三版在放弃梦想之后,老老实实做单机,加入花西子口红,一切就非常顺利,甚至还有时间换上了酷炫的背景动画,以及各种马里奥的 bgm 和 特效。这一版是我最喜欢的一版,尤其是在 UIUX 上。

但是,很可惜,产品经理马上就指出了新的问题:掉帧严重!

为此,我开启了慢慢地调试与摸索之路,继而真正领会了游戏开发的奥秘。

V4 游戏开发的左手:渲染引擎!

经过大量的调试,我逐步排除了桩服务器、多次setState、hook memo 等可能的性能问题,查阅了大量资料,最终不得不承认,基于状态管理的 react 根本不适合作为高刷游戏的载体,仅仅是 79 个羽毛与金币,服务端 50 的 fps,客户端 UI 竟然能卡到只剩 10 fps,这绝对是无法接受的。

0ba6a341303b5c06f42264ad9b9221e4

但同样的代码,我们使用 react-three-fiber (底层基于 webglcanvas)却能完美运行,客户端 UI 少说也能稳定在 30 fps 以上!

我不相信,又做了大量的调优,最后还是不得不放弃,除非我改写整个 react 的状态刷新,比如 79 个羽毛和金币 不要用 setState 之类,而是自己画 canvas 或者用 ref 更新啥的,但总之,这个工作量,或者说目标实现,其实就等于重新用一套新的渲染引擎了,而这一套引擎,我重点查了一下,大致有 pixijsthreejsphaser等等。

最终,我们使用 react-three-fiberthreejsreact版),完成了我们第四版的游戏设计,并在设置菜单里提供了渲染模型的切换,大家可以切换后仔细对比两种渲染引擎下的性能差异(至少 2 倍,实际是差一个数量级)。

V7 游戏开发的右手:游戏引擎!

既然我们已经把目光从传统的 react 开发转向了渲染引擎,那么最后一步,直接使用专业的游戏引擎进一步加速研发,将成为了一个自然的选择,所谓游戏引擎,就是集成了 刷新、交互、渲染等一整套游戏逻辑,以性能、兼容性等为主要目标的框架。

我主要查看了以下资料:

并且在简单尝试了 star 70 多 k 的 GoDot 之后,觉得还是不太习惯有 UI Editor 的游戏框架,以及不太想学新的语言(GDScript)。但不排除未来会有深度研究使用 GoDot 的可能,毕竟它 2D、3D都支持,而且是跨端的,会基于目标输出平台决定所使用的性能,这个非常棒。

image-20230925091822199

image-20230925092037914

(好吧,其实是因为 GoDot 我不怎么会用,2333 ……)

而在 JS 生态里,2D 有 phaser / pixi.js ,3D 有 three.js ,目前都非常能打,所以最终选择了 phaser

image-20230925084426852

phaser 由于经常被用于制作 rpg 游戏,所以我们也融入了一些 rpg 元素,摸索 phaser 也花了一定的时间,但最好的学习资料就是 phaser ( v3 版本)的官方案例库:

它的在线 demo 做的很高端(sha bi),竟然用的是文件夹的形式,但是请相信我,每个文件夹都过一过,这绝对是最好的资料,与代码库直接对接,否则绝对都是舍近求远了:

image-20230925102310842

比如我们可爱的 nayuki 同学,就是在 /Input/Dragging 里一眼相中的:

image-20230925075546752

然后我们游戏里吹羽毛时的龙卷风,其实本来是想直接用 /Animation/Yoyo 里的火焰,但是代码里是基于图像的切片实现的,火焰与角色是混在一起的,不方便切,所以就放弃了。

image-20230925102348410

最后我们是结合 ChatGPT4 自己手写了扇形蒙版动画,并精心挑选了静态龙卷风 png 组合完成:

image-20230925075644351

最终效果,就是酷炫的动态龙卷风了:

RPReplay_Final1695640188 (2)

核心技术汇总

FPS

帧同步主要瓶颈一般是在客户端上,因为客户端不但要处理数据更新,还要处理数据渲染,UI 与事件会抢进程,再加上与后端接口数据交互有延时,就会有明显的掉帧。

因此在网页端,几乎都是 30 FPS 左右,60 FPS 都要风扇发热了,更高(比如 90 FPS、120 FPS)就只有在设备终端本机环境、电影院等才支持了。

而在客户端向服务器获取数据上,有几种办法,除了 socket 之外,都是客户端主动向服务端发起请求的,以下给出几种办法。

  1. useInterval

而在客户端,我们一开始是使用一个 useInterval函数,它可以每隔 N ms 刷新一次 UI,以下是我们后端小哥哥和 @mantine/hooksuseInterval写法的对比:

image-20230925102451944

对比下来,如果不涉及主动控制,确实还是我们小哥哥写的简单方便,而且由于每次都传入最新的 cb,因此不会有脏数据的问题。

  1. 基于 trpc

可以基于 referchInterval 参数,自动每 N ms 获取一次数据。

  const { data, isFetching } = trpc.syncServer.useQuery(
{ roomId, eventsId: eventsIdRef.current },
{
enabled: !!roomId,
refetchInterval: 1000 / CONFIG.game.fps.client,
}
)

但使用 trpc 也要谨慎,有时会出现 isFetching = false, data=undefined的情况,这还是一个暂时留待去研究的小 bug。

除此之外,倒是问题不大。

  1. 基于游戏引擎的 nextTick 机制

游戏引擎里都会有一个类似 initonUpdate 的机制,每个 tick 都会自动触发 onUpdate 函数。在这个函数里面我们可以处理数据的更新。

// update 函数里非常适合处理 Input 事件
update(time, delta) {
// this.tick++
// const fps = Math.round((1000 / delta) * 10) / 10
// console.log({time, delta, tick: this.tick, fps})

const step = this.vw / 100
if (this.keys.A.isDown || this.keys.LEFT.isDown) this.player!.x -= step
if (this.keys.D.isDown || this.keys.RIGHT.isDown) this.player!.x += step
// 只要按住就会一直feedback
if (this.keys.SPACE.isDown) {
if (!this.isSpacingPressing) {
this.isSpacingPressing = true
this.lastDownTime = Date.now()
// console.log("down: ", this.lastDownTime)
}
}
if (this.keys.SPACE.isUp) {
if (this.isSpacingPressing) {
// 不考虑drag了
// console.log("up: ", Date.now() - this.lastDownTime)
this.handleShoot(Date.now() - this.lastDownTime)
}
this.isSpacingPressing = false
}

this.updatePlayerX(this.player!.x)
}

socket 与 nextjs 的集成

多人在线玩游戏是一个看起来很简单,但实际上颇有技巧的一件事,一般都用 socket,但也有其他的方案。

这里简单说一下我们集成 socket.ionextjs 里的一些要点:

首先一个非常大的坑是,nextjs@13.4.12环境下,socket.io服务器端需要额外配置addTrailingSlash: false,否则会自动加上 / 导致 api 访问出错,具体参考:https://github.com/vercel/next.js/issues/49334#issuecomment-1539268823

其次,由于 socket.io 默认挂载在 /socket.io/ 路径下,与 nexjts 默认的 api 路由并不一致,因此需要做一些处理,这里提供三种办法。

  1. 显式指定 socket 挂载的路径:
// [server] /pages/api/socket.ts 
export default async function handler(
req: NextApiRequest,
res: NextApiResponseServerIO
) {
if (res.socket.server.io) {
// console.log("Socket is already running")
} else {
console.log("initializing Socket.io server...")

// adapt Next's net Server to http Server
const httpServer: NetServer = res.socket.server as any
const server = new Server(httpServer, {
path: SOCKET_IO_ENDPOINT,
addTrailingSlash: false,
}

handleSocket(server)

// append SocketIO server to Next.js socket server response
res.socket.server.io = server
}

res.end()
} ...


// [client] /lib/socket.ts
export const socket = socketio(
// 不需要添加 url,直接指定 path即可
{
transports: [
// "websocket", // 不能开 websocket,我也不知道为什么!
"polling", // 这是默认的,关了后就没法用了
], // ref: https://stackoverflow.com/a/41953165/9422455
path: SOCKET_IO_ENDPOINT,
autoConnect: false,
timeout: 1000,
}
)
  1. 无论是客户端还是服务器端,都不指定 path,而是基于 fetch 激活服务端路由 (很多帖子里都是用的这种办法,很神奇)
 const init = async () => {
// 要先唤醒一下服务器
await fetch("/api/socket")

// no-op if the socket is already connected
console.log("connecting")
socket.connect()

for (const event of allEvents) {
console.log("on handler of ", event.name)
socket.on(event.name, event.handler)
}

socket.on("connect", () => {
// 客户端有可能都没有监听 UserJoinRoom,但是服务端我们要加,否则客户端容易bug
socket.emit(SocketEvent.UserJoinRoom, {
content: `user ${socket.id} joined room ${roomId}`,
...extra,
socketId: socket.id,
} as IFullMsg)
})
}
  1. 还有一种办法,是单开一个 nodejs 服务,使用 middleware 形式启动 socket,由于我个人不是很喜欢,所以就不放了。但也是很重要的一种办法。也很直接。

除了以上的一些要点之外,在 socket 的写法上也有各种办法,我是用了一个 useSocketEvents 统一管理需要监听的函数,注意最后要使用 beforeunload 的 eventListener 做清理动作,否则用户刷新浏览器,socket还活着,不符合预期。

export interface Event {
name: string

handler(...args: any[]): any
}

export const defaultEvents: Event[] = [
// { name: "connect", handler: console.log },
{ name: "connect_error", handler: console.error },
{ name: "disconnect", handler: console.log },
]

/**
* ref: https://www.codeconcisely.com/posts/react-socket-io-hooks/
*/
export function useSocketEvents(events: Event[], extra: IMsg) {
console.log("using socket events")
const { roomId, userImage } = extra
const allEvents: Event[] = [...defaultEvents, ...events]

const cleanup = () => {
console.log("disconnecting")
socket.disconnect()

for (const event of allEvents) {
console.log("off handler of ", event.name)
socket.off(event.name)
}
}
...

useEffect(() => {
void init()

window.addEventListener("beforeunload", cleanup)

return () => {
// note: important! react不会自己清除,ref: https://stackoverflow.com/a/61310052/9422455
window.removeEventListener("beforeunload", cleanup)
}
}, [])
}

游戏引擎与 React/NextJS 的集成

诚如这位老哥所言, React 可以与游戏集成,但是仅限于用于制作 UI 上,要注意与游戏的代码分离。

image-20230925102547276

我也顺利找到了将 phaser 与 其他集成的模板:

我个人比较喜欢 nextjs,因此基于最后的改了改,其实核心要领,就是要用 dynamic 组件,因为 phaser 不支持 ssr,否则会甚至有 HTMLVideoElement is not defined 的问题。

力导向

phaser 中提供了几个关于 applyForce 相关的函数,都是基于 matter.js 的。

主要有三个 API,都在 Phaser.Physics.Matter.MatterPhysics 下,参考:https://newdocs.phaser.io/docs/3.54.0/Phaser.Physics.Matter.MatterPhysics#applyForceFromAngle

  • applyForce(bodies, force): Applies a force to a body, at the bodies current position, including resulting torque.
  • applyForceFromPosition(bodies, position, speed, [angle]):
    • Applies a force to a body, from the given world position, including resulting torque. If no angle is given, the current body angle is used.
    • Use very small speed values, such as 0.1, depending on the mass and required velocity.
    • 注意这里的 position 是摄像机里的目标位置,而非平面坐标位置,关于这个,调了很久!
  • applyForceFromAngle(bodies, speed, [angle]):
    • Apply a force to a body based on the given angle and speed. If no angle is given, the current body angle is used.
    • Use very small speed values, such as 0.1, depending on the mass and required velocity.

我很想给大家画点示意图,因为官网的文档给的并不详细,并给出多种力导向的做法,比如生成一个 matter body,或者生成虚拟的形状,这两种各有利弊,但此处限于篇幅,暂时就不赘述了把!

目前我们是使用了基于点位置的扇形力导向,但其实也很方便做多种导向(类比 moba 游戏的英雄技能!)。

其他

微信小游戏

我们本来想要做微信小游戏的,结果需要至少一个月的审核,并且还需要软著,觉得没必要,就没搞了。。。

JS/React 相关游戏资源、教程

强推一款需要 64 G 内存的超大型多人在线沙盒游戏,基于 nextjs + webgl + webAssembly 的:Biomes — Join the community shaping a new world, https://www.biomes.gg/

最后

哈哈哈,本来以为两小时就能搞定的小游戏,没想到扯出这么多犊子,哈哈哈哈,真地没谁了!

有兴趣的朋友可以一起开发 V7 吹羽毛呀。(或者不会写代码,去点个赞也可以啊 👀 )

以上!我们下次再见!

但愿我们每个人都能加薪!