godot-rust(gdext)2D游戏之旅【flappy-bird】 - 3

0 阅读6分钟

简介

本文以Make Flappy Bird Game in Godot Engine | gameidea为蓝本撰写。如果你对rust、godot等内容比较生疏或感到疑惑,可以先阅读godot-rust入门文档

本文搭建在godot-rust(gdext)创建项目的基础之上,如果你对该部分内容感到生疏,可以先阅读这部分内容,为接下来的操作做好准备。

注意,本文使用的godot版本为4.6.2,godot版本的变化可能会导致一些内容失效

本文所有操作均在Windows11环境下进行。

正文

创建Main

在这个场景中,我们将把birdpipe放到一起,并控制游戏进程。

原文使用ParallaxBackgroundParallaxLayer组合方式来实现背景无限滚动效果,这种方式在godot 4.6中已经弃用。虽然仍然可以新建这两种节点,但为了与时俱进,我们采用官方推荐的Parallax2D方式来实现,可以参考2D 视差 — Godot Engine (4.x) 简体中文文档

rust中,创建Main节点。注意main.rs在rust中有特殊含义,这里文件名为main_scene.rs

// main_scene.rs
use godot::prelude::*;

#[derive(GodotClass)]
#[class(init, base=Node)]
struct Main {}

编译文件

cargo build

回到Main场景的编辑器界面。

  • 新建Main场景
  • Main节点添加子节点Parallax2D
  • Parallax2D子节点添加Sprite2D
  • Main节点添加实例化的bird子节点

godot中创建Main场景

  • 选中Sprite2D,将asset中的文件mountains-bg.jpg拖入,检查器 > Sprite2D > Texture
  • 选中Sprite2D检查器 > Node2D > Transform > Position > x设置404y设置200
  • 选中Parallax2D检查器 > Parallax2D > Repeat Size > x设置807
  • 选中Parallax2D检查器 > Node2D > Scale > xy均设置1.8
  • 选中bird检查器 > Node2D > Transform > Position > x设置270y设置360

原版游戏体验中,小鸟是向右飞行,为了延续同样的体验,我们需要让小鸟头朝右。回到Bird场景,选中AnimatedSprite2D,在检查器 > AnimatedSprite2D > Offset > Flip H > 启用

翻转bird动画

回到main场景中,这个时候你的场景看上去应该像这样

bird和背景

如果测试你的Main场景,这时你的还没有横向速度。

原教程中,为了让小鸟无限向右飞行,作者让ParallaxBackground的scroll_offset停地向左移动来模拟这个效果,这解决了小鸟坐标不会因为游玩时间足够长而发生数值过大的问题,但是却产生了scroll_offset可能会溢出的新问题。虽然我们使用的是Parallax2D,但在实际测试中给一个向左速度后Parallax2Dscroll_offset.x的绝对值确实在不断变得更大,而当玩家有足够耐心的时候,这个值就会发生溢出。

godot 4.6的官方指引中,也并没有专门讲解如何使用Parallax2D相机来实现背景无限滚动的方法,我们需要借鉴前后这两个文档的内容来手动实现。

回到rust中,我们需要对项目做一些调整。

首先确保我们的godot是关闭状态,找到Cargo.toml

// Cargo.toml
......

[dependencies]
# godot = "0.5.0" 换成如下所示
godot = { version = "0.5.0", features = ["experimental-godot-api"] }

执行

cargo clean

重新载入rust项目(比如关闭vscode再重新打开)

思路是这样的,我们用Parallax2Dscroll_offset属性来模拟镜头不断向右移动,就需要offset.x += -150.0 * delta。利用Parallax2Drepeat特性,每横向右移动1452.6像素后(背景图片的长度:807 * 我们放大的倍数:1.8)背景将完全重复一次,此时我们立即把背景向右移动1452.6像素来回溯offset,用以在保持背景一致的前提下使offset大小一直保持在恰当数值范围内。

回到main.rs,用下面代码测试我们的思路。

// main.rs
use godot::{classes::Parallax2D, prelude::*};

#[derive(GodotClass)]
#[class(init, base=Node)]
struct Main {
    #[init(node = "Parallax2D")]
    parallax2d: OnReady<Gd<Parallax2D>>,

    base: Base<Node>,
}
#[godot_api]
impl INode for Main {
    fn process(&mut self, delta: f32) {
        let mut offset = self.parallax2d.get_scroll_offset();
        offset.x += -150.0 * delta;

        // 让x轴回到repeat过程中的对应位置
        if offset.x < -1452.6 {
            offset.x += 1452.6;
        }

        self.parallax2d.set_scroll_offset(offset);
        
        // 可以打印出坐标观察offset的值
        // godot_print!("{}", self.parallax2d.get_scroll_offset());
    }
}
cargo build

重新打开godot测试Main场景,我们的bird已经可以正常地往右边“飞”了。

验证过思路,我们继续搭建游戏场景。

Main场景添加一个Timer子节点,命名为PipeSpawner。在检查器 > Timer > Wait Time设置为1.8。在同样的面板中启用AutoStart

设置Timer

Main场景添加一个CanvasLayer子节点,命名为UI。再向UI添加两个Label节点,分别命名为ScoreLabelMessageLabel

Label节点后,在编辑界面的工具栏中可以发现快捷定位按钮

label快捷定位

ScoreLabel设置顶部居中MessageLabel设置居中

选中Label节点后,找到检查器 > Control,为两个Label均设置:

  • Theme Override > Color > Font Color > Hex(000000)
  • Theme Override > Color > Font Outline Color > Hex(000000)
  • Theme Override > Constants > Outline > 15
  • Theme Override > Font Size > Font Size > 80

配置Label

为了在编辑界面体现效果,我们可以为两个Label在检查器 > Label > Text中添加演示文字:

  • ScoreLabel0
  • MessageLabelMessage

加入UI

回到rust,我们重新为Main编写游戏代码。

// main_scene.rs
use godot::{
    classes::{InputEvent, Label, Parallax2D, Timer},
    global::randi_range,
    prelude::*,
};

use crate::{bird::Bird, pipe::Pipe};

#[derive(PartialEq)]
enum GameState {
    Ready,
    Playing,
    GameOver,
}

#[derive(GodotClass)]
#[class(init, base=Node)]
struct Main {
    #[init(val = OnReady::manual())]
    current_state: OnReady<GameState>,

    score: i64,

    #[init(val = 150.0)]
    scroll_spped: real,

    #[export]
    pipe_scene: OnEditor<Gd<PackedScene>>,

    #[init(node = "UI/MessageLabel")]
    message_label: OnReady<Gd<Label>>,

    #[init(node = "UI/ScoreLabel")]
    score_label: OnReady<Gd<Label>>,

    #[init(node = "Bird")]
    bird: OnReady<Gd<Bird>>,

    #[init(node = "PipeSpawner")]
    pipe_spawner: OnReady<Gd<Timer>>,

    #[init(node = "Parallax2D")]
    parallax2d: OnReady<Gd<Parallax2D>>,

    base: Base<Node>,
}
#[godot_api]
impl INode for Main {
    fn ready(&mut self) {
        self.current_state.init(GameState::Ready);

        // 清理掉在godot编辑界面中的示意文字
        self.score_label.set_text("");
        self.message_label.set_text("");

        self.pipe_spawner
            .signals()
            .timeout()
            .connect_other(&*self, Self::on_pipe_spawner_timeout);

        self.bird.signals().hit().connect_other(&*self, |main| {
            main.set_state(GameState::GameOver);
        });
    }

    fn process(&mut self, delta: f32) {
        let mut offset = self.parallax2d.get_scroll_offset();
        offset.x += -self.scroll_spped * delta;

        if offset.x < -1452.6 {
            offset.x += 1452.6;
        }

        self.parallax2d.set_scroll_offset(offset);
    }

    fn unhandled_input(&mut self, event: Gd<InputEvent>) {
        if event.is_action_pressed("flap") {
            if *self.current_state == GameState::Ready {
                self.set_state(GameState::Playing);
            } else if *self.current_state == GameState::GameOver {
                self.base_mut().get_tree().reload_current_scene();
            }
        }
    }
}

#[godot_api]
impl Main {
    fn set_state(&mut self, state: GameState) {
        *self.current_state = state;

        match *self.current_state {
            GameState::Ready => {
                self.message_label.set_text("Flap to Start");
                self.message_label.show();
                self.score = 0;
                self.score_label.set_text(&self.score.to_string());
                {
                    let mut bird_bind = self.bird.bind_mut();
                    bird_bind.stop();
                    bird_bind.reset();
                }
            }
            GameState::Playing => {
                self.message_label.hide();
                self.pipe_spawner.start();
                self.bird.bind_mut().start();
            }
            GameState::GameOver => {
                self.message_label.set_text("Game Over\nFlap to Retry");
                self.message_label.show();
                self.pipe_spawner.stop();
                self.bird.bind_mut().stop();
            }
        }
    }

    fn on_pipe_spawner_timeout(&mut self) {
        let mut pipe = self.pipe_scene.instantiate_as::<Pipe>();

        pipe.set_position(Vector2 {
            x: 550.0,
            y: randi_range(250, 600) as f32,
        });
        pipe.bind_mut().set_scroll_speed(self.scroll_spped);

        pipe.signals()
            .hit()
            .connect_other(&*self, Self::on_pipe_hit);

        pipe.signals()
            .scored()
            .connect_other(&*self, Self::on_pipe_scored);

        self.base_mut().add_child(&pipe);
    }

    fn on_pipe_hit(&mut self) {
        self.set_state(GameState::GameOver);
    }

    fn on_pipe_scored(&mut self) {
        self.score += 1;
        self.score_label.set_text(&self.score.to_string());

        if self.score % 5 == 0 {
            self.scroll_spped += 10.0;
        }
    }
}
cargo build

运行Main场景,一个简易的flappy bird游戏就完成了。

最后

很显然,这个游戏不够完善,如代码所示,原文中只在每获得5分后加快移动速度10px,但管道生成速度依然保持为1.8s/个,你会发现游戏时间越长,管道相距距离反而越大,难度提升不明显。

我们可以从以下几个方面来丰富游玩体验

  • 加快管道生成速率
  • 减小管道通过空间
  • 加快管道移速改变速率(记得把速度同步到背景)
  • 等等......

这里是本文的最终代码,如需运行请编译rust,注意本文使用的环境为win11

参考

  1. godot-rust(gdext)创建项目 - 掘金
  2. godot - Rust
  3. Make Flappy Bird Game in Godot Engine | gameidea