1 小时学会用 MoonBit 开发马里奥游戏

351 天前
 Moonbit

嘿👋大家好!今天来讲讲一个好玩的游戏——马里奥游戏!

相信不少 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
}

Sprite

每个对象都需要在能在背景图片之上显示属于自己的动画,例如行走中的马里奥就由四帧动画组成。

我们用 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/

详细内容戳: https://mp.weixin.qq.com/s/pyJo8xcZ89o-ov0umwXW4A

667 次点击
所在节点    编程
1 条回复
AoEiuV020JP
351 天前
龟头没有碰撞箱?原版马里奥也是这样吗,新版是不是都这样的,

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/998142

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX