【SvelteでRPGゲーム開発】requestAnimationFrameを使いながらアニメーションを意識したスプライトを操作する


※ 当ページには【広告/PR】を含む場合があります。
2023/01/26
【SvelteでRPGゲーム開発】マップ背景を表示する〜基本編
【SvelteでRPGゲーム開発】コントローラーをコンポーネント化してスプライトを操作してみる
合同会社タコスキングダム|TacosKingdom,LLC.

前回は、Svelteのマップとその画像の扱い方を中心に解説していきました。

合同会社タコスキングダム|タコキンのPスクール
【SvelteでRPGゲーム開発】マップ背景を表示する〜基本編

SvelteでRPG自作ゲームを作る際にタイルマップをブラウザに簡単に表示させるためのテクニックを解説します。

今回はスプライトをキーボードから方向キーで動くゲームアプリまでの実装を解説していきます。

具体的には、この記事で目的としてかかげるポイントは以下の通りです。

            
            1. Svelteのリアクティブ構文を理解する
2. setTimeoutとリアクティブを組み合わせてSvelte版再帰ループを扱う
3. Svelteでのキーボード入力イベントの使い方
4. requestAnimationFrameと組み合わせたSvelteでのアニメーションの実装方法
        
最後まで実装していただくと以下のように動きます。

説明


RPG風マップでネコ(?)を動かすだけの実験プログラムです。
ゲームの枠内をクリックして、キーボードからの操作が有効になります。
キーボードの方向キーで上下左右に動きます。


JavaScriptとHTMLで「レトロ風RPG」を作ろう 全コード解説

JavaScriptとCanvasでアニメーションとゲーム制作!~描き、動かし、操作する~

合同会社タコスキングダム|タコキンのPスクール【Html&Cssアプリ】Html/JavascriptとSassを使った四択クイズゲームの作り方

Svelteでスプライトを操作する

今回は、以前の回の続きから作業を開始します。

Svelteプロジェクトの構成(のnodejs等の設定ファイルは除く)は前回から以下のようにしてみます。

※新規追加するファイル/フォルダには
、内容を修正するファイルにはで示します。

            
            .
├── index.html
└── src
     ├── App.svelte
     ├── app.scss
     ├── main.ts
     ├── components
     │   ├── player.svelte☆
     │   └── tile.svelte
     └── lib
          ├── models.ts☆
          └── GameStage.svelte○
#(その他のファイルは省略)
        
さほど修正するファイルの量は多くありませんが、Svelte特有のコードを実装に慣れていないと少し苦労するかも知れません。

Svelteでは他のJavascript系フレームワークと比較すると難易度が低いと言われていますが、「学習ゼロでも」できるとはいきません。分からない文法や用法がある場合、公式のドキュメントで基本に立ち戻り確認しましょう。

Svelte | 日本語版ドキュメント

まずはメインとなるキャラクタースプライト(プレイヤー)のコンポーネントファイル・
player.svelteを実装してみます。

            
            <script lang="ts">
    export let top: number;
    export let left: number;
    const neko = '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UCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOmlwdGNFeHQ9Imh0dHA6Ly9pcHRjLm9yZy9zdGQvSXB0YzR4bXBFeHQvMjAwOC0wMi0yOS8iCiAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiCiAgICB4bWxuczpwbHVzPSJodHRwOi8vbnMudXNlcGx1cy5vcmcvbGRmL3htcC8xLjAvIgogICAgeG1sbnM6R0lNUD0iaHR0cDovL3d3dy5naW1wLm9yZy94bXAvIgogICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iCiAgIHhtcE1NOkRvY3VtZW50SUQ9ImdpbXA6ZG9jaWQ6Z2ltcDo2YTc1YjkxMS1kZjFhLTQ3ZmUtOTJlNi0yZDlhZWRjZmYzOWQiCiAgIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ZDA2N2EzMzktMGYyZS00N2EzLWJiZGQtYmI5NThlMmNmMmUwIgogICB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZGUyNTBjYjMtNGZlZi00Mzc4LWFkMzMtOTZjNTYzYzU3NDIxIgogICBHSU1QOkFQST0iMi4wIgogICBHSU1QOlBsYXRmb3JtPSJMaW51eCIKICAgR0lNUDpUaW1lU3RhbXA9IjE2NzQzODk4MTk3OTg2NzQiCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4yMiIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIHRpZmY6T3JpZW50YXRpb249IjEiCiAgIHhtcDpDcmVhdG9yVG9vbD0iR0lNUCAyLjEwIj4KICAgPGlwdGNFeHQ6TG9jYXRpb25DcmVhdGVkPgogICAgPHJkZjpCYWcvPgogICA8L2lwdGNFeHQ6TG9jYXRpb25DcmVhdGVkPgogICA8aXB0Y0V4dDpMb2NhdGlvblNob3duPgogICAgPHJkZjpCYWcvPgogICA8L2lwdGNFeHQ6TG9jYXRpb25TaG93bj4KICAgPGlwdGNFeHQ6QXJ0d29ya09yT2JqZWN0PgogICAgPHJkZjpCYWcvPgogICA8L2lwdGNFeHQ6QXJ0d29ya09yT2JqZWN0PgogICA8aXB0Y0V4dDpSZWdpc3RyeUlkPgogICAgPHJkZjpCYWcvPgogICA8L2lwdGNFeHQ6UmVnaXN0cnlJZD4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDA0NGZhMjYtMDYyNS00YzI0LTg5N2MtMjFkZDU3YzJmNGI0IgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKExpbnV4KSIKICAgICAgc3RFdnQ6d2hlbj0iKzA5OjAwIi8+CiAgICA8L3JkZjpTZXE+CiAgIDwveG1wTU06SGlzdG9yeT4KICAgPHBsdXM6SW1hZ2VTdXBwbGllcj4KICAgIDxyZGY6U2VxLz4KICAgPC9wbHVzOkltYWdlU3VwcGxpZXI+CiAgIDxwbHVzOkltYWdlQ3JlYXRvcj4KICAgIDxyZGY6U2VxLz4KICAgPC9wbHVzOkltYWdlQ3JlYXRvcj4KICAgPHBsdXM6Q29weXJpZ2h0T3duZXI+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpDb3B5cmlnaHRPd25lcj4KICAgPHBsdXM6TGljZW5zb3I+CiAgICA8cmRmOlNlcS8+CiAgIDwvcGx1czpMaWNlbnNvcj4KICA8L3JkZjpEZXNjcmlwdGlvbj4KIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAKPD94cGFja2V0IGVuZD0idyI/PoYCEdYAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfnARYMEDt9VKJ4AAABS0lEQVRIx81VMW7DMAw8GaxfUGWIp76lW/ocTXlBJn0nW9+SyR3iF9QQwA42HVmmZQV1gRLQIJm6o6g7GfjjMNoie+IpwQWztrkkr1rb3LiAxoUZyFpeSpYl2ALU8oREC8pVNQN8x6Mdn0Nrc8CbLQKA1pN6KpnL91zQFjgA4JpUeg3AiVBCUm2CAwPYiebzwqhKkg7nsCCa1vYg+E0UEXx8c9GaCECGSpBzbmnE8l2A2Rqz0u6Xx4W+jRtvkQjkLrp+wGJP3LiA1hOMC4Y08K6H0Rx9UyR5vxAO57AoTPWBVGFrsFQWn2BNYZIztpezr6lUEgNLT8Uf6TyW7EsP23rqhFAl0FqUvjuag40L5gi8ZgmeeVFXFDQ7we5GE/D/5eTd/8m2BmsqSi9XDJXKVuRefIKvwr/eU2FrsIzRPNOcPfERmIY8blH+FD95YrOV1qX1VgAAAABJRU5ErkJggg==';
</script>

<object title="" data="data:image/png;base64,{neko}" type="image/png" style="left: {left}px; top: {top}px"></object>

<style lang="scss">
object {
    display: block;
    width: 24px;
    height: 24px;
    position: absolute;
}
</style>
        
これは前回のタイルマップ用のコンポーネントと実装したときと同じです。

スプライト用の絵は今回単なる動作確認をするだけですので、適当な
24x24のpng画像1枚をBase64化したものをそのままソースコードに貼り付けたものを使います。

次に、無くても動くのですが、今後のゲーム拡張に備えて、クラスのオブジェクト型を
models.tsにまとめて定義しておきます。

            
            export type TSpliteBase = {
    id: number,
    x: number,
    y: number,
    width: number,
    height: number
}

/**
 * #### [type] TPlayer
 * - - -
 * @param id `{number}` スプライトID値
 * @param x `{number}` マップ上の水平方向の座標番号
 * @param y `{number}` マップ上の垂直方向の座標番号
 * @param width `{number}` 画像の幅
 * @param height `{number}` 画像の高さ
 * @param life `{number}` HPを記録
 */
export type TPlayer = TSpliteBase & {
    life?: number,
};

export class PlayerModel {
    private player: TPlayer;
    constructor(player: TPlayer) {
        this.player = player;
    }

    //👇値の設定・取得につかうショートカットアクセッサ
    get Id(): number { return this.player.id; };
    set Id(val: number) { this.player.id = val; };
    get X(): number { return this.player.x; };
    set X(val: number) { this.player.x = val; };
    get Y(): number { return this.player.y; };
    set Y(val: number) { this.player.y = val; };
    get W(): number { return this.player.width; };
    get H(): number { return this.player.height; };
    get T(): number { return this.player.y * this.player.height; };
    get L(): number { return this.player.x * this.player.width; };
    get B(): number { return (this.player.y + 1) * this.player.height; };
    get R(): number { return (this.player.x + 1) * this.player.width; };
}
        
とプレイヤーのクラス定義を用意しておくと、何かのときに便利に使えます。

ここまでのコンポーネントの追加は簡単ですが、今回の話のキモは、ゲームエンジン部分の
GameStage.svelteの修正の理解が重要になります。

ではまず以下のように修正してみます。

            
            <script lang="ts">
    import Tile from '../components/tile.svelte';

    //☆プレイヤーのコンポーネントを追加
    import Player from '../components/player.svelte';
    //☆モデルの定義ファイルも追加
    import { PlayerModel } from './models';

    //☆移動方向(上下左右と静止状態)のリテラル型
    type TDirection = 'u' | 'd' | 'l' | 'r' | 'n';

    const _arr = [
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
        [1,1,1,2,1,2,2,1,1,2,2,2,1,1,2,1,2,1,1,1],
        [1,1,2,2,1,2,2,2,1,2,2,2,1,2,2,1,2,2,1,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1],
        [1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,1,1,1,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,1],
        [1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,1,2,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,1,2,2,2,2,2,1,2,1,2,2,1,2,2,1],
        [1,1,2,2,1,1,1,1,2,2,1,1,1,2,2,2,1,1,2,1],
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
    ];

    const _map = _arr.map((r: number[], i: number) => {
        return r.map((c: number, j: number) => {
            return {tile_index: c, x: 24*i, y: 24*j}
        })
    });

    //☆1コマあたりの移動にかかる時間(ms)
    const moveTerm = 200;

    //☆プレイヤーのモデルを初期化
    const player = new PlayerModel({
        id: 0,
        x: 9,
        y: 9,
        width: 24,
        height: 24
        life: 4,
    });

    //①リアクティブ構文による変数のサブスクリプションを登録
    let movingLock = false;
    $: if (!movingLock) {
        if (movingFlg != 'n') {movePlayer();}
    }

    //☆プレイヤーの水平方向への移動
    const moveX = (x:number) => {
        player.X = x;
        if (player.X < 0) {player.X += 1;}
        else if (player.X > 19) {player.X -= 1;}
    };

    //☆プレイヤーの垂直方向への移動
    const moveY = (y:number) => {
        player.Y = y;
        if (player.Y < 0) {player.Y += 1;}
        else if (player.Y > 19) {player.Y -= 1;}
    };

    //②入力キーに応じてプレイヤー動かすメソッド
    function movePlayer() {
        if (key != 'n') {
            const _dir = movingFlg;
            if (!movingLock) {
                movingLock = true;
                let _tid = setTimeout(function repeat() {
                    slipX = 0;
                    slipY = 0;
                    if (_dir == 'u') {moveY(player.Y-1);}
                    else if (_dir == 'd') {moveY(player.Y+1);}
                    else if (_dir == 'l') {moveX(player.X-1);}
                    else if (_dir == 'r') {moveX(player.X+1);}
                    movingLock = false;
                }, moveTerm);
            }
        }
    }

    //☆現在の入力キーを記録
    let key: TDirection = 'n';
    //☆一つ前の入力キーを記録
    let oldKey: TDirection = 'n';
    //☆キーの連続入力(押しっぱなし)に対応するためのキー状態を記録
    let movingFlg: TDirection = 'n';

    //③キーダウンイベント ... up/38 down/40 right/39 left/37
    function onKeyDown(e: any) {
        if (movingLock) {return;}
        switch(e.keyCode) {
        case 38:
            key = 'u';
            break;
        case 40:
            key = 'd';
            break;
        case 37:
            key = 'l';
            break;
        case 39:
            key = 'r';
            break;
        default:
            key = 'n';
            break;
        }
        if (key != oldKey) {
            //👇押されたキーが前回と違うときだけキーを記録し、移動を開始させる
            movingFlg = key;
            movePlayer();
        }
        oldKey = key;
    }

    //③キーアップイベント
    function onKeyUp(e: any) {
        //キーを離すタイミングで、静止状態にして、スプライトの動きを止める
        switch(e.keyCode) {
        case 38:
        case 40:
        case 37:
        case 39:
            key = 'n';
            if (!movingLock) {movePlayer();}
            break;
        }
        oldKey = key;
    }
</script>

<!-- 👇③Svelteでキー入力するためのおまじない -->
<svelte:window on:keydown|preventDefault={onKeyDown} on:keyup|preventDefault={onKeyUp}/>

<section>
    {#each _map as mapx}
        {#each mapx as mapxy}
            <Tile pattern={mapxy.tile_index} left={mapxy.x} top={mapxy.y}/>
        {/each}
    {/each}

    <!-- 👇プレイヤーコンポーネントを追加 -->
    <Player left={player.L} top={player.T}></Player>
</section>

<!-- 👇デバッグ用のパラメーター確認 -->
<p>x={player.X} y={player.Y} left={(player.L).toFixed(1)} top={(player.T).toFixed(1)}</p>
<p>key={key} movingFlg={movingFlg}</p>

<style lang="scss">
    //..前回より変更なし
</style>
        
ひとまずここまでのコードでアプリをビルドすると以下のように動くでしょう。

追記したコードの意味はコメントして記しました。

ここでは今回のSvelteアプリの作成で、とりわけ理解しにくいものを少し取り上げてみましょう。

Svelteのリアクティブ構文を使いこなす

あまり耳慣れないかもしれない
「リアクティブプログラミング」という単語を聞いたことはありますでしょうか。

"リアクティブ"とは言葉じりだけでいうと、「反応的な」を意味する形容詞なので、"リアクティブプログラミング"というと、「反応的なプログラミング」...なんじゃそらと思われるかもしれません。

と、言葉の意味そのものではなく、リアクティブプログラミングの指すところは、
「データストリームとその変更の伝搬を関心事とする宣言的プログラミングパラダイム」ということだそうです。

数あるプログラミングパラダイムの中でもっとも有名でかつ成功をおさめたものは、おそらく
「オブジェクト指向プログラミング」でしょう。

最近では、
「関数型プログラミング」もしばしば目にする頻度も多くなりましたが、これもプログラミングパラダイムの一つです。

「リアクティブプログラミング」もさほどメジャーではないものの、重要なプログラミングパラダイムになります。

主要なJS系フレームワークでもリアクティブプログラミングを実装する仕組みが大なり小なり備わっていて、Svelteにも
「リアクティブ構文」という形でひっそりと備わっています。

リアクティブ構文|Svelteドキュメント

Svelteのリアクティブ構文は、リアクティブプログラミングが出来るというほどのものではありませんが、この仕組みが理解して使えないと、複雑化するアプリの実装が厳しくなります。

Svelteでは、スクリプトパートで、
「$:」を付けて書き始めると、その範囲スコープ内に存在する変数すべてをまるごと"リアクティブ"にすることができます。

リアクティブとなった変数は、その値が常に監視される状態になり、その値が変更されたときに、その変数に依存する全ての関数や変数・リアクティブ化したスクリプトが自動で変更されるようになります。

            
            //外部のコンポーネントからの入力するためのエクスポート
export let title;
export let person

//👇変数titleの値が変わるたびに、それに依存するdocument.titleも自動で変更される
$: document.title = title;

//👇変数titleの値が変わるたびに、リアクティブ化したスクリプト全体が実行される
$: {
    console.log(`複数のステートメントをまとめることができます`);
    console.log(`現在のタイトルは ${title}`);
}

//👇personというインスタンスが変わるたびに、{name: string}型のオブジェクトも更新される
$: ({ name } = person);
        
ということで改めて先程のコード中のの部分を見てみましょう。

            
            ...
//👇リアクティブ構文による変数のサブスクリプションを登録
let movingLock = false;
$: if (!movingLock) {
    if (movingFlg != 'n') {movePlayer();}
}
...
        
ここではスプライトが移動中かどうかを表すmovingLockとスプライトの進む方向を示すmovingFlgを同時にリアクティブにしています。

これで、スプライトが移動している際には他のキー入力を受け付けない、と同時に、移動のロックが解除したら
movingFlgの状態を確認して即時次の移動を開始する、という2つのことが簡潔に行えます。

setTimeoutメソッドで移動完了を検知しながら処理ループさせる

キー入力と同時にスプライトを目的のマス目の座標に移動させては、移動する過程もなく、瞬間移動になってしまいます。

そこでキー入力してから特定の時間後にスプライト移動が実行される必要があります。

実際に移動しているように見せるには、いくつか方法が考えられますが、ここではもっともシンプルに
setTimeoutで実行を遅延させる方法を使います。

            
            //👇入力キーに応じてプレイヤー動かすメソッド
function movePlayer() {
    if (key != 'n') {
        const _dir = movingFlg;
        if (!movingLock) {
            movingLock = true;
            let _tid = setTimeout(function repeat() {
                slipX = 0;
                slipY = 0;
                if (_dir == 'u') {moveY(player.Y-1);}
                else if (_dir == 'd') {moveY(player.Y+1);}
                else if (_dir == 'l') {moveX(player.X-1);}
                else if (_dir == 'r') {moveX(player.X+1);}
                movingLock = false;
            }, moveTerm);
        }
    }
}
        
単純にJavascriptでsetTimeoutを使うと、引数で指定したコールバックの中身が遅延して1回実行されます。

ですが、先程のリアクティブ構文でも説明した通り、変数・
movingLockは既にリアクティブ化されています。

なので、setTimeoutのコールバック内で
movingLockの値を書き換えると、さらにリアクティブで登録した関数から再度movePlayerが呼び出されて、「Svelte版setTimeoutの再帰処理」となっていることに注意してください。

なお、純粋なJavascriptでの「setTimeoutの再帰処理」に関しては以下のブログで扱ったことがあるので、興味があれば一読ください。

参考|【Angular活用講座】Rxjs:repeatオペレーターで一定時間間隔の処理(再帰的ループ)を行わせてみる

Svelteでのキーボード入力処理

純粋なJavascript&HTMLアプリならば、グローバルな
windowdocumentオブジェクトを直接イベントハンドラを拡張することでキーボード入力できるわけですが、Svelteでは直接ブラウザのグローバル変数を扱うことは非推奨です。

ではSvelteでのキーボード操作はどうするかというと、例えば以下のサンプルコードが参考になります。

Special elements - <svelte:window>

Svelteには
特殊要素と呼ぶ、標準で使えるDOM要素がいくつか用意してあります。

先程の③のコード部分を改めてみると、

            
            <script>
    ...

    //③キーダウンイベント ... up/38 down/40 right/39 left/37
    function onKeyDown(e: any) {
        if (movingLock) {return;}
        switch(e.keyCode) {
        case 38:
            key = 'u';
            break;
        case 40:
            key = 'd';
            break;
        case 37:
            key = 'l';
            break;
        case 39:
            key = 'r';
            break;
        default:
            key = 'n';
            break;
        }
        if (key != oldKey) {
            //👇押されたキーが前回と違うときだけキーを記録し、移動を開始させる
            movingFlg = key;
            movePlayer();
        }
        oldKey = key;
    }

    //③キーアップイベント
    function onKeyUp(e: any) {
        //キーを離すタイミングで、静止状態にして、スプライトの動きを止める
        switch(e.keyCode) {
        case 38:
        case 40:
        case 37:
        case 39:
            key = 'n';
            if (!movingLock) {movePlayer();}
            break;
        }
        oldKey = key;
    }
</script>

<!-- 👇③Svelteでキー入力するためのおまじない -->
<svelte:window on:keydown|preventDefault={onKeyDown} on:keyup|preventDefault={onKeyUp}/>

...
        
という感じに<svelte:window>要素へ、キー入力イベントを登録することで、Svelteアプリだけにイベントが着火できるようになります。

Svelteゲームの動作確認(ただしアニメーションなし)

では先程のコードを実行して動くかどうか見てみましょう。

やってもらえば分かるように、キャラクタースプライトをマップ移動させるときに、カックカクです。それもそのはず、滑らかに座標を移動させる「アニメーション」機能をまだ実装していないのですから。

では次のパートで、少し難易度が上がりますが、
『requestAnimationFrame』APIによるアニメーションの実装手順を解説していきます。


JavaScriptとHTMLで「レトロ風RPG」を作ろう 全コード解説

JavaScriptとCanvasでアニメーションとゲーム制作!~描き、動かし、操作する~

合同会社タコスキングダム|タコキンのPスクール【Html&Cssアプリ】Html/JavascriptとSassを使った四択クイズゲームの作り方

アニメーション対応のSvelteコンポーネントに修正する

もともとSvelteには、CSSベースのアニメーション操作用の標準ライブラリが存在しています。

参考|Example -- Animation

ただ、HTMLゲームを作る場合、CSSのアニメーションを間接的に使いながら、スプライトのアニメーション効果を盛り込んでいくのはかなり柔軟性の欠ける手法です。

ということで、ここではブラウザ標準のAPIである
『requestAnimationFrame』を利用するかたちでアニメーション効果を実装することを考えていきましょう。

Window.requestAnimationFrame() | MDN

requestAnimationFrameメソッドとは?

ウェブブラウザは一般的に、お手元のディスプレイのリフレッシュレートに合わせて、ブラウザ上に表示している画面を更新し続けています。

滑らかにスクロールできたり、動画を再生できたりするのも、ブラウザのこの仕組みのおかげです。

通常のディスプレイのリフレッシュレートは60Hzほど(1秒間に60回の更新)ですが、最近では144Hzリフレッシュレートのゲーミングディスプレイを使っている方も珍しくないようです。

つまり、アニメーションのようなブラウザ上で滑らかに動かしたい表示効果は、このリフレッシュレートに合わせて更新させる必要があります。

通常、ブラウザは何が何でもリフレッシュレートで表示更新しているわけではなく、表示更新の必要性がない場合には更新させず休んでおくように最適化されています。

画面の更新を一回行うたびに、再描画するための下処理・描画座標等の再計算など、その都度それなりに重い処理負荷がかかるため、気をつけないとブラウザのパフォーマンスが著しく落ちたり、消費電力をバク食いしたりする恐れもあります。

つまり、リフレッシュレートに合わせてブラウザの描画更新するためには、ブラウザに
「アニメーションを行いたいことを知らせ、次の再描画の前にアニメーションを更新することを要求」しないといけません。

そのためのAPIメソッドが、
『requestAnimationFrame』です。

このメソッドは、再描画の前に呼び出されるコールバック関数を引数として与えて利用します。

このため、ブラウザゲームを自作したいなら、「requestAnimationFrame」の使いこなしは避けては通れないということです。

requestAnimationFrameは少し分かりにくいにくいメソッドですが、是非とも積極的に使い込んで理解を深めてみてください。

ゲームエンジンのコード修正

では再び
GameStage.svelteのコードを改造してみましょう。

            
            <script lang="ts">
    //☆onMountメソッドを実装
    import { onMount } from 'svelte';

    import Tile from '../components/tile.svelte';
    import Player from '../components/player.svelte';
    import { PlayerModel } from './models';

    type TDirection = 'u' | 'd' | 'l' | 'r' | 'n';

    const _arr = [
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
        [1,1,1,2,1,2,2,1,1,2,2,2,1,1,2,1,2,1,1,1],
        [1,1,2,2,1,2,2,2,1,2,2,2,1,2,2,1,2,2,1,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1],
        [1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,2,2,2,2,1,1,1,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,1,1,2,2,2,2,2,2,2,2,1,1],
        [1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,1],
        [1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,1,2,1],
        [1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1],
        [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
        [1,2,2,2,2,1,2,2,2,2,2,1,2,1,2,2,1,2,2,1],
        [1,1,2,2,1,1,1,1,2,2,1,1,1,2,2,2,1,1,2,1],
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
    ];

    const _map = _arr.map((r: number[], i: number) => {
        return r.map((c: number, j: number) => {
            return {tile_index: c, x: 24*i, y: 24*j}
        })
    });

    const moveTerm = 200;

    //⑤フレッシュレートによる更新周期の割合progressの1つ前の値を記録する
    let _oldProg: number;
    //⑤アニメーションさせる移動時の変化量
    let slipX: number = 0, slipY: number = 0;
    //⑤requestAnimationFrameで更新させるメインの描画関数
    const draw = (progress: number) => {
        const _delta = progress > _oldProg ? progress - _oldProg : 1 + progress - _oldProg;
        _oldProg = progress;
        if (movingLock) {
            if (movingFlg == 'u') {
                if (player.Y == 0) {slipY = 0;}
                else if (slipY > -player.H) {slipY = slipY - player.H*_delta;}
                slipX = 0;
            }
            else if (movingFlg == 'd') {
                if (player.Y == 19) {slipY = 0;}
                else if (slipY < player.H) {slipY = slipY + player.H*_delta;}
                slipX = 0;
            }
            else if (movingFlg == 'l') {
                if (player.X == 0) {slipX = 0;}
                else if (slipX > - player.W) {slipX = slipX - player.W*_delta;}
                slipY = 0;
            }
            else if (movingFlg == 'r') {
                if (player.X == 19) {slipX = 0;}
                else if (slipX < player.W) {slipX = slipX + player.W*_delta;}
                slipY = 0;
            }
        }
    };

    const player = new PlayerModel({
        id: 0,
        x: 9,
        y: 9,
        width: 24,
        height: 24
        life: 4,
    });

    let movingLock = false;
    $: if (!movingLock) {
        if (movingFlg != 'n') {movePlayer();}
    }

    const moveX = (x:number) => {
        player.X = x;
        if (player.X < 0) {player.X += 1;}
        else if (player.X > 19) {player.X -= 1;}
    };

    const moveY = (y:number) => {
        player.Y = y;
        if (player.Y < 0) {player.Y += 1;}
        else if (player.Y > 19) {player.Y -= 1;}
    };

    function movePlayer() {
        if (key != 'n') {
            const _dir = movingFlg;
            if (!movingLock) {
                movingLock = true;
                let _tid = setTimeout(function repeat() {
                    slipX = 0;
                    slipY = 0;
                    if (_dir == 'u') {moveY(player.Y-1);}
                    else if (_dir == 'd') {moveY(player.Y+1);}
                    else if (_dir == 'l') {moveX(player.X-1);}
                    else if (_dir == 'r') {moveX(player.X+1);}
                    movingLock = false;
                }, moveTerm);
            }
        }
    }

    let key: TDirection = 'n';
    let oldKey: TDirection = 'n';
    let movingFlg: TDirection = 'n';

    function onKeyDown(e: any) {
        if (movingLock) {return;}
        switch(e.keyCode) {
        case 38:
            key = 'u';
            break;
        case 40:
            key = 'd';
            break;
        case 37:
            key = 'l';
            break;
        case 39:
            key = 'r';
            break;
        default:
            key = 'n';
            break;
        }
        if (key != oldKey) {
            movingFlg = key;
            movePlayer();
        }
        oldKey = key;
    }

    function onKeyUp(e: any) {
        switch(e.keyCode) {
        case 38:
        case 40:
        case 37:
        case 39:
            key = 'n';
            if (!movingLock) {movePlayer();}
            break;
        }
        oldKey = key;
    }

    //④ onMountでコンポーネントの生成時にアニメーションを開始
    onMount(() => {
        let frame: number;
        let timer: number = performance.now();
        function loop() {
            let start = performance.now();
            const progress = (start - timer) / moveTerm;
            if (progress > 1) {timer = performance.now();}
            draw(progress);
            frame = requestAnimationFrame(loop);
        }
        loop();
        return () => cancelAnimationFrame(frame);
    });
</script>

<svelte:window on:keydown|preventDefault={onKeyDown} on:keyup|preventDefault={onKeyUp}/>

<section>
    {#each _map as mapx}
        {#each mapx as mapxy}
            <Tile pattern={mapxy.tile_index} left={mapxy.x} top={mapxy.y}/>
        {/each}
    {/each}
    <!-- ☆コンポーネントにアニメーション変化量(slipX/slipY)を加味 -->
    <Player left={player.L + slipX} top={player.T + slipY}></Player>
</section>

<!-- ☆デバッグ用のパラメーター確認 -->
<p>x={player.X} y={player.Y} left={(player.L + slipX).toFixed(1)} top={(player.T + slipY).toFixed(1)} slipX={slipX.toFixed(2)} slipY={slipY.toFixed(2)}</p>
<p>key={key} movingFlg={movingFlg}</p>

<style lang="scss">
    //..前回より変更なし
</style>
        
新たに修正した箇所はを付けてコメントを付加しました。

以下ではコードに記した
のポイントだけを集中的に解説しましょう。

requestAnimationFrameをループ化して呼び出す

まず、コード上の
の部分の解説です。

requestAnimationFrameを使ってSvelteでフレッシュレートを使ってアニメーションさせる必要があるたびに使うテクニックになります。

ゲームエンジンとなるコンポーネント(ここでは
GameStage.svelte)のスクリプトで、以下のような実装を追加していました。

            
            //👇OnMountからコンポーネントが最初にDOMレンダリングされた後にアニメーション登録
onMount(() => {
    let frame: number;
    let timer: number = performance.now();

    //👇requestAnimationFrameをループ化し連続的に画面を更新する
    function loop() {
        const start = performance.now();
        const progress = (start - timer) / moveTerm;
        if (progress > 1) {
            //👇指定したmoveTermが一周したらtimerの時間を更新
            timer = performance.now();
            progress = 1;
        }
        //👇メインの描画関数
        draw(progress);
        frame = requestAnimationFrame(loop);
    }

    //👇ディスプレイフレッシュレートによるアニメーションの開始
    loop();

    //👇このコンポーネントが破棄されたタイミングでアニメーションも破棄
    return () => cancelAnimationFrame(frame);
});
        
ここではゲームの開始時に、requestAnimationFrameを再帰的に呼んでリフレッシュレート毎に描画処理(ここではdrawメソッド)を行わせています。

リフレッシュレートでループが繰り返される際に、
progressと名付けたローカル変数あることに気づかれると思います。

この
progress(=進捗率)こそもっとも重要で、アニメーションを行わせる時間(=moveTerm)が開始されたとき(progress = 0)から終わるまで(progress = 1)を計算しています。

アニメーションさせる際には、この進捗率を利用させることで、ユーザーによって異なるディスプレイのフレッシュレートに依存しないゲーム環境を提供できるようになります。

フレッシュレートに合わせたアニメーションを更新する

さて、最後に
の部分を以下に詳しくコメントとして解説しておきましょう。

            
            //👇フレッシュレートによる更新周期の割合progressの1つ前の値を記録する
let _oldProg: number;

//👇アニメーションさせる移動時の変化量
let slipX: number = 0, slipY: number = 0;

//👇requestAnimationFrameで更新させるメインの描画関数
const draw = (progress: number) => {
    //👇前回の進捗率と現在の進捗率の差分(=アニメーションの移動量)
    const _delta = progress > _oldProg ? progress - _oldProg : 1 + progress - _oldProg;

    //👇次のループでの1つ前の進捗率として使うために保存
    _oldProg = progress;

    //👇movingLock = trueのときだけアニメーションが有効
    if (movingLock) {
        //👇入力されたキーによってスプライトの位置を更新
        if (movingFlg == 'u') {
            if (player.Y == 0) {slipY = 0;}
            else if (slipY > -player.H) {slipY = slipY - player.H*_delta;}
            slipX = 0;
        }
        else if (movingFlg == 'd') {
            if (player.Y == 19) {slipY = 0;}
            else if (slipY < player.H) {slipY = slipY + player.H*_delta;}
            slipX = 0;
        }
        else if (movingFlg == 'l') {
            if (player.X == 0) {slipX = 0;}
            else if (slipX > - player.W) {slipX = slipX - player.W*_delta;}
            slipY = 0;
        }
        else if (movingFlg == 'r') {
            if (player.X == 19) {slipX = 0;}
            else if (slipX < player.W) {slipX = slipX + player.W*_delta;}
            slipY = 0;
        }
    }
};
        
なお、フレッシュレートでの進捗率progressはゲームがスタートすると基本止まること無く「0 〜 1」の間の値で更新され続けます。

よって
progressの値はゲーム固有のグローバルカウンターとも考えることができますので、これを利用して様々なアニメーション効果を追加することが可能です。

ただし、フレッシュレートを使ったアニメーション処理は非常に重い負荷がかかることもあるので、アニメーションの使用はほどほどに抑えられるように工夫しましょう。


JavaScriptとHTMLで「レトロ風RPG」を作ろう 全コード解説

JavaScriptとCanvasでアニメーションとゲーム制作!~描き、動かし、操作する~

合同会社タコスキングダム|タコキンのPスクール【Html&Cssアプリ】Html/JavascriptとSassを使った四択クイズゲームの作り方

まとめ

今回はSvelteアプリを念頭に、レトロRPG風のキャラクタースプライトをアニメーション付きでマップ上で動かすもっとも基本となるテクニックを紹介しました。

この記事で解説した細かいポイントが以下の通りです。

            
            1. Svelteのリアクティブ構文を理解する
2. setTimeoutとリアクティブを組み合わせてSvelte版再帰ループを扱う
3. Svelteでのキーボード入力イベントの使い方
4. requestAnimationFrameと組み合わせたSvelteでのアニメーションの実装方法
        
ここまでなるべく詳しく解説してきましたが、javascript/typescriptやHTMLの高度な知識も必要になるかも知れません。

一人では到底理解できないと感じられたら、やはりUdemyのオンライン動画講座などでじっくり基礎を固められてから目的のアプリ作りに専念されるのも良いでしょう。

で、次回はRPG風の会話ウィンドウなどの実装の話題をお届けする予定です。好ご期待〜💫
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

これからの"地方格差"なきプログラミング教育とは何かを考えながら、 地方密着型プログラミング学習関連テーマの記事を不定期で独自にブログ発信しています。

合同会社タコスキングダム|タコキンのPスクール【Html&Cssアプリ】Html/JavascriptとSassを使った四択クイズゲームの作り方