0x07. 处理精灵

411 阅读6分钟

通过之前的代码, 已经搞出了个根据方向键移动的矩形, 我们要是把一个飞船纹理加载到矩形上,就是个可移动的飞船了, 纹理加载如果自己实现,非常繁琐,幸好 SDL 帮我们处理了细节,只要做一个调参侠,我也可以快速加载纹理。

精灵

不过标题说是处理精力,个么现在先讲一下精灵(sprite)是什么玩意,如果从事过游戏开发,应该能理解精灵是什么玩意,但是废话还是要讲的。假如你要模拟一条火龙喷火的效果,要是做个 3D 游戏,为了效果逼真我们当然可以写个粒子动画模拟火焰,不过 2D 游戏可以不需要这么复杂,一条龙用一张图片显示,这张图片分几个区域,上面有闭嘴的状态图片,张嘴喷火的状态图片,然后显示不同区域,就可以表现龙喷火的效果。

这里放一下图片素材,好理解上面的意思。

飞船精灵

这张图包含九个小图,中间的是飞船普通状态,要是键盘点击上键,就让之前的矩形显示第一排第二个图,如果点击左就显示第二排第一个图,其他操作类似理解就好。

Cargo.toml 配置改一下,因为马上要用到 image 相关到东西。

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

加载纹理

但是饭要一口一口吃,先把加载纹理的事熟悉一下。现在要搞个飞船,先把名字改一下,RectView 不符合语义化,建议击毙,改成 ShipView,这是个飞船视图,里面的内容稍微改一下,本质上其实没区别,只是名字变了。

use sdl2::render::{Texture, TextureQuery};
struct Ship {
    rect: Rectangle,
    tex: Texture,
}

pub struct ShipView {
    player: Ship,
}

接下来把 impl RectView 改一下,自然是改成 impl ShipView

impl ShipView {
    pub fn new(phi: &mut Phi) -> Self {
        let tex = phi.canvas
        .load_texture(Path::new("assets/spaceship.png"))
        .unwrap();
        let TextureQuery { width, height, .. } = texture.query();
        Self {
            player: Ship {
                // width: u32, height: u32
                rect: Rectangle {
                    x: 64.0,
                    y: 64.0,
                    w: width as f64,
                    h: height as f64,
                },
                tex
            },
        }
    }
}

解构语法看着真爽,回到 ShipViewrender 函数,把渲染操作修改一下

impl View for ShipView {
    fn render(&mut self, context: &mut Phi) -> ViewAction {
        ...
        phi.canvas.copy(
        &mut self.player.tex,
        Rectangle {
            x: 0.0, y: 0.0,
            w: self.player.rect.w,
            h: self.player.rect.h,
        }.to_sdl(),
        self.player.rect.to_sdl());
    }
}

现在就很厉害了,把整张图都渲染出来了,接下来把九个小图,只显示其中一个,就拿中间那个开刀。
因为整图大小是 129 * 117,刚好九个,所以每个小图大小是 43 * 39,既然小图大小确定那就定义常量吧,反正这个东西固定了。

const SHIP_W = 43.0;
const SHIP_H = 39.0;

// ...

impl ShipView {
    pub fn new(phi: &mut Phi) -> Self {
        let tex = phi.canvas
        .load_texture(Path::new("assets/spaceship.png"))
        .unwrap();
        Self {
            player: Ship {
                rect: Rectangle {
                    x: 64.0,
                    y: 64.0,
                    w: SHIP_W,
                    h: SHIP_H,
                },
                tex,
            },
        }
    }
}

// ...

phi.canvas.copy(
    &mut self.player.tex,
    Rectangle {
        x: SHIP_W, y: SHIP_H,
        w: self.player.rect.w,
        h: self.player.rect.h,
    }.to_sdl(),
    self.player.rect.to_sdl());

这样就显示中间那个格子的小图了,显示效果应该还不错,就是丑了点,黄黄的背景还没处理,而且,要怎么实现上面说到的精灵图呢?

步入正题

每次执行 copy 图片某区域操作,就能达到显示该区域的效果,其实我们可以一次性 copy 所有的区域,然后根据键盘事件切换到对应区域就解决问题了。

struct Ship {
    rect: Rectangle,
    sprites: Vec<Sprite>,
    current: ShipFrame,
}

#[derive(Clone)]
pub struct Sprite {
    tex: Rc<RefCell<Texture>>,
    src: Rectangle,
}

impl Sprite {
    pub fn new(texture: Texture) -> Self {
        let TextureQuery { width, height, .. } = texture.query();
        Self {
            tex: Rc::new(RefCell::new(texture)),
            src: Rectangle {
                w: width as f64,
                h: height as f64,
                x: 0.0,
                y: 0.0,
            },
        }
    }
    pub fn load(canvas: &Renderer, path: &str) -> Option<Sprite> {
        canvas.load_texture(Path::new(path)).ok().map(Sprite::new)
    }
}

Sprite 结构体有个奇怪的东西,Rc<RefCell<Texture>>。这里用到了 Rc 这个智能指针,那就解释一下这玩意。Rc 其实全称就是引用计数器,如果有过 iOS 开发经验很好理解,Objective-C 的内存是通过引用计数来管理的,Rc 道理上类似。通常情况下,一个变量可以明确地知晓自己有某个值,而如果一个值存在多个所有者,就是 Rc 派上用场的时候了。某个值有一个所有者,计数器就是 1,如果有两个,计数器就是 2,如果有 0 个,值就可以被清理。现在的情况是 Ship 结构体包含多个精灵实例,但是我们只用一张图片,所以用上 Rc 来引用同一个资源。同时,因为 phi.canvas.copy 函数参数需要 self 可变,虽然我们不会改变图片,但是需要绕过借用检查器,这时候 RefCell 派上用场。现在把小图区域布局到 ShipView

impl ShipView {
    pub fn new(phi: &mut Phi) -> Self {
        let sprite_sheet = Sprite::load(&phi.canvas, "assets/spaceship.png")
        .unwrap();
        let mut sprites = Vec::with_capacity(9);
        for y in 0..3 {
            for x in 0..3 {
                sprites.push(
                    sprite_sheet
                        .region(Rectangle {
                            w: SHIP_W,
                            h: SHIP_H,
                            x: SHIP_W * x as f64,
                            y: SHIP_H * y as f64,
                        })
                        .unwrap(),
                )
            }
        }
        Self {
            player: Ship {
                rect: Rectangle {
                    x: 64.0,
                    y: 64.0,
                    w: 32.0,
                    h: 32.0,
                },
                sprites,
                current: ShipFrame::MidNorm,
            },
        }
    }
}

之后控制键盘事件处理,所以 render 函数也要改一下。

#[derive(Clone, Copy)]
enum ShipFrame {
    UpNorm = 0,
    UpFast = 1,
    UpSlow = 2,
    MidNorm = 3,
    MidFast = 4,
    MidSlow = 5,
    DownNorm = 6,
    DownFast = 7,
    DownSlow = 8,
}

impl View for ShipView {
    fn render(&mut self, context: &mut Phi) -> ViewAction {
        let (w, h) = context.output_size();
        let canvas = &mut context.canvas;
        let events = &mut context.events;

        if events.now.quit || events.now.key_escape == Some(true) {
            return ViewAction::Quit;
        }

        let diagonal: bool =
            (events.key_up ^ events.key_down) &&
            (events.key_left ^ events.key_right);
        let moved = if diagonal { 1.0 / 2.0f64.sqrt() } else { 1.0 } *
        PLAYER_SPEED;
        let dx: f64 = match (events.key_left, events.key_right) {
            (true, true) | (false, false) => 0.0,
            (true, false) => -moved,
            (false, true) => moved,
        };

        let dy: f64 = match (events.key_up, events.key_down) {
            (true, true) | (false, false) => 0.0,
            (true, false) => -moved,
            (false, true) => moved,
        };

        self.player.rect.x += dx;
        self.player.rect.y += dy;

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

        canvas.set_draw_color(Color::RGB(200, 200, 50));

        let movable_region: Rectangle = Rectangle::new(0.0, 0.0, w * 0.7, h);
        self.player.rect = self.player.rect
            .move_inside(movable_region)
            .unwrap();

        self.player.current =
            if dx == 0.0 && dy < 0.0 { ShipFrame::UpNorm }
            else if dx > 0.0 && dy < 0.0 { ShipFrame::UpFast }
            else if dx < 0.0 && dy < 0.0 { ShipFrame::UpSlow }
            else if dx == 0.0 && dy == 0.0 { ShipFrame::MidNorm }
            else if dx > 0.0 && dy == 0.0 { ShipFrame::MidFast }
            else if dx < 0.0 && dy == 0.0 { ShipFrame::MidSlow }
            else if dx == 0.0 && dy > 0.0 { ShipFrame::DownNorm }
            else if dx > 0.0 && dy > 0.0 { ShipFrame::DownFast }
            else if dx < 0.0 && dy > 0.0 { ShipFrame::DownSlow }
            else { unreachable!() };
        
        self.player.sprites[self.player.current as usize]
            .render(canvas, self.player.rect);

        ViewAction::None
    }
}

impl Rectangle {
    pub fn move_inside(self, parent: Rectangle) -> Option<Rectangle> {
        if self.w > parent.w || self.h > parent.h {
            return None;
        }
        Some(Rectangle {
            w: self.w,
            h: self.h,
            x: if self.x < parent.x {
                parent.x
            } else if self.x + self.w >= parent.x + parent.w {
                parent.x + parent.w - self.w
            } else {
                self.x
            },
            y: if self.y < parent.y {
                parent.y
            } else if self.y + self.h >= parent.y + parent.h {
                parent.y + parent.h - self.h
            } else {
                self.y
            },
        })
    }

    pub fn contains(&self, rect: Rectangle) -> bool {
        let x_min = rect.x;
        let x_max = x_min + rect.w;
        let y_min = rect.y;
        let y_max = y_min + rect.h;

        x_min >= self.x && x_min <= self.x + self.w && x_max >= self.x &&
            x_max <= self.x + self.w && y_min >= self.y
            && y_min <= self.y + self.h &&
            y_max >= self.y && y_max <= self.y + self.h
    }
}

impl Sprite {
    pub fn region(&self, rect: Rectangle) -> Option<Sprite> {
        let src: Rectangle = Rectangle {
            x: rect.x + self.src.x,
            y: rect.y + self.src.y,
            ..rect
        };
        if self.src.contains(src) {
            Some(Sprite {
                tex: self.tex.clone(),
                src,
            })
        } else {
            None
        }
    }

    pub fn render(&self, canvas: &mut Renderer, dest: Rectangle) {
        canvas.copy(&mut self.tex.borrow_mut(),
            self.src.to_sdl(), dest.to_sdl())
            .expect("failed to copy texture");
    }
}

现在执行之后,可以看到一个飞船然后根据键盘方向键就可以看到不同的图案。其实到这里已经完成了,其实还可以把代码写得更符合直觉一些,就是让 canvas 来渲染精灵,而不是精灵渲染其本身。

self.player.sprites[self.player.current as usize] .render(canvas, self.player.rect);

pub trait CopySprite {
    fn copy_sprite(&mut self, sprite: &Sprite, dest: Rectangle);
}

impl<'window> CopySprite for Renderer<'window> {
    fn copy_sprite(&mut self, sprite: &Sprite, dest: Rectangle) {
        sprite.render(self, dest);
    }
}

impl View for ShipView {
    fn render(&mut self, context: &mut Phi) -> ViewAction {
        // ...
        
        canvas.copy_sprite(
            &self.player.sprites[self.player.current as usize],
            self.player.rect);
        
        // ...
    }
}

利用 trait 来抽象行为。


目前就这样吧,接下来看看还有什么要处理到。