V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Moonbit
V2EX  ›  编程

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

  •  
  •   Moonbit · 2023-12-06 18:21:45 +08:00 · 689 次点击
    这是一个创建于 387 天前的主题,其中的信息可能已经有所发展或是发生改变。

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

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

    1 条回复    2023-12-06 18:32:34 +08:00
    AoEiuV020JP
        1
    AoEiuV020JP  
       2023-12-06 18:32:34 +08:00
    龟头没有碰撞箱?原版马里奥也是这样吗,新版是不是都这样的,
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2855 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 08:01 · PVG 16:01 · LAX 00:01 · JFK 03:01
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.