🎮

SDL2でRPGツクールっぽい挙動を再現する

2024/09/23に公開

「ゆめにっき」や「Ib」をプレイして育った世代ということもあり、RPGツクール2000あたりの質感には郷愁のようなものを覚えます。そういうわけで、RPGツクールっぽい挙動をプログラム上で再現してみました。

動作例

GIFアニメなのでカクカクですがこんな感じで動きます。

開発環境

Windows上で開発したかったので Visual Studio + vcpkg を使いました。よくあるWindows向けチュートリアルだと、SDLのライブラリ本体をダウンロードしてVisual Studio上でプロジェクトのパス設定を頑張ることになりますが、vcpkg を使えばその手間が減ります。

vcpkg 自体のセットアップは vckpg のインストール(Windows 上) を参考にするのがよさそうです。以下のコマンドでSDLのパッケージをインストールします。

vcpkg install sdl2 sdl2-image sdl2-ttf sdl2-mixer

ちゃんと vcpkg が導入できていれば、特に設定をしなくても Visual Studio のプロジェクトからSDL2が使えるようになっているはずです。
動作確認として以下のような main.cpp を作成します。ビルドが通って、黒いウィンドウが出ればOKです。

// SPDX-License-Identifier: Unlicense
#include <SDL2/SDL.h>

#ifdef main
#undef main
#endif
int main(int argc, char* argv[])
{
    SDL_Init(SDL_INIT_VIDEO);
    SDL_Window* window = NULL;
    SDL_Renderer* render = NULL;
    SDL_CreateWindowAndRenderer(640, 480, SDL_WINDOW_HIDDEN, &window, &render);
    SDL_SetWindowTitle(window, "");
    SDL_RenderSetVSync(render, SDL_TRUE);
    SDL_ShowWindow(window);

    SDL_Event ev;
    bool shown = true;
    while (shown) {
        while (SDL_PollEvent(&ev)) {
            switch (ev.type) {
            case SDL_QUIT:
                shown = false;
                break;
            }
        }
        SDL_SetRenderDrawColor(render, 0, 0, 0, 255);
        SDL_RenderClear(render);
        SDL_RenderPresent(render);
    }

    SDL_DestroyRenderer(render);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

#if _WIN64 || _WIN32
#include <windows.h>
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nShowCmd)
{
    return main(0, NULL);
}
#endif

ソースコード

ここではチップ画像素材として、REFMAP研究部さんの 「キャラクター」「町01」 をお借りしました。

キャラクター素材は、「キャラクター02」に含まれている chara05_y6.png を使いました。その他のツクール2000素材でも動作すると思います。

マップ素材は town01_a.png をタイリングした 25×20 (400×320px) の画像 map.png をまるごと読み込んでいます。おそらく適当な画像でよいと思います。マップチップのタイリング仕様まで完コピしようとするとなかなか大変なので、今回はそこまではやっていません。

コードは長めですが以下です。chara05_y6.pngmap.png を用意したうえで、SDL と SDL-image が入っていれば動くはずです。

// SPDX-License-Identifier: Unlicense
#include <SDL2/SDL.h>
#include <SDL2/SDL_image.h>
#include <stdio.h>
#include <algorithm>

#define SCREEN_W 320
#define SCREEN_H 240
#define CHIPSIZE 16
#define SCREEN_CENTER_X 9
#define SCREEN_CENTER_Y 7
#define CHARACTER_W 24
#define CHARACTER_H 32
#define CHARACTER_DIRECTION_U 0
#define CHARACTER_DIRECTION_R 1
#define CHARACTER_DIRECTION_D 2
#define CHARACTER_DIRECTION_L 3
const int chara_move_frames[6] = { 2, 4, 8, 16, 32, 64 };     // 移動コマ数
const int chara_anim_frames[6] = { 24, 24, 32, 48, 64, 64 };  // アニメコマ数

// キャラ移動入力
class direction_input_stack
{
private:
    int _count;
    int _directions[4];

public:
    direction_input_stack() : _count(0), _directions() {}

    void key_down(int dir)
    {
        if (dir < 0 || dir >= 4) { return; }
        _count++;
        _directions[dir] = _count;
    }

    void key_up(int dir)
    {
        if (dir < 0 || dir >= 4) { return; }
        _directions[dir] = 0;
        if (!is_any_key_pressed()) {
            _count = 0;
        }
    }

    int current() const
    {
        // 上下・左右の向きを取得する
        // 上下同時押し・左右同時押しのときは何も押していない (= 0) とみなす
        int vert = (_directions[CHARACTER_DIRECTION_D] != 0) - (_directions[CHARACTER_DIRECTION_U] != 0);
        int hori = (_directions[CHARACTER_DIRECTION_L] != 0) - (_directions[CHARACTER_DIRECTION_R] != 0);

        // なにも押されていないなら -1 (無方向)
        if (!vert && !hori) { return -1; }

        // 上下または左右のどちらかが押されている場合
        // 上下と左右のどちらが後に押されたかを判定する
        int vert_bigger_count = std::max(_directions[CHARACTER_DIRECTION_D], _directions[CHARACTER_DIRECTION_U]);
        int hori_bigger_count = std::max(_directions[CHARACTER_DIRECTION_L], _directions[CHARACTER_DIRECTION_R]);
        return (vert_bigger_count > hori_bigger_count) ? vert + 1 : hori + 2;
    }

    bool is_any_key_pressed() const
    {
        return _directions[0] + _directions[1] + _directions[2] + _directions[3];
    }

private:
    // コピー禁止
    direction_input_stack(const direction_input_stack&) = delete;
    direction_input_stack& operator=(const direction_input_stack&) = delete;
    // ムーブ禁止
    direction_input_stack(direction_input_stack&&) = delete;
    direction_input_stack& operator=(direction_input_stack&&) = delete;
};

void set_fullscreen_and_show(SDL_Window* window, bool is_fullscreen, SDL_Rect* fullscreen_rect)
{
    // フルスクリーン状態を設定
    SDL_HideWindow(window);
    SDL_SetWindowFullscreen(window, is_fullscreen ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0);
    SDL_ShowWindow(window);

    if (is_fullscreen) {
        int w = SCREEN_W, h = SCREEN_H;
        SDL_GetWindowSize(window, &w, &h);

        // スケーリングを設定
        int scale = std::min(w / SCREEN_W, h / SCREEN_H);
        fullscreen_rect->w = SCREEN_W * scale;
        fullscreen_rect->h = SCREEN_H * scale;
        fullscreen_rect->x = w / 2 - fullscreen_rect->w / 2;
        fullscreen_rect->y = h / 2 - fullscreen_rect->h / 2;
    }
}

// 0 から x_max までの範囲になるように x をループさせる
int wrap(int x, int x_max)
{
    if (x < 0) { return x_max * (x / x_max + 1) + x; }
    if (x >= x_max) { return x % x_max; }
    return x;
}

#ifdef main
#undef main
#endif
int main(int argc, char* argv[])
{
    bool is_fullscreen = false;
    int window_scale = 2;
    SDL_Rect window_rect = { 0 };
    window_rect.x = 2;
    window_rect.y = 2;
    window_rect.w = SCREEN_W * window_scale;
    window_rect.h = SCREEN_H * window_scale;
    SDL_Rect fullscreen_rect = { 0 };

    SDL_Init(SDL_INIT_VIDEO);
    SDL_Window* window = NULL;
    SDL_Renderer* render = NULL;
    SDL_CreateWindowAndRenderer(window_rect.w + 3, window_rect.h + 3, SDL_WINDOW_HIDDEN, &window, &render);
    SDL_ShowCursor(SDL_DISABLE);  // マウスポインタを隠す
    SDL_SetWindowTitle(window, "");
    SDL_RenderSetVSync(render, SDL_TRUE);

    set_fullscreen_and_show(window, is_fullscreen, &fullscreen_rect);

    // スクリーン描画用のテクスチャ
    // RPGツクール2000 で 16ビットカラー (RGB565) が使われている点も再現する
    SDL_Texture* screen_texture = SDL_CreateTexture(
        render, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_TARGET, SCREEN_W, SCREEN_H
    );

    // キャラ画像を読み込む
    SDL_Surface* chara_surface = IMG_Load("chara05_y6.png");
    if (!chara_surface) {
        SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "", "Unable to load character image.", window);
        SDL_DestroyRenderer(render);
        SDL_DestroyWindow(window);
        SDL_Quit();
        return 1;
    }
    SDL_SetColorKey(chara_surface, SDL_TRUE, 0);  // インデックス0をカラーキーとして透過させる
    SDL_Texture* chara_texture = SDL_CreateTextureFromSurface(render, chara_surface);

    // キャラの内部状態 (初期値)
    int chara_number = 0;   // キャラクターの通し番号 (0-5)
    int chara_direction = CHARACTER_DIRECTION_D;  // 現在の向き
    int chara_aniframe = 1;       // 現在のアニメフレーム (1=静止)
    int chara_aniframe_next = 2;  // 次のアニメフレームの値 (0=右足前, 2=左足前)
    int chara_offset_x = 0;  // ピクセル単位のオフセットX
    int chara_offset_y = 0;  // ピクセル単位のオフセットY
    int chara_curr_x = 9;    // マップタイル上の現在位置X
    int chara_curr_y = 7;    // マップタイル上の現在位置Y
    int chara_next_x = chara_curr_x;  // マップタイル上の目標位置X
    int chara_next_y = chara_curr_y;  // マップタイル上の目標位置Y
    int chara_speed = 3;  // 移動速度 (0-5)

    // マップ画像を読み込む
    // ここでは全部のチップが並べられた一枚絵を読み込む
    SDL_Surface* map_surface = IMG_Load("map.png");
    if (!map_surface) {
        SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "", "Unable to load map image.", window);
        SDL_DestroyRenderer(render);
        SDL_DestroyWindow(window);
        SDL_Quit();
        return 1;
    }
    SDL_SetColorKey(map_surface, SDL_TRUE, 0);  // インデックス0をカラーキーとして透過させる
    SDL_Texture* map_texture = SDL_CreateTextureFromSurface(render, map_surface);

    // マップ設定
    int map_w = 25;  // マップ横幅
    int map_h = 20;  // マップ縦幅
    bool is_loop_x = false;  // X方向にループするか?
    bool is_loop_y = false;  // Y方向にループするか?
    int map_offset_x = 0;    // マップ描画位置のオフセットX (ピクセル)
    int map_offset_y = 0;    // マップ描画位置のオフセットY (ピクセル)

    // 歩行の内部状態
    bool is_moving = true;     // 移動中?
    bool is_animating = true;  // アニメ中?
    double phase_move = 0.0;   // カウントアップして 1.0 に到達したら移動実行
    double phase_anim = 0.0;   // カウントアップして 1.0 に到達したらアニメ実行
    direction_input_stack keystack;

    int direction_prev = -1;
    double time_prev = static_cast<double>(SDL_GetPerformanceCounter());

    SDL_Event ev;
    bool shown = true;
    while (shown) {
        while (SDL_PollEvent(&ev)) {
            switch (ev.type) {
            case SDL_QUIT:  // ウィンドウが閉じられた
                shown = false;
                break;
            case SDL_KEYDOWN:  // キーが押された
                if (ev.key.repeat) break;
                switch (ev.key.keysym.sym) {
                case SDLK_w: case SDLK_k: case SDLK_KP_8: case SDLK_UP:
                    keystack.key_down(CHARACTER_DIRECTION_U);
                    break;
                case SDLK_d: case SDLK_l: case SDLK_KP_6: case SDLK_RIGHT:
                    keystack.key_down(CHARACTER_DIRECTION_R);
                    break;
                case SDLK_s: case SDLK_j: case SDLK_KP_2: case SDLK_DOWN:
                    keystack.key_down(CHARACTER_DIRECTION_D);
                    break;
                case SDLK_a: case SDLK_h: case SDLK_KP_4: case SDLK_LEFT:
                    keystack.key_down(CHARACTER_DIRECTION_L);
                    break;
                case SDLK_z: case SDLK_KP_ENTER: case SDLK_SPACE: case SDLK_RETURN:
                    printf("select\n");
                    break;
                case SDLK_x: case SDLK_c: case SDLK_v: case SDLK_b: case SDLK_n:
                case SDLK_KP_0: case SDLK_ESCAPE: case SDLK_INSERT:
                    printf("cancel\n");
                    break;
                case SDLK_F4:
                    is_fullscreen = !is_fullscreen;
                    set_fullscreen_and_show(window, is_fullscreen, &fullscreen_rect);
                    break;
                case SDLK_F5:
                    window_scale = ((window_scale - 1) + 1) % 4 + 1;
                    window_rect.w = SCREEN_W * window_scale;
                    window_rect.h = SCREEN_H * window_scale;
                    SDL_HideWindow(window);
                    SDL_SetWindowSize(window, window_rect.w + 3, window_rect.h + 3);
                    SDL_ShowWindow(window);
                    break;
                }
                break;
            case SDL_KEYUP:  // キーが離された
                switch (ev.key.keysym.sym) {
                case SDLK_w: case SDLK_k: case SDLK_KP_8: case SDLK_UP:
                    keystack.key_up(CHARACTER_DIRECTION_U);
                    break;
                case SDLK_d: case SDLK_l: case SDLK_KP_6: case SDLK_RIGHT:
                    keystack.key_up(CHARACTER_DIRECTION_R);
                    break;
                case SDLK_s: case SDLK_j: case SDLK_KP_2: case SDLK_DOWN:
                    keystack.key_up(CHARACTER_DIRECTION_D);
                    break;
                case SDLK_a: case SDLK_h: case SDLK_KP_4: case SDLK_LEFT:
                    keystack.key_up(CHARACTER_DIRECTION_L);
                    break;
                }
                break;
            }
        }

        int direction_curr = keystack.current();
        double time_curr = static_cast<double>(SDL_GetPerformanceCounter());
        double deltatime = (time_curr - time_prev) / SDL_GetPerformanceFrequency();

        // 最初の歩行フレーム?
        bool is_first_walk_frame = (!is_moving && direction_prev == -1 && direction_curr != -1);

        // 歩行移動処理
        if (is_moving || is_first_walk_frame) {
            phase_move += deltatime / (chara_move_frames[chara_speed] / 60.0);
            if (phase_move >= 1.0 || is_first_walk_frame) {
                phase_move = 0.0;

                // キャラの現在位置を更新
                chara_next_x = wrap(chara_next_x, map_w);
                chara_next_y = wrap(chara_next_y, map_h);
                chara_curr_x = chara_next_x;
                chara_curr_y = chara_next_y;

                // 新しい移動方向を決める
                switch (direction_curr) {
                case CHARACTER_DIRECTION_U: is_moving = true; chara_next_y--; break;
                case CHARACTER_DIRECTION_R: is_moving = true; chara_next_x++; break;
                case CHARACTER_DIRECTION_D: is_moving = true; chara_next_y++; break;
                case CHARACTER_DIRECTION_L: is_moving = true; chara_next_x--; break;
                default: is_moving = false; break;
                }
                // キャラの向きを更新
                if (is_moving) {
                    chara_direction = direction_curr;
                }
            }

            // 座標移動
            chara_offset_x = (int)round(((double)chara_next_x - chara_curr_x) * CHIPSIZE * phase_move);
            chara_offset_y = (int)round(((double)chara_next_y - chara_curr_y) * CHIPSIZE * phase_move);

            // マップ描画位置を移動
            map_offset_x = chara_offset_x + (chara_curr_x - SCREEN_CENTER_X) * CHIPSIZE;
            map_offset_y = chara_offset_y + (chara_curr_y - SCREEN_CENTER_Y) * CHIPSIZE;
        }

        // 歩行アニメーション処理
        if (is_animating || is_first_walk_frame) {
            phase_anim += deltatime / (chara_anim_frames[chara_speed] / 60.0) * 6.0;
            if (phase_anim >= 1.0 || is_first_walk_frame) {
                phase_anim = 0.0;

                // アニメーションを継続する?
                is_animating = is_moving;

                // 交互にアニメーション
                if (chara_aniframe != 1 || !is_animating) {
                    chara_aniframe = 1;
                }
                else {
                    chara_aniframe = chara_aniframe_next;
                    chara_aniframe_next = 2 - chara_aniframe_next;
                }
            }
        }

        // スクリーンテクスチャへ描画
        SDL_SetRenderTarget(render, screen_texture);
        SDL_SetRenderDrawColor(render, 0, 0, 0, 255);
        SDL_RenderClear(render);
        // 背景マップを描画
        SDL_Rect map_src_rect = { 0 };
        SDL_Rect map_dst_rect = { 0 };
        map_src_rect.w = map_src_rect.h = CHIPSIZE;
        map_dst_rect.w = map_dst_rect.h = CHIPSIZE;
        int map_x = map_offset_x / CHIPSIZE;
        int map_y = map_offset_y / CHIPSIZE;
        for (int x = map_x - 1; x < map_x + 1 + SCREEN_W / CHIPSIZE; x++) {
            map_src_rect.x = CHIPSIZE * wrap(x, map_w);
            map_dst_rect.x = CHIPSIZE * x - map_offset_x;
            for (int y = map_y - 1; y < map_y + 1 + SCREEN_H / CHIPSIZE; y++) {
                map_src_rect.y = CHIPSIZE * wrap(y, map_h);
                map_dst_rect.y = CHIPSIZE * y - map_offset_y;
                SDL_RenderCopy(render, map_texture, &map_src_rect, &map_dst_rect);
            }
        }
        // キャラクターの切り出し範囲を設定
        SDL_Rect trim_rect = { 0 };
        trim_rect.x = CHARACTER_W * (chara_aniframe + (chara_number % 4) * 3);
        trim_rect.y = CHARACTER_H * (chara_direction + (chara_number / 4) * 4);
        trim_rect.w = CHARACTER_W;
        trim_rect.h = CHARACTER_H;
        // キャラクターの画面上配置を設定
        SDL_Rect render_rect = { 0 };
        render_rect.x = chara_offset_x + chara_curr_x * CHIPSIZE - map_offset_x - (CHARACTER_W - CHIPSIZE) / 2;
        render_rect.y = chara_offset_y + chara_curr_y * CHIPSIZE - map_offset_y - (CHARACTER_H - CHIPSIZE);
        render_rect.w = CHARACTER_W;
        render_rect.h = CHARACTER_H;
        // キャラを描画
        SDL_RenderCopy(render, chara_texture, &trim_rect, &render_rect);

        // ウィンドウにスクリーンテクスチャの内容を拡大描画
        SDL_SetRenderTarget(render, NULL);
        SDL_SetRenderDrawColor(render, 0, 0, 0, 255);
        SDL_RenderClear(render);
        if (is_fullscreen) {
            // スクリーンを描画
            SDL_RenderCopy(render, screen_texture, NULL, &fullscreen_rect);
        }
        else {
            // 枠を描画
            SDL_SetRenderDrawColor(render, 160, 160, 160, 255);
            SDL_RenderDrawLine(render, 0, 0, window_rect.w + 2, 0);
            SDL_RenderDrawLine(render, 0, 0, 0, window_rect.h + 2);
            SDL_SetRenderDrawColor(render, 105, 105, 105, 255);
            SDL_RenderDrawLine(render, 1, 1, window_rect.w + 1, 1);
            SDL_RenderDrawLine(render, 1, 1, 1, window_rect.h + 1);
            SDL_SetRenderDrawColor(render, 223, 223, 223, 255);
            SDL_RenderDrawLine(render, window_rect.w + 2, 1, window_rect.w + 2, window_rect.h + 2);
            SDL_RenderDrawLine(render, 1, window_rect.h + 2, window_rect.w + 2, window_rect.h + 2);
            // スクリーンを描画
            SDL_RenderCopy(render, screen_texture, NULL, &window_rect);
        }
        SDL_RenderPresent(render);

        // 前フレームの情報を保存
        direction_prev = direction_curr;
        time_prev = time_curr;
    }

    SDL_DestroyTexture(chara_texture);
    SDL_DestroyTexture(map_texture);
    SDL_DestroyRenderer(render);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

#if _WIN64 || _WIN32
#include <windows.h>
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nShowCmd)
{
    return main(0, NULL);
}
#endif

ポイント

RPGツクール2000の仕様

  • ゲーム画面の解像度は 320×240 固定です。デフォルトだと2倍ズーム (640×480) で表示されます。
  • キャラチップは 288×256, マップチップは 480×256 です。キャラチップ1個は 24×32、マップの1マスは16×16です。 (参考: YADOT 素材管理(素材規格・ファイル形式))。
  • キャラの移動速度には6種類あり、それぞれ 2, 4, 8, 16, 32, 64 フレーム (60 FPS) ごとに1マス移動します (参考: 2000における移動頻度・速度の消費フレーム数一覧表)。
  • ゲーム画面はフルカラーではなく、16ビットカラー(RGB565) 形式で表示されています (参考: YADOT)。

キャラ素材

キャラ画像を読み込む際は SDL_SetColorKey で背景色を指定しています。インデックスカラーのチップ素材を使う場合はこれをしておかないとキャラ背景が透明になりません。

// キャラ画像を読み込む
SDL_Surface* chara_surface = IMG_Load("chara05_y6.png");
if (!chara_surface) {
    SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "", "Unable to load character image.", window);
    SDL_DestroyRenderer(render);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 1;
}
SDL_SetColorKey(chara_surface, SDL_TRUE, 0);  // インデックス0をカラーキーとして透過させる
SDL_Texture* chara_texture = SDL_CreateTextureFromSurface(render, chara_surface);


SDL_SetColorKeyを呼び出さなかった場合

移動キーの同時押し処理

RPGツクール2000は、移動キーを同時押ししたときの処理が作り込まれています。

  • キーを同時押ししてから離すと、最後に押されたキー入力に戻ります。たとえば「→」を押したまま「↑」を押せば、キャラは上へ移動します (後の入力が優先される)。ここで「↑」だけを離すと、右へ移動します (一つ前の入力に戻る)。
  • 逆向きのキーを同時押しすると移動が止まります。たとえば「←」を押したまま「→」を押すと停止します。

このあたりのロジックは direction_input_stack クラスとして分けています。
実装方法としては、上下左右の入力順(カウント)を記録して、その最大値を探すようなイメージです。これはもっとスマートな実装があるかもしれません・・・。

// キャラ移動入力
class direction_input_stack
{
private:
    int _count;
    int _directions[4];

public:
    direction_input_stack() : _count(0), _directions() {}

    void key_down(int dir)
    {
        if (dir < 0 || dir >= 4) { return; }
        _count++;
        _directions[dir] = _count;
    }

    void key_up(int dir)
    {
        if (dir < 0 || dir >= 4) { return; }
        _directions[dir] = 0;
        if (!is_any_key_pressed()) {
            _count = 0;
        }
    }

    int current() const
    {
        // 上下・左右の向きを取得する
        // 上下同時押し・左右同時押しのときは何も押していない (= 0) とみなす
        int vert = (_directions[CHARACTER_DIRECTION_D] != 0) - (_directions[CHARACTER_DIRECTION_U] != 0);
        int hori = (_directions[CHARACTER_DIRECTION_L] != 0) - (_directions[CHARACTER_DIRECTION_R] != 0);

        // なにも押されていないなら -1 (無方向)
        if (!vert && !hori) { return -1; }

        // 上下または左右のどちらかが押されている場合
        // 上下と左右のどちらが後に押されたかを判定する
        int vert_bigger_count = std::max(_directions[CHARACTER_DIRECTION_D], _directions[CHARACTER_DIRECTION_U]);
        int hori_bigger_count = std::max(_directions[CHARACTER_DIRECTION_L], _directions[CHARACTER_DIRECTION_R]);
        return (vert_bigger_count > hori_bigger_count) ? vert + 1 : hori + 2;
    }

    bool is_any_key_pressed() const
    {
        return _directions[0] + _directions[1] + _directions[2] + _directions[3];
    }

private:
    // コピー禁止
    direction_input_stack(const direction_input_stack&) = delete;
    direction_input_stack& operator=(const direction_input_stack&) = delete;
    // ムーブ禁止
    direction_input_stack(direction_input_stack&&) = delete;
    direction_input_stack& operator=(direction_input_stack&&) = delete;
};

歩行処理

歩行処理は「移動処理」と「アニメ処理」に分けました。主となる変数は以下の通りです。

// 歩行の内部状態
bool is_moving = true;     // 移動中?
bool is_animating = true;  // アニメ中?
double phase_move = 0.0;   // カウントアップして 1.0 に到達したら移動実行
double phase_anim = 0.0;   // カウントアップして 1.0 に到達したらアニメ実行

「移動処理」では、主人公キャラのXY座標を移動する処理をします。

移動中は phase_move という浮動小数点型の変数を使って、0.0 から 1.0 まで、移動にかけるフレーム数にあわせてカウントアップします。この phase_move1.0 になるまでは chara_offset_x, chara_offset_y という変数で主人公キャラの位置を少しずつずらしていきます。そのあいだは移動キー入力を判定しません。最終的に 1.0 を超えた時点でマップ上の位置を確定させたあと、もしまだ移動キー入力がされていたら続けて移動します。

// 歩行移動処理
if (is_moving || is_first_walk_frame) {
    phase_move += deltatime / (chara_move_frames[chara_speed] / 60.0);
    if (phase_move >= 1.0 || is_first_walk_frame) {
        phase_move = 0.0;

        // キャラの現在位置を更新
        chara_next_x = wrap(chara_next_x, map_w);
        chara_next_y = wrap(chara_next_y, map_h);
        chara_curr_x = chara_next_x;
        chara_curr_y = chara_next_y;

        // 新しい移動方向を決める
        switch (direction_curr) {
        case CHARACTER_DIRECTION_U: is_moving = true; chara_next_y--; break;
        case CHARACTER_DIRECTION_R: is_moving = true; chara_next_x++; break;
        case CHARACTER_DIRECTION_D: is_moving = true; chara_next_y++; break;
        case CHARACTER_DIRECTION_L: is_moving = true; chara_next_x--; break;
        default: is_moving = false; break;
        }
        // キャラの向きを更新
        if (is_moving) {
            chara_direction = direction_curr;
        }
    }

    // 座標移動
    chara_offset_x = (int)round(((double)chara_next_x - chara_curr_x) * CHIPSIZE * phase_move);
    chara_offset_y = (int)round(((double)chara_next_y - chara_curr_y) * CHIPSIZE * phase_move);

    // マップ描画位置を移動
    map_offset_x = chara_offset_x + (chara_curr_x - SCREEN_CENTER_X) * CHIPSIZE;
    map_offset_y = chara_offset_y + (chara_curr_y - SCREEN_CENTER_Y) * CHIPSIZE;
}

「アニメ処理」では、主人公キャラの歩行グラフィックの位置 chara_aniframe をパラパラマンガの要領で切り替える処理をします。

こちらもアニメ中は phase_move という浮動小数点型の変数でカウントしています。左→中→右→中→・・・というように行ったり戻ったりする動作を繰り返します。

// 歩行アニメーション処理
if (is_animating || is_first_walk_frame) {
    phase_anim += deltatime / (chara_anim_frames[chara_speed] / 60.0) * 6.0;
    if (phase_anim >= 1.0 || is_first_walk_frame) {
        phase_anim = 0.0;

        // アニメーションを継続する?
        is_animating = is_moving;

        // 交互にアニメーション
        if (chara_aniframe != 1 || !is_animating) {
            chara_aniframe = 1;
        }
        else {
            chara_aniframe = chara_aniframe_next;
            chara_aniframe_next = 2 - chara_aniframe_next;
        }
    }
}

スクリーン描画用バッファ

スクリーン描画用バッファとして 320×240 のテクスチャ screen_texture を持っています。ゲーム画面の内容はすべてここへレンダリングします。

ちなみに、このバッファのピクセル形式を SDL_PIXELFORMAT_RGB565 とすることで、RPGツクール2000の16ビットカラー仕様を再現しています。そこまでやらなくていい場合は SDL_PIXELFORMAT_RGB888 などに変えてみてください。

// スクリーン描画用のテクスチャ
// RPGツクール2000 で 16ビットカラー (RGB565) が使われている点も再現する
SDL_Texture* screen_texture = SDL_CreateTexture(
    render, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_TARGET, SCREEN_W, SCREEN_H
);

ウィンドウ枠

細かすぎて伝わらないポイントですが、RPGツクール2000/2003で作られたゲームのウィンドウモードには謎の枠が付いています (ただし現在入手が容易なSteam版のRPGツクールでは、エンジンのアップデートに伴ってこの枠は無くなっています)。より当時のRPGツクールの雰囲気を再現するために、この要素も盛り込んでみました。

// 枠を描画
SDL_SetRenderDrawColor(render, 160, 160, 160, 255);
SDL_RenderDrawLine(render, 0, 0, window_rect.w + 2, 0);
SDL_RenderDrawLine(render, 0, 0, 0, window_rect.h + 2);
SDL_SetRenderDrawColor(render, 105, 105, 105, 255);
SDL_RenderDrawLine(render, 1, 1, window_rect.w + 1, 1);
SDL_RenderDrawLine(render, 1, 1, 1, window_rect.h + 1);
SDL_SetRenderDrawColor(render, 223, 223, 223, 255);
SDL_RenderDrawLine(render, window_rect.w + 2, 1, window_rect.w + 2, window_rect.h + 2);
SDL_RenderDrawLine(render, 1, window_rect.h + 2, window_rect.w + 2, window_rect.h + 2);
// スクリーンを描画
SDL_RenderCopy(render, screen_texture, NULL, &window_rect);


くぼんでいるように見えるウィンドウ枠 (拡大図)

おわりに

RPGツクールっぽい歩行がだいたい再現できました。

マップまわりも再現したいところですが、タイリングの仕様が複雑なためたぶん大変だと思います。汎用的なマップエディタである Tiled Map Editor を使っても細かい挙動が再現しにくいので、本気でやるとカスタムのマップエディタを作るところからになってしまいそうです。

vcpkg が便利だったので積極的に推していきたいです。最初の導入の手間こそあるものの、いちど構築してしまえばあとがめっちゃ楽でした。

Discussion