嘿大家好!今天来讲讲一个好玩的游戏马里奥游戏!
相信不少 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/
![]() | 1 AoEiuV020JP 2023-12-06 18:32:34 +08:00 龟头没有碰撞箱?原版马里奥也是这样吗,新版是不是都这样的, |