• בלוג
  • יום 7 - פיתוח ממשק גרפי למשחק Snake ב Rust

יום 7 - פיתוח ממשק גרפי למשחק Snake ב Rust

02/01/2023

זוכרים את משחק סנייק מאתמול? אז היום נכתוב ממשק גרפי כדי שנוכל לשחק בו.

1. מה אנחנו בונים

המצב של פיתוח ממשקים גרפיים ב Rust לא מזהיר: יש מודולים שיודעים לחבר תוכנית Rust לספריות GUI קיימות משפות אחרות (למשל rust-qt, gtk-rs או fltk-rs), אבל הדברים שנכתבו מאפס בראסט עדיין לא בשלים, וגם לא ברור אם אי פעם Rust הולכת לשמש כשפה לפיתוח יישומים גרפיים. כיוון אחד שכן אנשים בונים ב Rust הוא ממשקי ווב בעזרת קומפילציה ל Web Assembly, אבל זה לא מה שרציתי לבנות היום.

כיוון אחר שעובד ב Rust הוא כלי שורת פקודה, ובמיוחד כלים שמציגים ממשקים "גרפיים" מבוססי טקסט, וספריה אחת שראיתי שהיתה פופולרית בהרבה פרויקטים היא ספריית cursive, שמאפשרת לבנות ממשק טקסטואלי מבוסס ncurses לתוכניות. זה אומר שהנחש שלנו יקבל מראה רטרו ויהיה מורכב מטקסט בלבד, אבל זה חלק מהכיף.

2. קצת על Cursive

ל cursive יש מדריך למתחילים שלי מאוד עזר, אפשר למצוא אותו בשלושה חלקים בקישורים:

https://github.com/gyscos/cursive/blob/main/doc/tutorial_1.md https://github.com/gyscos/cursive/blob/main/doc/tutorial_2.md https://github.com/gyscos/cursive/blob/main/doc/tutorial_3.md

זאת לדוגמה תוכנית Hello World פשוטה מאותו המדריך:

use cursive::views::TextView;

fn main() {
    let mut siv = cursive::default();

    siv.add_global_callback('q', |s| s.quit());

    siv.add_layer(TextView::new("Hello cursive! Press <q> to quit."));

    siv.run();
}

שימו לב איך כל דבר ב Cursive הוא View - אנחנו יוצרים משהו שנקרא TextView, מעבירים לו מחרוזת טקסט ובסוף מפעילים siv.run כדי "להתחיל" את התוכנית. הפקודה run חוזרת רק כשיוצאים מהממשק הטקסטואלי, והתוכנית כולה מציגה באמצע המסך את הטקסט ומחכה עד שנלחץ q כדי לחזור למסוף.

3. ממשק גרפי לנחש

בתוכנית שלנו אנחנו גם נבנה View, אבל הוא לא יהיה TextView אלא View מותאם אישית שאני בונה לבד. בשביל לבנות כזה אני צריך לממש פונקציה בשם draw, שמקבלת אוביקט בשם Printer ויכולה להשתמש בו כדי לצייר דברים על המסך. הנה המימוש שלי שמצייר את הנחש, בהתבסס על הסטראקט של המשחק שכתבנו אתמול:

fn draw(&self, printer: &Printer) {
    printer.print(self.board.snake.front().unwrap(), match self.direction {
        Direction::Up => "^",
        Direction::Down => "V",
        Direction::Left => "<",
        Direction::Right => ">",
    });
    for snake_pos in self.board.snake.iter().skip(1) {
        printer.print(snake_pos, "X");
    }

    printer.print(self.board.apple, "O");
}

אני מסתכל על הכיוון אליו אנחנו הולכים ולפי הכיוון מצייר את הסימן עבור ראש הנחש. אחרי זה אני ממשיך ומצייר X-ים לכל הגוף של הנחש, ואת האות O בקואורדינטות של התפוח. נשאר לנו רק לוודא שכל שניה תופעל הפונקציה step של המשחק, ואחרי זה תופעל מחדש draw. אה, וגם לטפל בלחיצות על המקלדת כדי לשנות את כיוון הנחש.

4. טיפול באירועים

הפוקציה on_event של View אחראית על טיפול באירועים, והאירוע היחיד שמעניין אותנו נקרא Key, כלומר לחיצה על כפתור במקלדת. הנה המימוש שבוחר ערך חדש ל self.direction לפי הכפתור שנלחץ:

fn on_event(&mut self, event: Event) -> EventResult {
    match event {
        Event::Key(key) => {
            self.direction = match key {
                Key::Down => Direction::Down,
                Key::Up => Direction::Up,
                Key::Left => Direction::Left,
                Key::Right => Direction::Right,
                _ => self.direction,
            };
            self.step();
        }
        _ => (),
    }
    EventResult::Ignored
}

5. עבודה ברקע ב Cursive

אחרי שנציג את ה View אם משתמש ילחץ על כפתור אוטומטית cursive ייכנס לפונקציה on_event וייתן לנו הזדמנות לטפל בלחיצה. אבל במשחק סנייק יש עוד דבר שצריך לעשות - להזיז את הנחש. ואת זה אנחנו צריכים לעשות לפי שעון, בלי קשר לאירועים שקרו או לא. בפריימוורקים רבים אחרים יש דרך להגדיר אירוע timeout על View, וכך להריץ קוד כל X שניות. ב cursive לא מצאתי דרך להפעיל שעון ברקע שיפעיל אירוע, ולכן הרעיון השני שלי היה להריץ Thread ברקע, שכל חצי שניה יפעיל את הפונקציה step של Game. זה התברר כהרבה יותר מסובך ממה שקיוויתי בגלל מודל הבעלות של Rust. הבלוק הראשון שאני רוצה להדביק הוא המימוש של GameView, שכולל את הפונקציה step:

impl GameView {
    pub fn new() -> Self {
        return GameView {
            board: game::Game::new(),
            direction: Direction::Down,
        };
    }

    pub fn step(&mut self) -> bool {
        return self.board.step(self.direction);
    }
}

בעצם בנוסף לכל מה שכבר סיפרתי על ה View שלי, הוספתי לו עכשיו עוד שתי פונקציות - הפונקציה new שיוצרת GameView חדש, והפונקציה step שמפעילה את הפונקציה step של המשחק שמוצמד ל View.

בקוד ה main אני כותב:

let mut siv = cursive::default();

let game = GameView::new();
let tx = siv.cb_sink().clone();

thread::spawn(move || {
    // game.board.step(Direction::Up);
    loop {
        thread::sleep(Duration::from_millis(500));
        tx.send(Box::new(|si| {
            si.call_on_name("game", |view: &mut GameView|  {
                let still_alive = view.step();
                if !still_alive {
                    panic!("You lose");
                }
            }).unwrap();
        })).unwrap();
    }
});

ברור למה אני מפעיל Thread חדש כדי שירוץ ברקע (עשינו משהו דומה בדוגמת התקשורת), אבל שימו לב שאני לא יכול מתוך ה Closure של ה Thread לקרוא ל game.step סתם כך. כלומר קוד כזה לא היה מתקמפל:

thread::spawn(|| {
    loop {
        thread::sleep(Duration::from_millis(500));
        game.step();
    }
});

בגדול בגלל שאף אחד לא יכול להבטיח ל Rust ש game יישאר חי כל זמן שה Thread נשאר חי (אולי זה משתנה פנימי של הפונקציה), אז ראסט צריך להעביר את הבעלות על game לתוך ה Closure, אבל אם הוא יעשה את זה לא נוכל להשתמש ב game מחוץ ל Closure. במלים אחרות למרות שאנחנו יודעים שה Thread המשני והראשי יישארו חיים לאותו משך זמן, ראסט לא יודע את זה ולכן דורש שנבחר מי משניהם ינהל את Game. וזאת בחירה שאין לי איך לעשות.

וזה מביא אותי לגירסה הקצת יותר מסורבלת של הקוד שמטרתה להשאיר את game בבעלות התהליכון הראשי, ולהפעיל את הפונקציה step באמצעות שליחת הודעה מה Thread המשני. הפונקציה siv.cb_sink של ספריית cursive מייצרת צינור תקשורת איתו אני יכול לשלוח הודעות מתהליכוני רקע ליישום הראשי, הפקודה send של צינור התקשורת שולחת את ההודעה ותוכן ההודעה הוא Closure בעצמו שמקבל Reference של אוביקט ה siv הראשי. מתוך ה Reference אני מפעיל את call_on_name כדי לקבל Reference ל Game View, ודרכו אני מפעיל את step, כל זה בלי שאף אחד ייקח בעלות על שום דבר. אני קורא לקוד כזה "מס ראסט", כי אני עובד יותר קשה רק בשביל להתאים לשיטת העבודה של ראסט. לאורך זמן נצטרך להחליט אם הערך של ראסט - כלומר תוכניות בטוחות יותר - שווה את המחיר.

אם נסכם את כל קטעי הקוד מהפוסט ועוד קצת קטעים של חיבורים נגיע לתוכן המלא של main.rs:

use cursive::{
    event::{Event, EventResult, Key},    
    view::{Nameable},    
    Printer, Vec2,
};
use game::{Direction};
use std::{thread};
use std::time::Duration;
mod game;

struct GameView {
    // Actual board, unknown to the player.
    board: game::Game,
    direction: Direction,
}

impl GameView {
    pub fn new() -> Self {
        return GameView {
            board: game::Game::new(),
            direction: Direction::Down,
        };
    }

    pub fn step(&mut self) -> bool {
        return self.board.step(self.direction);
    }
}

impl cursive::view::View for GameView {
    fn draw(&self, printer: &Printer) {
        printer.print(self.board.snake.front().unwrap(), match self.direction {
            Direction::Up => "^",
            Direction::Down => "V",
            Direction::Left => "<",
            Direction::Right => ">",
        });
        for snake_pos in self.board.snake.iter().skip(1) {
            printer.print(snake_pos, "X");
        }        

        printer.print(self.board.apple, "O");
    }

    fn on_event(&mut self, event: Event) -> EventResult {
        match event {
            Event::Key(key) => {
                self.direction = match key {
                    Key::Down => Direction::Down,
                    Key::Up => Direction::Up,
                    Key::Left => Direction::Left,
                    Key::Right => Direction::Right,
                    _ => self.direction,
                };
                self.step();
            }
            _ => (),
        }
        EventResult::Ignored
    }

    fn required_size(&mut self, _: Vec2) -> Vec2 {
        self.board.size.map_x(|x| 2 * x)
    }
}


fn main() {
    // Creates the cursive root - required for every application.
    let mut siv = cursive::default();

    let game = GameView::new();
    let tx = siv.cb_sink().clone();

    thread::spawn(|| {
        loop {
            thread::sleep(Duration::from_millis(500));
            game.step();
        }
    });

    thread::spawn(move || {
        // game.board.step(Direction::Up);
        loop {
            thread::sleep(Duration::from_millis(500));
            tx.send(Box::new(|si| {
                si.call_on_name("game", |view: &mut GameView|  {
                    let still_alive = view.step();
                    if !still_alive {
                        panic!("You lose");
                    }
                }).unwrap();
            })).unwrap();
        }
    });

    siv.add_layer(game.with_name("game"));

    siv.set_fps(24);
    // Starts the event loop.
    siv.run();
}

אותו תוכלו למצוא גם במאגר הגיטהאב של הסידרה בקישור: https://github.com/ynonp/rust-8-days/tree/main/day7%20-%20snake%20gui/snake

6. תרגילים להרחבה

  1. כרגע המשחק קצת משעמם כי לא משנה כמה תפוחים נאכל המהירות תישאר זהה (step נקראת פעם בחצי שניה). תחילה עדכנו את הקוד כך שיספור ויציג נקודות.

  2. אחרי שיש לכם נקודות עדכנו את הקוד כדי שככל שיש יותר נקודות מהירות המשחק תגדל.

  3. הוסיפו אפשרות יציאה באמצע - לחיצה על כפתור q מסיימת את המשחק.

  4. הוסיפו מסך Game Over אחרי שהנחש מתנגש בקיר או בעצמו.

אתם כרגיל מוזמנים לפרסם את הפיתרונות שלכם כאן (או לינקים לפיתרונות אם תבחרו להעלות אותם לגיטהאב) ואנחנו ניפרד מהנחש ומחר נמשיך לדבר על ראסט והאינטרנט.