タイルベース描画とアニメーションの実装

ぼっちgamedev Advent Calendar 2018の11日目。
・タイルの描画
・タイルベースアニメーション
について書く。

タイルの描画

4日目に書いた通り、ゲーム画面が複数のタイルの組み合わせで表現される場合がある。以下に関連するコードを引用する。

struct Renderable<T> {
    position: Point,
    scale: f32,
    rotate: f32,
    opacity: f32,
    prop: T,
}

trait Drawing {
    fn draw(&self, ctx: &RenderContext) {
        unimplemented!();
    }
}

trait Rendering {
    fn render(&self, ctx: &RenderContext) {
        unimplemented!();
    }
}

impl<T: Drawing> Rendering for Renderable<T> {
    fn render(&self, ctx: &RenderContext) {
        ctx.set_coordinate(self.position);
        ctx.set_transform(self.scale, self.rotate);
        ctx.set_opacity(self.opacity);
        self.prop.draw(ctx);
    }
}

struct TileProperty<'a> {
    tiles: &'a TileManager,
    tile_id: usize,
}

type Tile<'a> = Renderable<TileProperty<'a>>

impl<'a> Drawing for TileProperty<'a> {/* 略 */}

TileManagerとタイル描画の実装を省略していたため、ここから実装していくことにする。まずタイル描画の仕様を確認し、それに基づいてTileManagerを実装していく。TileManagerには、タイルマップの指定IDのタイルをRenderContextの描画基準位置に描画するメソッドを想定し、以下のようにタイル描画を行う。

impl<'a> Drawing for TileProperty<'a> {
    fn draw(&self, ctx: &mut RenderContext) {
        self.tiles.copy(ctx, self.tile_id);
    }
}

よって、TileManagerの実装は以下のようになる。

struct TileManager<'a> {
    tilemap: &'a Image<'a>,
    tile_width: i32,
    tile_height: i32,
}

impl<'a> TileManager<'a> {
    fn copy(&self, ctx: &mut RenderContext, tile_id: usize) {
        let ix = self.tilemap.width / self.tile_width;
        let dx: i32 = (tile_id as i32) % ix;
        let dy: i32 = ((tile_id as i32)-dx) / ix;
        let x = dx * self.tile_width;
        let y = dy * self.tile_height;
        ctx.draw_image(self.tilemap, x, y, self.tile_width, self.tile_height);
    }
}

描画の仕組みはRenderContextの実装に依存することになるため、ここまででひとまずコンパイルが通ることを確認したい場合は、以下のようにImageとRenderContextを実装する。Imageのdataフィールドの型をStringのエイリアスにしているが、あくまでコンパイルを通すためであるため、正しい実装では無い。

#[derive(Debug, Copy, Clone)]
struct Point {
    x: i32,
    y: i32,
}

type RawImageData = String;

struct Image<'a> {
    path: String,
    data: &'a RawImageData,
    width: i32,
    height: i32,
}

struct Renderable<T> {
    position: Point,
    scale: f32,
    rotate: f32,
    opacity: f32,
    prop: T,
}

struct RenderContext {
    coordinate: Point,
    opacity: f32,
    scale: f32,
    rotate: f32,
}

impl RenderContext {
    fn set_coordinate(&mut self, pos: Point) {
        self.coordinate = pos;
    }
    fn set_transform(&mut self, scale: f32, rotate: f32) {
        self.scale = scale;
        self.rotate = rotate;
    }
    fn set_opacity(&mut self, opacity: f32) {
        self.opacity = opacity;
    }
    fn draw_image(&mut self, image: &Image, dx: i32, dy: i32, dw: i32, dh: i32) {
        println!("Copy {} from {:?} to {:?}, to {:?}", image.path, Point{x: dx, y: dy}, Point{x: dx+dw, y: dy+dh}, self.coordinate);
    }
}

trait Drawing {
    fn draw(&self, ctx: &mut RenderContext) {
        unimplemented!();
    }
}

trait Rendering {
    fn render(&self, ctx: &mut RenderContext) {
        unimplemented!();
    }
}

impl<T: Drawing> Rendering for Renderable<T> {
    fn render(&self, ctx: &mut RenderContext) {
        ctx.set_coordinate(self.position);
        ctx.set_transform(self.scale, self.rotate);
        ctx.set_opacity(self.opacity);
        self.prop.draw(ctx);
    }
}

struct TileProperty<'a> {
    tiles: &'a TileManager<'a>,
    tile_id: usize,
}

type Tile<'a> = Renderable<TileProperty<'a>>;

impl<'a> Drawing for TileProperty<'a> {
    fn draw(&self, ctx: &mut RenderContext) {
        self.tiles.copy(ctx, self.tile_id);
    }
}

struct TileManager<'a> {
    tilemap: &'a Image<'a>,
    tile_width: i32,
    tile_height: i32,
}

impl<'a> TileManager<'a> {
    fn copy(&self, ctx: &mut RenderContext, tile_id: usize) {
        let ix = self.tilemap.width / self.tile_width;
        let iy = self.tilemap.height / self.tile_height;
        let dx: i32 = (tile_id as i32) % ix;
        let dy: i32 = ((tile_id as i32)-dx) / ix;
        ctx.draw_image(self.tilemap, dx*self.tile_width, dy*self.tile_height as i32, self.tile_width, self.tile_height);
    }
}

fn main() {
    let data = String::from("bar");
    let image = Image {
        path: String::from("images/foo.png"),
        data: &data,
        width: 128,
        height: 128,
    };
    let tile_manager = TileManager {
        tilemap: &image,
        tile_width: 32,
        tile_height: 32,
    };
    let mut ctx = RenderContext {
        coordinate: Point {x: 0, y: 0},
        opacity: 1.,
        scale: 1.,
        rotate: 0.,
    };
    let tile = Tile {
        position: Point {x: 32, y: 0},
        scale: 1.,
        rotate: 0.,
        opacity: 1.,
        prop: TileProperty {
            tiles: &tile_manager,
            tile_id: 3,
        },
    };
    tile.render(&mut ctx);
}

// Copy images/foo.png from Point { x: 96, y: 0 } to Point { x: 128, y: 32 }, to Point { x: 32, y: 0 }

タイルベースアニメーション

コンピュータゲームでは、パラパラ漫画のようにタイルを切り替えることでアニメーションを表現する。ここまででタイル描画を実装したため、アニメーションの実装は難しく無い。

TileProperty構造体にはtile_idフィールドを持たせたが、これを切り替えることでコピー対象のタイルも切り替わる。これを念頭に、アニメーションに必要な要素を考えてみる。

まず、アニメーションを構成する全てのコマ(タイル)の参照が必要となる。これはtile_idを集めた配列として表現すれば良い。そして各コマ毎の表示時間が必要だ。一定間隔で表示しても良いが、アニメーションの自由度が低下するため避けたい。これはコマ(タイル)と紐付いた情報となるため、tile_idと時間を持つ構造体を作ることにする。

struct AnimationFrame {
    tile_id: usize,
    time: u32,
}

type Animation = Vec<AnimationFrame>;

これをもとにアニメーションのコマを管理するAnimationManagerを実装する。AnimationManagerのupdateでは、前回の更新からの経過時間(ms)を計算し、該当タイルのtile_idを現在のフレーム番号のtile_idに変更する。すなわちAnimationManagerにアニメーション対象のタイルの変更可能な参照を渡す必要がある。以下に単純な実装を示す。

use std::time::{SystemTime, Duration};

struct AnimationState<'a, 'b> {
    target: &'a mut TileProperty<'a>,
    frame_number: usize,
    elapsed: i32,
    animation: &'b Animation,
    is_loop: bool,
    finished: bool,
}

struct AnimationManager<'a, 'b> {
    states: Vec<AnimationState<'a, 'b>>,
    last_updated: SystemTime,
}

fn elapsed_millis(st: &mut SystemTime) -> u64 {
    let d = st.elapsed().unwrap();
    let millis = d.as_secs() * 1000 + d.subsec_millis() as u64;
    *st = SystemTime::now();
    millis
}

impl<'a, 'b> AnimationManager<'a, 'b> {
    fn push(&mut self, target: &'a mut TileProperty<'a>, animation: &'b Animation, is_loop: bool) {
        let state = AnimationState {
            target: target,
            frame_number: 0,
            elapsed: 0,
            animation: animation,
            is_loop: is_loop,
            finished: false,
        };
        self.states.push(state);
    }
    fn update(&mut self) {
        let delta = elapsed_millis(&mut self.last_updated) as i32;
        for state in self.states.iter_mut() {
            state.elapsed += delta;
            let mut fnum = state.frame_number;
            let mut head = state.animation[fnum].time as i32 - state.elapsed;
            while head <= 0 && !state.finished {
                state.elapsed = -head;
                state.frame_number += 1;
                if state.frame_number >= state.animation.len() {
                    if state.is_loop {
                        state.frame_number = 0;
                    } else {
                        state.finished = true;
                    }
                }
                fnum = state.frame_number;
                state.target.tile_id = state.animation[fnum].tile_id;
                head = state.animation[fnum].time as i32 - state.elapsed;
            }
        }
        self.states.retain(|state| !state.finished);
    }
}

コンパイルも通り、この実装でも一見問題無いように思える。が、AnimationManagerにアニメーション対象としてタイルを登録した後にrender関数を呼ぼうとするとコンパイル不可能となる。これはAnimationManagerにタイルのmutableな参照を渡した後に、タイルのimmutableな参照を要するrender関数を呼び出そうとするためである。これはRustにおける以下のルールに抵触する。

最初に、借用は全て所有者のスコープより長く存続してはなりません。 次に、次の2種類の借用のどちらか1つを持つことはありますが、両方を同時に持つことはありません。
・リソースに対する1つ以上の参照( &T )
・ただ1つのミュータブルな参照( &mut T )
The Rust Programming Language, First Edition - 参照と借用 より

これを回避するため、Renderableの定義を少し変更する必要がある。AnimationManagerの例のように、Renderableのpropは外部から変更を加えられる可能性を考慮しなければならない。参照先の値を変更可能なポインタを複数存在させるにはRc<RefCell<T>>というパターンを使う。

use std::cell::RefCell;
use std::rc::Rc;

struct Renderable<T> {
    position: Point,
    scale: f32,
    rotate: f32,
    opacity: f32,
    prop: Rc<RefCell<T>>,
}

impl<'a, T: Drawing> Rendering for Renderable<T> {
    fn render(&self, ctx: &mut RenderContext) {
        ctx.set_coordinate(self.position);
        ctx.set_transform(self.scale, self.rotate);
        ctx.set_opacity(self.opacity);
        self.prop.borrow().draw(ctx);
    }
}

これに付随し、AnimationStateのtargetもRc<RefCell<T>>となるように変更する。

struct AnimationState<'a, 'b> {
    target: Rc<RefCell<TileProperty<'a>>>,
    frame_number: usize,
    elapsed: i32,
    animation: &'b Animation,
    is_loop: bool,
    finished: bool,
}

impl<'a, 'b> AnimationManager<'a, 'b> {
    fn push(&mut self, target: Rc<RefCell<TileProperty<'a>>>, animation: &'b Animation, is_loop: bool) {
        let state = AnimationState {
            target: target,
            frame_number: 0,
            elapsed: 0,
            animation: animation,
            is_loop: is_loop,
            finished: false,
        };
        self.states.push(state);
    }
    fn update(&mut self) {
        let delta = elapsed_millis(&mut self.last_updated);
        for state in self.states.iter_mut() {
            state.elapsed += delta as i32;
            let mut fnum = state.frame_number;
            let mut head = state.animation[fnum].time as i32 - state.elapsed;
            while head <= 0 && !state.finished {
                state.elapsed = -head;
                state.frame_number += 1;
                if state.frame_number >= state.animation.len() {
                    if state.is_loop {
                        state.frame_number = 0;
                    } else {
                        state.finished = true;
                    }
                }
                fnum = state.frame_number;
                let mut target = (*state.target).borrow_mut();
                target.tile_id = state.animation[fnum].tile_id;
                head = state.animation[fnum].time as i32 - state.elapsed;
            }
        }
        self.states.retain(|state| !state.finished);
    }
}

これで問題なくアニメーションに従ってtile_idを更新することが可能となる。例えば以下のようなテストコードが動作する。

fn main() {
    let data = String::from("bar");
    let image = Image {
        path: String::from("images/foo.png"),
        data: &data,
        width: 128,
        height: 128,
    };
    let tile_manager = TileManager {
        tilemap: &image,
        tile_width: 32,
        tile_height: 32,
    };
    let mut ctx = RenderContext {
        coordinate: Point {x: 0, y: 0},
        opacity: 1.,
        scale: 1.,
        rotate: 0.,
    };
    let prop = TileProperty {
            tiles: &tile_manager,
            tile_id: 0,
    };
    let rcrefprop1 = Rc::new(RefCell::new(prop));
    let rcrefprop2 = rcrefprop1.clone();
    let tile = Tile {
        position: Point {x: 32, y: 0},
        scale: 1.,
        rotate: 0.,
        opacity: 1.,
        prop: rcrefprop1,
    };
    let anime = vec![
        AnimationFrame {
            tile_id: 0,
            time: 10,
        },
        AnimationFrame {
            tile_id: 1,
            time: 20,
        }
    ];
    let mut am = AnimationManager {
        states: Vec::new(),
        last_updated: SystemTime::now(),
    };
    am.push(rcrefprop2, &anime, true);
    for _i in 0..10 {
        sleep(Duration::new(0, 5_000_000));
        am.update();
        tile.render(&mut ctx);
    }
}

/*
Copy images/foo.png from Point { x: 0, y: 0 } to Point { x: 32, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 0, y: 0 } to Point { x: 32, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 0, y: 0 } to Point { x: 32, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
Copy images/foo.png from Point { x: 32, y: 0 } to Point { x: 64, y: 32 }, to Point { x: 32, y: 0 }
*/

まとめ

・タイルを用いたアニメーションはパラパラ漫画と同じ仕組み
・構造体のフィールドの管理を外部に委託するにはRc<RefCell<T>>を使う

明日はメニュー画面のUI/UXについて書きたい。

この記事が気に入ったらサポートをしてみませんか?