0x09. 游戏菜单

755 阅读3分钟

现在一启动程序就很厉害了,无垠的宇宙有一艘飞船在飞行,但是通常游戏不是这样的呀,好歹给我个选项界面。要用上之前写好的切换视图的操作了。

pub enum ViewAction {
    None,
    Quit,
    ChangeView(Box<dyn View>)
}
pub fn spawn(title: &str) {
    // ...
    let mut current_view: Box<dyn View> =
        Box::new(views::menu::Menu::new(&mut context));
    'running: loop {
        // ...
        match current_view.render(&mut context, elapsed) {
            ViewAction::None => context.canvas.present(),
            ViewAction::Quit => break 'running,
            ViewAction::ChangeView(view) => current_view = view
        }
    }
}

pub struct Menu;

impl View for Menu {
    fn render(&mut self, context: &mut Phi, _: f64) -> ViewAction {
        let events = &context.events;
        let canvas = &mut context.canvas;
        if events.now.quit || events.now.key_escape == Some(true) {
            return ViewAction::Quit;
        }
        canvas.set_draw_color(Color::RGB(0, 0, 0));
        canvas.clear();
        ViewAction::None
    }
}

现在只要一启动,启动的是菜单页面,虽然现在菜单页面什么都没有,一片黑。我们还要把 Cargo.toml 改一下,因为菜单肯定要用到文字。

[dependencies.sdl2]
version = "0.29"
default-features = false
features = ["image", "ttf"]

然后我们要将菜单页的行为抽象,一个选项具备功能,还具备标题

struct Action {
    func: Box<Fn(&mut Phi) -> ViewAction>,
    idle_sprite: Sprite,
    hover_sprite: Sprite,
}

接着实现一下 Action 给这玩意加点料.

fn new(
    context: &mut Phi,
    label: &'static str,
    func: Box<dyn Fn(&mut Phi) -> ViewAction>,
) -> Self {
    Action {
        func,
        idle_sprite: context
            .ttf_str_sprite(
                label,
                "assets/belligerent.ttf",
                32,
                Color::RGB(220, 220, 220),
            )
            .expect("Failed to load idle sprite font"),
        hover_sprite: context
            .ttf_str_sprite(
                label,
                "assets/belligerent.ttf",
                38,
                Color::RGB(255, 255, 255),
            )
            .expect("Failed to load hover sprite font"),
    }
}

这里放了两个字体, 字体文件可以克隆教程项目拿去. 但是现在我们紧要任务是得学一下怎么渲染文本, 顺道实现一下 ttf_str_sprite 函数

pub struct Phi<'window> {
    pub events: Events,
    pub canvas: Renderer<'window>,
    pub ttf_context: &'window Sdl2TtfContext
}

impl<'window> Phi <'window> {
    fn new(events: Events,
        canvas: Renderer<'window>,
        ttf_context: &'window Sdl2TtfContext,
    ) -> Phi<'window> {
        Phi { events, canvas, ttf_context }
    }
    
    pub fn ttf_str_sprite(
        &mut self,
        text: &str,
        font_path: &'static str,
        size: u16,
        color: Color,
    ) -> Option<Sprite> {
        self.ttf_context
            .load_font(Path::new(font_path), size)
            .ok()
            .and_then(|font| font
                .render(text).blended(color).ok()
                .and_then(|surface| self.canvas
                    .create_texture_from_surface(&surface).ok()
                    .map(Sprite::new)
            )
    }
}

加载字体完成, 然后可以优化一下, 其实也不算优化, 就是 cache font. 值得一提的就是, map 会返回 enum Option 值, 所以不需要再自己包装 Some 之类的.

pub struct Phi<'window> {
    ...
    cached_fonts:
        HashMap<(&'static str, u16), sdl2::ttf::Font<'window, 'window>>,
}

pub fn ttf_str_sprite(
    &mut self,
    text: &str,
    font_path: &'static str,
    size: u16,
    color: Color,
) -> Option<Sprite> {
    if let Some(font) = self.cached_fonts.get(&(font_path, size)) {
        return font
            .render(text)
            .blended(color)
            .ok()
            .and_then(|surface| {
                self.canvas.create_texture_from_surface(&surface).ok()
            })
            .map(Sprite::new);
    }
    self.ttf_context
        .load_font(Path::new(font_path), size)
        .ok()
        .and_then(|font| {
            self.cached_fonts.insert((font_path, size), font);
            self.ttf_str_sprite(text, font_path, size, color)
        })
}

现在这样就很棒了, 下一趟加载字体, 会从 HashMap 里取出来. 接着渲染一下选项. 还要计算让文本居中, 这里得自己手动计算, 突然想起写前端样式
display: flex; justify-content: center; align-items: center;
居中三连. 还要处理选中状态, 所以 Menu 结构加一个选中状态值, 同时还要处理上下选择键, 定义空格键调用函数.

pub struct Menu {
    actions: Vec<Action>,
    selected: i8,
}

impl Menu {
    pub fn new(context: &mut Phi) -> Menu {
        use crate::views::game::ShipView;
        Self {
            actions: vec![
                Action::new(
                    context,
                    "New Game",
                    Box::new(|phi| {
                        ViewAction::ChangeView(Box::new(
                            ShipView::new(phi),
                        ))
                    }),
                ),
                Action::new(context, "Quit", Box::new(|_| ViewAction::Quit)),
            ]
        }
    }
}

impl View for Menu {
    fn render(&mut self, context: &mut Phi, elapsed: f64) -> ViewAction {
        let events = &context.events;
        if events.now.quit || events.now.key_escape == Some(true) {
            return ViewAction::Quit;
        }

        if events.now.key_up == Some(true) {
            self.selected -= 1;
            if self.selected < 0 {
                self.selected = self.actions.len() as i8 - 1;
            }
        }
        
        if events.now.key_space == Some(true) {
            return (self.actions[self.selected as usize].func)(context);
        }

        if events.now.key_down == Some(true) {
            self.selected += 1;
            if self.selected >= self.actions.len() as i8 {
                self.selected = 0;
            }
        }

        let (win_w, win_h) = context.output_size();
        let canvas = &mut context.canvas;

        canvas.set_draw_color(Color::RGB(0, 0, 0));
        canvas.clear();

        let label_h = 50.0;
        let border_width: f64 = 3.0;
        let box_w = 360.0;
        let box_h = self.actions.len() as f64 * label_h;
        let margin_h = 10.0;

        self.actions.iter().enumerate().for_each(|(i, action)| {
            if self.selected as usize == i {
                let (w, h) = action.hover_sprite.size();
                canvas.copy_sprite(
                    &action.hover_sprite,
                    Rectangle {
                        x: (win_w - w) / 2.0,
                        y: (win_h - box_h + label_h - h) / 2.0
                            + label_h * i as f64,
                        w,
                        h,
                    },
                );
            } else {
                let (w, h) = action.idle_sprite.size();
                canvas.copy_sprite(
                    &action.idle_sprite,
                    Rectangle {
                        x: (win_w - w) / 2.0,
                        y: (win_h - box_h + label_h - h) / 2.0
                            + label_h * i as f64,
                        w,
                        h,
                    },
                );
            }
        });
        ViewAction::None
    }
}

给菜单页加背景, 这个比较简单, 使用上一节的 Background 结构, 同时 Menu 结构添加这些属性

pub struct Menu {
    ...
    bg_back: Background,
    bg_middle: Background,
    bg_front: Background,
}

自然地, Menu 的实现也要改一下

impl Menu {
    pub fn new(context: &mut Phi) -> Self {
        use crate::views::game::ShipView;
        Self {
            ...
            selected: 0,
            bg_front: Background {
                pos: 0.0,
                vel: 80.0,
                sprite: Sprite::load(&mut context.canvas, "assets/star_fg.png")
                    .unwrap(),
            },
            bg_middle: Background {
                pos: 0.0,
                vel: 40.0,
                sprite: Sprite::load(&mut context.canvas, "assets/star_mg.png")
                    .unwrap(),
            },
            bg_back: Background {
                pos: 0.0,
                vel: 20.0,
                sprite: Sprite::load(&mut context.canvas, "assets/star_bg.png")
                    .unwrap(),
            },
        }
    }
}

接下来直接在 Menu 的 trait View render 函数里渲染背景

impl View for Menu {
    fn render(&mut self, context: &mut Phi, elapsed: f64) -> ViewAction {
        ...
        canvas.clear();
        self.bg_back.render(canvas, elapsed);
        self.bg_middle.render(canvas, elapsed);
        self.bg_front.render(canvas, elapsed);
        
        let label_h = 50.0;
        ...
    }
}