嘿👋大家好!今天来讲讲一个好玩的游戏——马里奥游戏!
相信不少 80 、90 后的朋友们在小时候都玩过马里奥游戏,对那个戴着红色帽子、穿着蓝色工装背带裤的马里奥叔叔念念不忘。
这款游戏自 1985 年面世以来,就以简单易上手和丰富有趣的情节关卡设计,迅速俘获全球玩家的心。
今天,让我们重拾那份童年的情怀~如果你的童年也曾被那魔性的 “灯灯灯灯灯灯灯”旋律洗脑,那就一起来追忆那些美好时光吧!让我们动起手来,用 MoonBit 创造一个属于自己的“马里奥游戏”吧!
首先,让我们来梳理马里奥游戏中的基本元素。
在游戏中,一共存在四种可以互相交互的对象,它们分别是玩家、敌人、物品、砖块。
每一种对象都有各自的分类,比如玩家按尺寸来说分为大小,按状态来说分为站立、奔跑、跳跃、蹲下。
我们依次定义这四种对象,并且用一个枚举类型来统一它们:
enum PlayerSize {
Small
Large
}
enum Player {
Standing
Jumping
Running
Crouching
}
enum Item {
Mushroom
Coin
}
enum Enemy {
Goomba
GKoopa
RKoopa
GKoopaShell
RKoopaShell
}
enum Block {
QBlock(Item)
QBlockUsed
Brick
UnBBlock
Cloud
Panel
Ground
}
enum Spawn {
Player(PlayerSize, Player)
Enemy(Enemy)
Item(Item)
Block(Block)
}
除了这四种基本对象之外,还有一种特殊的对象并不与其他对象进行交互,而只是一段固定的动画,例如砖块碎裂后四散的碎片,敌人死去后飘起的分数,我们也为它们定义一个枚举类型。
enum Part {
GoombaSquish
BrickChunkL
BrickChunkR
Score100
Score200
Score400
Score800
Score1000
Score2000
Score4000
Score8000
}
最后,每个对象都有自己的坐标;玩家和敌人都有各自的朝向,向左或者向右;对象之间唯一的交互方式是碰撞,而碰撞则分为上下左右四个方向。我们依次定义这些概念。
struct XY {
mut x : Double
mut y : Double
}
enum Dir1d {
Left
Right
}
enum Dir2d {
North
South
East
West
}
每个对象都需要在能在背景图片之上显示属于自己的动画,例如行走中的马里奥就由四帧动画组成。
我们用 Image 类型来表示由 JavaScript 运行时提供的图片,每个不同状态的对象都需要在对应的图片上裁剪出属于自己的部分。
此外,在判断碰撞时,每个对象在逻辑上也同样是一个个的方块,这种逻辑上的方块与对象图片所在的方块并不总是相同。例如对于乌龟来说,逻辑方块不包含头部。因此我们要分开定义两种方块。
struct SpriteParams {
max_frames : Int
max_ticks : Int
img_src : Image
frame_size : (Double, Double)
src_offset : (Double, Double)
bbox_offset : (Double, Double)
bbox_size : (Double, Double)
loop : Bool
}
struct Sprite {
mut params : SpriteParams
frame : Ref[Int]
ticks : Ref[Int]
mut img : Image
}
如果对各个参数的作用有疑惑,可以以一个具体的例子作为参考,下面是砖块的例子。
fn make_block(block : Block) -> SpriteParams {
match block {
Brick => setup_sprite_(block_, 5, 10, (16.0, 16.0), (0.0, 0.0))
QBlock(_) => setup_sprite_(block_, 4, 15, (16.0, 16.0), (0.0, 16.0))
QBlockUsed => setup_sprite_(block_, 1, 0, (16.0, 16.0), (0.0, 32.0))
UnBBlock => setup_sprite_(block_, 1, 0, (16.0, 16.0), (0.0, 48.0))
Cloud => setup_sprite_(block_, 1, 0, (16.0, 16.0), (0.0, 64.0))
Panel => setup_sprite_(panel_, 3, 15, (26.0, 26.0), (0.0, 0.0))
Ground => setup_sprite_(ground, 1, 0, (16.0, 16.0), (0.0, 32.0))
}
}
对于砖块来说,逻辑方块总是等于图片方块,因此在我们的构造函数中,我们只需要传入对象所在的图片、对象图片的帧数、每一帧持续的时间、对象图片的大小、对象图片的第一帧在整张图片中的位置。
接下来我们来处理一个相对简单的部分,即不需要和其他对象进行交互的碎片。
除了持续时间之外,每一个碎片都要记录自己的位置、速度和加速度,例如碎裂的砖块会做抛物运动,而敌人死后飘出的分数则做匀速直线运动。
struct ParticleParams {
sprite : Sprite
lifetime : Int
}
struct Particle {
params : ParticleParams
pos : XY
vel : XY
acc : XY
mut kill : Bool
mut life : Int
}
生成一个碎片之后,我们只要在每一帧结束之后独立地更新它的状态就可以了,不需要考虑交互的问题。
fn update_vel(self : Particle) {
self.vel.x = self.vel.x + self.acc.x
self.vel.y = self.vel.y + self.acc.y
}
fn update_pos(self : Particle) {
self.pos.x = self.pos.x + self.vel.x
self.pos.y = self.pos.y + self.vel.y
}
fn process(self : Particle) {
self.life = self.life - 1
if self.life == 0 {
self.kill = true
}
self.update_vel()
self.update_pos()
}
而对于能够相互碰撞的对象来说,处理起来则要复杂一些。
首先,我们需要为每个对象定义最基本的属性,例如位置、速度、编号、状态。
struct ObjectParams {
has_gravity : Bool
speed : Double
}
struct Object {
params : ObjectParams
pos : XY
vel : XY
id : Int
mut jumping : Bool
mut grounded : Bool
mut dir : Dir1d
mut invuln : Int
mut kill : Bool
mut health : Int
mut crouch : Bool
mut score : Int
}
接下来我们用一个枚举类型来统一四种基本对象。
enum Collidable {
Player(PlayerSize, Sprite, Object)
Enemy(Enemy, Sprite, Object)
Item(Item, Sprite, Object)
Block(Block, Sprite, Object)
}
在每一帧结束之后,除了独立地更新每个对象,我们还要处理它们之间的交互。
交互只会通过碰撞发生,所以我们首先需要判断两个对象之间是否发生了碰撞,以及碰撞的方向。
注意代码中的 col_bypass
过滤掉了互不影响的对象,比如敌人和硬币之间碰撞时可以不做任何处理,只是简单的互相穿过。
fn check_collision(c1 : Collidable, c2 : Collidable) -> Option[Dir2d] {
let b1 = get_aabb(c1)
let b2 = get_aabb(c2)
let o1 = get_obj(c1)
if col_bypass(c1, c2) {
Option::None
} else {
let vx = b1.center.x - b2.center.x
let vy = b1.center.y - b2.center.y
let hwidths = b1.half.x + b2.half.x
let hheights = b1.half.y + b2.half.y
if abs(vx) < hwidths && abs(vy) < hheights {
let ox = hwidths - abs(vx)
let oy = hheights - abs(vy)
if ox >= oy {
if vy > 0.0 {
o1.pos.y = o1.pos.y + oy
Option::Some(Dir2d::North)
} else {
o1.pos.y = o1.pos.y - oy
Option::Some(Dir2d::South)
}
} else if vx > 0.0 {
o1.pos.x = o1.pos.x + ox
Option::Some(Dir2d::West)
} else {
o1.pos.x = o1.pos.x - ox
Option::Some(Dir2d::East)
}
} else {
Option::None
}
}
}
在判断完碰撞关系之后,我们开始处理对象之间的交互。
玩家在敌人之上、玩家在砖块之下、玩家吃到金币,不同的事件会触发不同的处理函数,因此下面的判断函数不可避免地稍显复杂,它细致地对不同对象之间的交互进行了分类处理。
fn process_collision(dir : Dir2d, c1 : Collidable, c2 : Collidable, state : St) ->
(Option[Collidable], Option[Collidable]) {
match (c1, c2, dir) {
(Player(_, _, o1), Enemy(typ, s2, o2), South)|
(Enemy(typ, s2, o2), Player(_, _, o1), North) => player_attack_enemy(
o1,
typ,
s2,
o2,
state,
)
(Player(_, _, o1), Enemy(t2, s2, o2), _)|
(Enemy(t2, s2, o2), Player(_, _, o1), _) => enemy_attack_player(
o1,
t2,
s2,
o2,
)
(Player(_, _, o1), Item(t2, _, o2), _)|
(Item(t2, _, o2), Player(_, _, o1), _) => match t2 {
Mushroom => {
dec_health(o2)
if o1.health == 2 {
()
} else {
o1.health = o1.health + 1
}
o1.vel.x = 0.0
o1.vel.y = 0.0
update_score(state, 1000)
o2.score = 1000
(None, None)
}
Coin => {
state.coins = state.coins + 1
dec_health(o2)
update_score(state, 100)
(None, None)
}
}
(Enemy(t1, s1, o1), Enemy(t2, s2, o2), dir) => col_enemy_enemy(
t1,
s1,
o1,
t2,
s2,
o2,
dir,
)
(Enemy(t1, s1, o1), Block(t2, _, o2), East)|
(Enemy(t1, s1, o1), Block(t2, _, o2), West) => match (t1, t2) {
(RKoopaShell, Brick) | (GKoopaShell, Brick) => {
dec_health(o2)
reverse_left_right(o1)
(None, None)
}
(RKoopaShell, QBlock(typ)) | (GKoopaShell, QBlock(typ)) => {
let updated_block = evolve_block(o2)
let spawned_item = spawn_above(o1.dir, o2, typ)
rev_dir(o1, t1, s1)
(Some(updated_block), Some(spawned_item))
}
(_, _) => {
rev_dir(o1, t1, s1)
(None, None)
}
}
(Item(_, _, o1), Block(_), East) | (Item(_, _, o1), Block(_), West) => {
reverse_left_right(o1)
(None, None)
}
(Enemy(_, _, o1), Block(_), _) | (Item(_, _, o1), Block(_), _) => {
collide_block(true, dir, o1)
(None, None)
}
(Player(t1, _, o1), Block(t, _, o2), North) => match t {
QBlock(typ) => {
let updated_block = evolve_block(o2)
let spawned_item = spawn_above(o1.dir, o2, typ)
collide_block(true, dir, o1)
(Option::Some(spawned_item), Option::Some(updated_block))
}
Brick => if t1 == Large {
collide_block(true, dir, o1)
dec_health(o2)
(None, None)
} else {
collide_block(true, dir, o1)
(None, None)
}
Panel => {
state.game_over = true
game_win()
(None, None)
}
_ => {
collide_block(true, dir, o1)
(None, None)
}
}
(Player(_, _, o1), Block(t, _, _), _) => match t {
Panel => {
state.game_over = true
game_win()
(None, None)
}
_ => match dir {
South => {
state.multiplier = 1
collide_block(true, dir, o1)
(None, None)
}
_ => {
collide_block(true, dir, o1)
(None, None)
}
}
}
_ => (None, None)
}
}
对于相对简单的情况,例如到达终点游戏结束,我们直接在上面的函数中处理掉了。而对于更多的情况,我们在专门的函数中处理,例如下面的函数处理了玩家和砖块碰撞的情况。我们可以看到,发生碰撞之后,玩家相应方向上的速度降低到零,其余的属性也做出相应的改变。
fn collide_block(check_x : Bool, dir : Dir2d, obj : Object) {
match dir {
North => {
obj.vel.y = -0.001
}
South => {
obj.vel.y = 0.0
obj.grounded = true
obj.jumping = false
}
East | West => if check_x {
obj.vel.x = 0.0
}
}
}
以上就是 MoonBit 写马里奥游戏的简要介绍,完整的代码可以访问我们的在线 IDE 。在 MoonBit 实时编程环境中,你可以灵活调整马里奥的跳跃高度,实时创建多个马里奥角色,探索多重乐趣。此外,你还能实时调整游戏结束的逻辑,非常适合通过实践来理解和学习。
在线 IDE 链接: https://www.moonbitlang.cn/gallery/mario/
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.