Wasm4 案例:双人乒乓球
WASM-4 是一款使用 WebAssembly 实现的较底层的虚拟游戏机,主要用于构建小型、复古的游戏。 和其他一些可能可以编译到 WebAssembly 的语言不同,MoonBit 为 WASM 平台提供第一方支持,社区则提供了 WASM-4 的 binding。 使用 WASM-4 进行游戏开发,MoonBit 是有许多优势的。
我们用一个十分简单的,但却支持多人游玩的乒乓游戏来展示如何利用 WASM-4 开发小型游戏。
配置环境
- wasm-4 runtime,可通过 npm 安装:
npm install -D wasm4
-
新建一个 MoonBit 项目,将 wasm4 binding 作为依赖加入到项目中:
moon add moonbitlang/wasm4
. -
修改
moon.pkg.json
,linking 配置中应当导出start
update
方法供 wasm-4 调用::
{
"import": ["moonbitlang/wasm4"],
"link": {
"wasm-gc": {
"exports": ["start", "update"],
"import-memory": {
"module": "env",
"name": "memory"
}
},
"wasm": {
"exports": ["start", "update"],
"import-memory": {
"module": "env",
"name": "memory"
},
"heap-start-address": 6590
}
}
}
注意对于 wasm 后端,在 0x0000 ~ 0x19BE
(6590
,开区间)范围内的地址是分配给 wasm4 ABI 的,MoonBit 的堆在该区间之后。 若使用 wasm-gc 后端则不需配置。
- 根据 ABI 的规格,必须要导出
start
update
方法start
仅在游戏初始化时执行一次update
会以游戏刷新率(60Hz)的频率执行
基本想法是,我们将任何静态代码(即那些不会改变游戏状态的代码)放在 start
中(例如初始化调色板); 除此之外的动态代码(即那些会更改游戏状态的),就需要放在 update
中。 游戏的主要逻辑和相关的函数调用都是在 update
中完成的。
在 MoonBit 中,函数是通过改变其可见性为 pub
来实现导出功能的。
确保环境配置一切无误之后,我们就可以开始编写游戏了。
画一个乒乓球
毕竟我们是实现一个乒乓游戏,那么最基本的工作就是绘制球和挡板:
@wasm4.set_draw_colors(0x2U, index=1)
@wasm4.set_draw_colors(0x3U, index=2)
@wasm4.oval(bs.ball_x, bs.ball_y, ball_size, ball_size)
@wasm4.rect(0, bs.y_2, width, height)
@wasm4.rect(screen_size - width, bs.y_1, width, height)
oval
用于在给定中心 (ball_x,ball_y)
处绘制椭圆。长轴和短轴相等的椭圆就是一个圆,且它的直径是 ball_size
。
为元素上色
根据 memory layout, wasm-4 的调色板寄存器( PALETTE
,在 MoonBit 的绑定中对用户不可见)一次只能存储 4 种颜色, 但可以通过随时更改这一寄存器来引入新的颜色。我们使用 set_palette
来配置调色板,但在这个实现中就使用默认颜色配置了。
默认的配色看起来像 gameboy 的:
但需要注意内置的绘图函数不直接访问这个寄存器, 而是访问同样能够存储 4 个颜色的 DRAW_COLORS
寄存器(也对用户不可见)。
// 按 `palette_index` 访问 PALETTE 的颜色
// 并将之设定为 `DRAW_COLORS` 对应 `index` 的颜色
set_draw_colors(palette_index, index)
这两个寄存 器的关键区别在于 DRAW_COLORS
的 4 个颜色具有不同的用途:
对内置的 oval
rectangle
形状来说,寄存器的第一个颜色是填充色,第二个颜色是边框色。
因而我们的乒乓球看起来应该像:
注意像这样动态的绘图代码必须在 update
中调用。更新游戏状态的唯一方法就是通过 update
函数。
处理撞击情况
在 wasm-4 中,原点 (0,0)
在左上角,纵轴指向下。
乒乓球的撞击情况有三种:
- 球根本没有发生撞击,记为
0
- 球与左侧挡板撞击,弹向右侧,记为
1
- 球与右侧挡板撞击,弹向左侧,记为
-1
我们将左右侧挡板记为 y_1
y_2
(挡板只能上下移动)。用于处理撞击的函数必须返回球撞击后的方向(或0),即 0/1/-1
.
列出这些情况后,我们可以实现 paddle_collision
如下:
pub fn paddle_collision(
y_1 : Int,
y_2 : Int,
ball_x : Int,
ball_y : Int
) -> Int {
if ball_x < width && ball_y < y_2 + height && ball_y + ball_size > y_2 {
1
} else if ball_x + ball_size > screen_size - width &&
ball_y < y_1 + height &&
ball_y + ball_size > y_1 {
-1
} else {
0
}
}
移动挡板
移动挡板就是更新 y_1
y_2
。在 wasm-4 中,用户输入是通过 gamepads 获取的。一个 gamepad 包括 4 个方向键,两个动作键。总共有 4 个 gamepads(即最多 4 名玩家),默认情况下 gamepad1
最先使用。
MoonBit 中使用以下方法访问 gamepad:
get_gamepad().<BUTTON> : Bool
当按键按下时返回一个 true
,否则 false
.
Networking
这里正 好可以顺便介绍 netplay,wasm-4 的多人游戏功能。实际上我们根本不需要处理网络栈, wasm-4 底层使用 WebRTC 但这我们不需要在意。对开发者来说,玩家就是通过对应的 gamepads 来表示的。 因此若一个玩家加入,游戏会按 gamepads 的数字顺序分配给其一个 gamepad。 例如 gamepad1
已经被某名玩家使用, 在其之后若还有玩家加入则会为其分配 gamepad2
。
乒乓是一个双人游戏,我们将 gamepad1
绑定到右侧挡板上;gamepad2
绑定到左侧挡板上。
默认情况下,左侧挡板的位置是根据乒乓球上下位置决定的,也就是说左侧挡板作为一个永远不会漏球的 AI 来和玩家竞争。 但我们也可以用内置的
get_netplay() : Bool
来确认多人游戏功能有没有启用:如果启用,则启用 gamepad2
;否则让 AI 接手。
// 判定若挡板高度超过屏幕高度
if @wasm4.get_gamepad().button_up && bs.y_1 > 0 {
bs.y_1 -= 2
} else if @wasm4.get_gamepad().button_down && bs.y_1 + height < screen_size {
bs.y_1 += 2
}
if @wasm4.get_netplay().active {
if @wasm4.get_gamepad(index=2).button_up && bs.y_2 > 0 {
bs.y_2 -= 2
} else if @wasm4.get_gamepad(index=2).button_down &&
bs.y_2 + height < screen_size {
bs.y_2 += 2
}
} else {
bs.y_2 = bs.ball_y
}
bs
是BallStat
的简写,是一个用于记录当前游戏状态的 struct。
struct BallStats {
mut ball_x : Int // ball's horizontal pos
mut ball_y : Int // ball's vertical pos
mut dir_x : Int // ball's direction (horizontal)
mut dir_y : Int // ball's direction (vertical)
mut y_1 : Int // left bat position
mut y_2 : Int // right bat position
mut score_1 : Int // player1
mut score_2 : Int // player2
}
音效反馈
wasm-4 当然是不缺那些怀旧的八位机游戏音效(tone)的。要播放一个 tone,我们一般使用内置的
fn tone(
frequency : (UInt, UInt),
duration : ADSR,
volume : ADSRVolume,
flags : ToneFlag
)
- frequency:频率,即用频率度量的音调
- Duration:时长,是用 envelope 记录的。 envelope 用于记录一个声音随着时间的变化, 此处的 ADSR 指定了 envelope 的四个参数 attack, decay, sustain and release。
- volume:字面义,音量大小
- flags:包括 channel, duty cycle 和 panning。 对于我们的乒乓游戏来说,使用默认值即可。
例子:在球发生撞击的时候播放音效,我们需要获取判定球是否发生撞击,即上边所说 paddle_collision
返回的值是否非零:
let dir_now = paddle_collision(bs.y_1, bs.y_2, bs.ball_x, bs.ball_y)
if dir_now != 0 { // ball collides
let int_rng = rng.int() % 2
bs.dir_x = dir_now
@wasm4.tone(
(2000, 0),
@wasm4.ADSR::new(5),
@wasm4.ADSRVolume::new(100),
@wasm4.ToneFlag::new(),
)
bs.dir_y = dir_now * if int_rng != 0 { -1 } else { 1 }
}
或者当球撞击到屏幕的顶部/底部时,球应该回弹并反馈音效:
if bs.ball_y > screen_size || bs.ball_y < 0 {
@wasm4.tone(
(2000, 0),
@wasm4.ADSR::new(5),
@wasm4.ADSRVolume::new(100),
@wasm4.ToneFlag::new(),
)
bs.dir_y = -bs.dir_y
}
正如我们之前所规定的,如果检测到碰撞,dir_now
应为非零值:此时我们会发出提示音。
完整代码
这里的完整代码(120 行左右)也包括了格式化之后产生的各种换行,否则会更少。十分简单。
编译 & 游玩
构建:
moon build --target {wasm-gc, wasm}
通过 wasm4 和 npx 运行:
npx wasm4 run target/{wasm-gc,wasm}/release/build/{PACKAGE_PATH}/{PACKAGE_NAME}.wasm
游戏应该会自动在浏览器中打开。