Rust로 구현하는 간단한 플래피 버드 게임

프로젝트 개요

bracket-lib 라이브러리를 활용한 간단한 플래피 버드 게임 구현 프로젝트이다. 스페이스바를 통해,玩家를 조작하며,P 키로 게임 일시정지 및 재개가 가능하다.

프로젝트 구조

.
├── Cargo.lock
├── Cargo.toml
├── src/
│   ├── main.rs
│   ├── player/
│   │   ├── player.rs
│   │   └── mod.rs
│   ├── game/
│   │   ├── game.rs
│   │   └── mod.rs
│   └── barriers/
│       ├── barrier.rs
│       └── mod.rs

game.rs는 게임 로직과 조작을 담당하고, player.rs는 플레이어 캐릭터의 동작을 구현하며, barrier.rs는 장애물의 생성과 이동을 처리한다.

모듈 정의 파일들

// game/mod.rs
pub mod game;

// player/mod.rs
pub mod player;

// barriers/mod.rs
pub mod barrier;

main.rs 구현

use bracket_lib::prelude::{main_loop, BError, BTermBuilder};

pub mod game;
pub mod player;
pub mod barriers;

fn main() -> BError {
    let ctx = BTermBuilder::simple80x50()
        .with_title("Flappy Bird")
        .build()
        .unwrap();

    let game_state = game::game::GameScreen::new();
    main_loop(ctx, game_state)
}

game.rs 구현

use crate::player::player;
use crate::barriers::barrier;
use bracket_lib::{
    prelude::{BTerm, GameState},
    terminal::{VirtualKeyCode, LIGHT_CYAN},
};
use std::collections::LinkedList;

const SCREEN_WIDTH: u32 = 80;
const SCREEN_HEIGHT: u32 = 50;
const GAME_CYCLE: f32 = 120.0;

enum PlayMode {
    Active,
    Finished,
    Paused,
    StartScreen,
}

pub struct GameScreen {
    current_mode: PlayMode,
    points: u32,
    player_entity: player::Player,
    frame_accumulator: f32,
    barrier_collection: LinkedList<barrier::Barrier>,
}

impl GameState for GameScreen {
    fn tick(&mut self, ctx: &mut BTerm) {
        match self.current_mode {
            PlayMode::Active => self.handle_active(ctx),
            PlayMode::Finished => self.handle_finished(ctx),
            PlayMode::StartScreen => self.handle_menu(ctx),
            PlayMode::Paused => self.handle_paused(ctx),
        }
    }
}

impl GameScreen {
    pub fn new() -> Self {
        let mut barriers = LinkedList::new();
        barriers.push_back(barrier::Barrier::new(0, 35, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 50, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 65, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 80, SCREEN_HEIGHT));

        Self {
            current_mode: PlayMode::StartScreen,
            points: 0,
            player_entity: player::Player::new(SCREEN_WIDTH, SCREEN_HEIGHT),
            frame_accumulator: 0.0,
            barrier_collection: barriers,
        }
    }

    fn restart(&mut self) {
        self.current_mode = PlayMode::Active;
        self.frame_accumulator = 0.0;
        self.player_entity = player::Player::new(SCREEN_WIDTH, SCREEN_HEIGHT);
        self.points = 0;
        
        let mut barriers = LinkedList::new();
        barriers.push_back(barrier::Boundary::new(0, 35, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 50, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 65, SCREEN_HEIGHT));
        barriers.push_back(barrier::Barrier::new(0, 80, SCREEN_HEIGHT));
        self.barrier_collection = barriers;
    }

    fn handle_active(&mut self, ctx: &mut BTerm) {
        ctx.cls();
        ctx.set_bg(SCREEN_WIDTH, SCREEN_HEIGHT, LIGHT_CYAN);

        self.frame_accumulator += ctx.frame_time_ms;

        if self.frame_accumulator >= GAME_CYCLE {
            self.player_entity.apply_gravity();
            self.player_entity.update_position();
            self.update_barriers();
            self.check_passage();
            self.frame_accumulator = 0.0;
        }

        if let Some(key) = ctx.key {
            match key {
                VirtualKeyCode::P => self.current_mode = PlayMode::Paused,
                VirtualKeyCode::Space => {
                    self.player_entity.jump();
                    self.frame_accumulator = 0.0;
                }
                VirtualKeyCode::Q | VirtualKeyCode::Escape => ctx.quit(),
                _ => (),
            }
        }

        self.player_entity.render(ctx);

        for barrier in &self.barrier_collection {
            barrier.render(SCREEN_HEIGHT, ctx);
        }

        ctx.print(0, 0, format!("점수: {}", self.points));
        ctx.print(0, 1, "SPACE: 점프");
        ctx.print(0, 2, "P: 일시정지  Q/ESC: 종료");

        if self.player_entity.is_out_of_bounds(SCREEN_HEIGHT) {
            self.current_mode = PlayMode::Finished;
        }
    }

    fn update_barriers(&mut self) {
        for barrier in &mut self.barrier_collection {
            barrier.slide(1);
        }
    }

    fn check_passage(&mut self) {
        let front_barrier = self.barrier_collection.front().unwrap();
        if front_barrier.get_x() as u32 <= self.player_entity.get_x() as u32 {
            let half_gap = front_barrier.get_gap_size() / 2;
            let top_limit = front_barrier.get_gap_y() - half_gap;
            let bottom_limit = front_barrier.get_gap_y() + half_gap;
            let player_y = self.player_entity.get_y() as u32;

            if player_y > top_limit && player_y < bottom_limit {
                self.points += 1;
            } else {
                self.current_mode = PlayMode::Finished;
            }

            self.barrier_collection.pop_front();
            self.barrier_collection
                .push_back(barrier::Barrier::new(self.points, 80, SCREEN_HEIGHT));
        }
    }

    fn handle_finished(&mut self, ctx: &mut BTerm) {
        ctx.cls();
        ctx.print_centered(6, format!("게임 오버! 점수: {}", self.points));
        ctx.print_centered(8, "R: 다시하기");
        ctx.print_centered(9, "Q/ESC: 종료");

        if let Some(key) = ctx.key {
            match key {
                VirtualKeyCode::R => {
                    self.restart();
                    self.current_mode = PlayMode::Active;
                }
                VirtualKeyCode::Q | VirtualKeyCode::Escape => ctx.quitting = true,
                _ => (),
            }
        }
    }

    fn handle_menu(&mut self, ctx: &mut BTerm) {
        ctx.cls();
        ctx.print_centered(6, "플래피 버드에 오신 것을 환영합니다!");
        ctx.print_centered(8, "S: 게임 시작");
        ctx.print_centered(9, "Q/ESC: 종료");

        if let Some(key) = ctx.key {
            match key {
                VirtualKeyCode::S => self.current_mode = PlayMode::Active,
                VirtualKeyCode::Q | VirtualKeyCode::Escape => ctx.quit(),
                _ => (),
            }
        }
    }

    fn handle_paused(&mut self, ctx: &mut BTerm) {
        ctx.print_centered(0, format!("현재 점수: {}", self.points));
        ctx.print_centered(7, "일시정지 상태 - P 키를 눌러 계속하기");
        self.player_entity.render(ctx);

        if let Some(key) = ctx.key {
            match key {
                VirtualKeyCode::P => self.current_mode = PlayMode::Active,
                VirtualKeyCode::Q | VirtualKeyCode::Escape => ctx.quit(),
                _ => (),
            }
        }
    }
}

player.rs 구현

use bracket_lib::prelude::BTerm;
use bracket_lib::terminal::{to_cp437, BLACK, GOLD};

pub struct Player {
    position_x: f64,
    position_y: f64,
    fall_speed: f64,
}

impl Player {
    pub fn new(screen_width: u32, screen_height: u32) -> Self {
        Self {
            position_x: (screen_width / 5) as f64,
            position_y: (screen_height / 2) as f64,
            fall_speed: 0.1,
        }
    }

    pub fn render(&self, ctx: &mut BTerm) {
        ctx.set(
            self.position_x as u32,
            self.position_y as u32,
            GOLD,
            BLACK,
            to_cp437('@'),
        );
    }

    pub fn get_x(&self) -> f64 {
        self.position_x
    }

    pub fn get_y(&self) -> f64 {
        self.position_y
    }

    pub fn apply_gravity(&mut self) {
        self.fall_speed += 0.1;
        self.position_y += self.get_velocity();
    }

    pub fn jump(&mut self) {
        self.position_y -= 2.0;
        self.fall_speed = 0.1;
    }

    pub fn update_position(&mut self) {}

    pub fn is_out_of_bounds(&self, max_y: u32) -> bool {
        self.position_y as u32 > max_y || self.position_y < 0.0
    }

    fn get_velocity(&mut self) -> f64 {
        if self.fall_speed > 2.0 {
            self.fall_speed = 2.0;
        }
        self.fall_speed
    }
}

barrier.rs 구현

use bracket_lib::{
    prelude::BTerm,
    random::RandomNumberGenerator,
    terminal::{to_cp437, BLACK, CRIMSON},
};

pub struct Barrier {
    x_position: u32,
    gap_height: u32,
    gap_center: u32,
}

impl Barrier {
    pub fn new(current_score: u32, initial_x: u32, height: u32) -> Self {
        let mut rng = RandomNumberGenerator::new();

        let gap_size;
        if current_score > 60 {
            gap_size = 2;
        } else {
            gap_size = u32::max(2, 20 - current_score / 3);
        }

        let half_gap = gap_size / 2 + 1;
        
        Self {
            x_position: initial_x,
            gap_height: gap_size,
            gap_center: rng.range(half_gap, height - half_gap),
        }
    }

    pub fn slide(&mut self, distance: u32) {
        self.x_position -= distance;
    }

    pub fn get_x(&self) -> u32 {
        self.x_position
    }

    pub fn get_gap_size(&self) -> u32 {
        self.gap_height
    }

    pub fn get_gap_y(&self) -> u32 {
        self.gap_center
    }

    pub fn render(&self, screen_height: u32, ctx: &mut BTerm) {
        let render_x = self.x_position;
        let half_gap = self.gap_height / 2;

        for y in 0..self.gap_center - half_gap {
            ctx.set(render_x, y, CRIMSON, BLACK, to_cp437('█'));
        }

        for y in self.gap_center + half_gap..screen_height {
            ctx.set(render_x, y, CRIMSON, BLACK, to_cp437('█'));
        }
    }
}

Cargo.toml 설정

[package]
name = "flappy_bird"
version = "0.1.0"
edition = "2021"

[dependencies]
bracket-lib = "0.8.7"

이 프로젝트는 Rust의 터미널 라이브러리인 bracket-lib를 활용하여 구현되었다. 게임 루프는 bracket-lib의 main_loop 함수를 사용하며, 각 프레임마다 GameState 트레이트의 tick 메서드가 호출되어 게임 상태를 업데이트한다. 플레이어 캐릭터는 중력의 영향을 받아下落하며, 스페이스바 입력 시 점프 동작을 수행한다. 장애물은 LinkedList로 관리되어 왼쪽으로 이동하면서 새로운 장애물이 오른쪽에서 생성된다.

태그: Rust bracket-lib game-development terminal-game flappy-bird

7월 13일 20:32에 게시됨