【SvelteでRPGゲーム開発】コントローラーをコンポーネント化してスプライトを操作してみる


※ 当ページには【広告/PR】を含む場合があります。
2023/02/12
2023/02/14
【SvelteでRPGゲーム開発】requestAnimationFrameを使いながらアニメーションを意識したスプライトを操作する
【SvelteでRPGゲーム開発】Svelteコンポーネントを使ったHTML中の表示画面のフレキシブル対応
合同会社タコスキングダム|TacosKingdom,LLC.

前回までで、Javascriptゲームで欠かせない滑らかなアニメーション効果をつけるための
「requestAnimationFrame」をじっくり解説していきました。

合同会社タコスキングダム|タコキンのPスクール
【SvelteでRPGゲーム開発】requestAnimationFrameを使いながらアニメーションを意識したスプライトを操作する

SvelteでRPG自作ゲームを作る際にブラウザへキー入力によるキャラクターのアニメーション付き操作を考えます。

おそらく
requestAnimationFrameを理解することがJavascripゲームにおいては一番難しいのではないかと想像します。

逆に言うと、
requestAnimationFrameさえ理解すれば、他のテクニックはまだ何とか分かるようになってくるでしょう。

今回はスプライトを
コントローラー風のSvelteコンポーネントから操作できるゲームアプリの作り方の基本を解説していきます。

前回で難しさの“山場”は超えているので、さほど気構えることなく楽しくSvelteアプリを楽しんで作り込んでいきましょう。

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

            
            1. 親子関係にあるSvelteコンポーネント間のイベントの渡し方
2. ゲーム画面上のコンポーネントを右クリック禁止にする
3. ゲーム画面上の文字テキストの選択を禁止にする
4. iframeのリソースキャッシュを無効化するメタタグの書き方
        
なお、最後まで実装していただくと以下のように動きます。※まだ表示要素のフレキシブル対応などを考慮していなので、スマホなどからでは表示サイズが適切に調整されません...ご了承ください。

説明


RPG風マップでネコ(?)を動かすだけの実験プログラムです。
画面下にあるコントローラーからの操作が有効になります。
現状は十字キーで上下左右に動くのみです。


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

【Svelte.js入門】ReactやVueに挫折した人でも大丈夫!Svelteとfirebaseでシンプルアプリ開発

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

Svelteでコントローラー風コンポーネントを準備する

今回も前回の内容を終えたあとの続きとして開発作業を進めていきます。

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

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

            
            .
├── index.html
└── src
     ├── App.svelte
     ├── app.scss
     ├── main.ts
     ├── components
     │   ├── player.svelte
     │   ├── title.svelte
     │   └── controller.svelte☆
     └── lib
          ├── models.ts
          └── GameStage.svelte○
#(その他のファイルは省略)
        
ということでcontroller.svelteというコンポーネントファイルが新しく追加になります。

いきなり完成品を丸々とお見せしますが、ファイルを追加したら、まず以下のように実装してみてください。

            
            <script lang="ts">
    export let cursorHandler: (dir: number, state: number) => void;
    export let btnAHandler: (state: number) => void;
    export let btnBHandler: (state: number) => void;
    export let btnXHandler: (state: number) => void;
    export let btnYHandler: (state: number) => void;
    export let btnStartHandler: (state: number) => void;
    export let btnSelectHandler: (state: number) => void;
</script>

<div class="spf-controller" on:contextmenu|preventDefault>
    <div class="controller-left">
        <div class="cross-layout">
            <button class="position-top btn cross-key-btn"
                on:mousedown={() => cursorHandler(1,0)}
                on:mouseup={() => cursorHandler(1,1)}><span class="top-mark"></span></button>
            <button class="position-left btn cross-key-btn"
                on:mousedown={() => cursorHandler(2,0)}
                on:mouseup={() => cursorHandler(2,1)}><span class="left-mark"></span></button>
            <button class="position-center btn cross-key-btn"><span class="center-mark"></span></button>
            <button class="position-right btn cross-key-btn"
                on:mousedown={() => cursorHandler(3,0)}
                on:mouseup={() => cursorHandler(3,1)}><span class="right-mark"></span></button>
            <button class="position-bottom btn cross-key-btn"
                on:mousedown={() => cursorHandler(4,0)}
                on:mouseup={() => cursorHandler(4,1)}><span class="bottom-mark"></span></button>
        </div>
    </div>
    <div class="controller-center">
        <div class="selectstart-btn-set">
            <button class="btn selectstart-btn"
                on:mousedown={() => btnSelectHandler(0)}
                on:mouseup={() => btnSelectHandler(1)}></button>
            <button class="btn selectstart-btn"
                on:mousedown={() => btnStartHandler(0)}
                on:mouseup={() => btnStartHandler(1)}></button>
        </div>
    </div>
    <div class="controller-right">
        <div class="abxy-btn-set">
            <div class="cross-layout">
                <button class="btn abxy-btn position-top btn-x"
                    on:mousedown={() => btnXHandler(0)}
                    on:mouseup={() => btnXHandler(1)}>X</button>
                <button class="btn abxy-btn position-left btn-y"
                    on:mousedown={() => btnYHandler(0)}
                    on:mouseup={() => btnYHandler(1)}>Y</button>
                <button class="btn abxy-btn position-right btn-a"
                    on:mousedown={() => btnAHandler(0)}
                    on:mouseup={() => btnAHandler(1)}>A</button>
                <button class="btn abxy-btn position-bottom btn-b"
                    on:mousedown={() => btnBHandler(0)}
                    on:mouseup={() => btnBHandler(1)}>B</button>
            </div>
        </div>
    </div>
</div>

<style lang="scss">
.spf-controller {
    display: flex;
    flex-direction: row;
    justify-content: center;

    .btn {
        border-style: none;
        cursor: pointer;
    }

    .cross-layout {
        display: grid;
        grid-template-columns: 30px 30px 30px;
        grid-template-rows: 30px 30px 30px;
        .position-top {
            grid-row: 1 / 2;
            grid-column: 2 / 3;
        }
        .position-left {
            grid-row: 2 / 3;
            grid-column: 1 / 2;
        }
        .position-center {
            grid-row: 2 / 3;
            grid-column: 2 / 3;
        }
        .position-right {
            grid-row: 2 / 3;
            grid-column: 3/4;
        }
        .position-bottom {
            grid-row: 3 / 4;
            grid-column: 2/3;
        }
        .abxy-btn {
            border-radius: 50%;
            width: 30px;
            height: 30px;
            color: white;
            &.btn-x {
                background-color: blue;
                &:active {
                    color: black;
                    background-color: rgb(125, 208, 255);
                }
            }
            &.btn-y {
                background-color: green;
                &:active {
                    color: black;
                    background-color: rgb(185, 243, 185);
                }
            }
            &.btn-a {
                background-color: red;
                &:active {
                    color: black;
                    background-color: rgb(255, 179, 179);
                }
            }
            &.btn-b {
                background-color: yellow;
                &:active {
                    color: black;
                    background-color: rgb(255, 255, 154);
                }
            }
        }
        .cross-key-btn {
            width: 30px;
            height: 30px;
            background-color: rgba(66, 86, 123, 0.5);
            &:active {
                color: aliceblue;
                background-color: red;
            }
            .left-mark {
                display: block;
                transform: rotate(-90deg);
            }
            .right-mark {
                display: block;
                transform: rotate(90deg);
            }
            .bottom-mark {
                display: block;
                transform: rotate(180deg);
            }
        }
    }

    .controller-right {
        z-index: 2;
        display: inline-block;
        background-color: rgb(229, 227, 250);
        padding: 20px;
        border-radius: 50%;
        .abxy-btn-set {
            display: inline-block;
            padding: 10px;
            background-color: rgba(66, 86, 123, 0.5);
            border-radius: 50%;
        }
    }

    .controller-left {
        z-index: 2;
        display: inline-block;
        background-color: rgb(229, 227, 250);
        padding: 30px;
        border-radius: 50%;
    }

    .controller-center {
        z-index:1;
        width: 220px;
        height: 110px;
        background-color: rgb(229, 227, 250);
        display: block;
        margin: auto;
        margin-top: 0;
        margin-left: -80px;
        margin-right: -80px;
        padding-top: 20px;
        text-align: center;
        font-size: 0.8em;
        color: gray;
        .selectstart-btn-set {
            display: block;
            width: 140px;
            text-align: center;
            margin: auto;
            margin-top: 50px;
            .selectstart-btn {
                width: 10px;
                height: 35px;
                border-radius: 15%;
                background-color: rgba(66, 86, 123, 0.5);
                transform: rotate(30deg);
                &:first-child {
                    margin-right: 20px;
                }
                &:active {
                    background-color: rgb(255, 115, 0);
                }
            }
        }
    }
}
</style>
        

なお、素のデザインなしのコントローラーでも良かったのですが、せっかくですので以下のサイトで紹介されている「スーパーファミコン風コントローラー」のスタイルを参考にレトロな雰囲気にさせていただきました。

参考|CSSでスーパーファミコン風のコントローラーを書いた

このSvelteコンポーネントの実装で重要なことは、
「親子関係にあるSvelteコンポーネントでの子から親へ向かうイベントの受け渡し」です。

上のソースコードでは少し長いので、重要なところだけを以下のように抜き出してみます。

            
            <script lang="ts">
    export let cursorHandler: (dir: number, state: number) => void;
    //...中略
</script>

//...中略

<button class="position-top btn cross-key-btn"
    on:mousedown={() => cursorHandler(1,0)}
    on:mouseup={() => cursorHandler(1,1)}><span class="top-mark"></span></button>

//...以降略
        
今回で言えば、ゲームのメインエンジンであるGameStage.svelteが親にあたり、このコンポーネントに従属する形でcontroller.svelteが子コンポーネントとして利用されています。

子コンポーネント上でイベントが発生した場合、親コンポーネント側で用意した関数(ハンドラ)を子に渡すことで、コンポーネント間のイベントの共有が可能です。

上の例でいうと、子コンポーネント側に
export let cursorHandler ...の関数型の変数を外部から注入できるようにしておきます。

そして、親コンポーネントにハンドラ(
cursorHandler)の実体を実装することで、子コンポーネントで発生したイベントを親側で取り扱うことが可能になります。

ということで、
GameStage.svelte側も以下のように修正しておきます。

            
            <script lang="ts">
    //...中略

    //👇Controllerコンポーネントを追加
    import Controller from '../components/controller.svelte';

    //...中略

    //👇各ボタンに対応したハンドラを追加
    function onClickCrossCursor(direct: number, state: number) {
        if (state === 0) {
            if (movingLock) {return;}
            switch(direct) {
                case 1:
                    key = 'u';
                    break;
                case 4:
                    key = 'd';
                    break;
                case 2:
                    key = 'l';
                    break;
                case 3:
                    key = 'r';
                    break;
                default:
                    break;
            }
            if (key != oldKey) {
                movingFlg = key;
                movePlayer();
            }
            oldKey = key;
        }
        else if (state === 1) {
            key = 'n';
            if (!movingLock) {movePlayer();}
            oldKey = key;
        }
    }

    function onClickABtn(state: number) {
        console.log('A', state);
    }

    function onClickBBtn(state: number) {
        console.log('B', state);
    }

    function onClickXBtn(state: number) {
        console.log('X', state);
    }

    function onClickYBtn(state: number) {
        console.log('Y', state);
    }

    function onClickStartBtn(state: number) {
        console.log('START', state);
    }

    function onClickSelectBtn(state: number) {
        console.log('SELECT', state);
    }

    //...中略
</script>

//...中略

//👇HTML部にControllerコンポーネントを追加
// ... 各ボタンに対応したイベントハンドラも設定
<Controller
    cursorHandler={onClickCrossCursor}
    btnAHandler={onClickABtn}
    btnBHandler={onClickBBtn}
    btnXHandler={onClickXBtn}
    btnYHandler={onClickYBtn}
    btnStartHandler={onClickStartBtn}
    btnSelectHandler={onClickSelectBtn}></Controller>

//...以下略
        
Svelteでコンポーネント間でイベントを共有するテクニックはいくつかあり、以前説明していたような「bind:this」を使って子コンポーネントを直接操作することもできます。

合同会社タコスキングダム|タコキンのPスクール
【Html&Cssで作る四択クイズゲーム開発記録⑦】Svelteアプリの作り方入門!HTML&CSSゲームをSvelteプロジェクトへ移行(マイグレーション)する

ネイティブなHTML&CSSを使って作ってきた四択クイズアプリをSvelteアプリとして再構築・マイグレーションする手順を解説していきます。

bind:this属性は、document.getElementById等のDOMを直接操作するAPIで操作できる反面、Svelte的な洗練されたコードというよりピュアなJavascriptコードの作法に近くなるため、できればSvelteのon:{イベント}属性を利用から外れないほうがベターな書き方です。


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

【Svelte.js入門】ReactやVueに挫折した人でも大丈夫!Svelteとfirebaseでシンプルアプリ開発

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

Svelteコンポーネントを右クリック禁止にする

細かい修正として、コンポーネントをマウスクリックする際に、通常のブラウザの挙動のままだと、右クリックしたらメニューバーが表示されてしまうことがしばしば問題になります。

合同会社タコスキングダム|タコキンのPスクール

この右クリックしたらメニューが表示されるのをコンポーネント単位で避けるためには、SvelteコンポーネントのDOM要素に以下のような属性を記述するだけでOKです。

            
            <div class="hoge" on:contextmenu|preventDefault>
...
</div>
        
Svelteの場合、on:contextmenu|preventDefaultを設定するだけで、右クリックからのコンテクストメニューの表示尾を禁止してくれます。

合同会社タコスキングダム|タコキンのPスクール


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

【Svelte.js入門】ReactやVueに挫折した人でも大丈夫!Svelteとfirebaseでシンプルアプリ開発

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

Svelteコンポーネントでマウス操作中のテキスト選択を無効にする

もう一つHTMLゲームを作るときに気をつけておきたいのは、マウスでテキスト文字を長押ししてみたり、テキスト上をマウスでドラッグしてみたりするようなマウス操作でHTML中のテキストが選択されてしまうというブラウザの機能です。

ブラウザでウェブページを閲覧するときに、文章をマウスドラッグで選んでコピーするなど、普通は無いと不便です。

他方、HTMLベースのゲームとなると、一々キャラクターのセリフに表示させるテキストが選択できてしまうことは問題になります。

ということで、ゲーム中のテキストは基本的に選択させない、という対策も必要です。

一つは、マウス操作の開始時に起こるイベント・
onselectstartをデフォルト機能を無効にすることでテキスト選択を回避できます。

例えばゲーム全体を新しく
div要素で囲んで、その要素に「on:selectstart|preventDefault」を指定してデフォルト機能を無効します。

            
            <div id="wrapper" on:selectstart|preventDefault>
...
</div>
        
実際にこの対策の前後で、ゲーム中のテキストをマウスで操作してみると、

合同会社タコスキングダム|タコキンのPスクール

というようにテキストは選択できないようになります。

代替手段としては、cssスタイルからテキストを選択することもできます。

先程の
onselectstartを使ったテキスト選択無効化はかなり以前から使われた手法で、現在はむしろcssファイルからテキスト選択禁止にするやり方を紹介されている方が多いようです。

例えば、ゲーム全体をテキスト禁止にしたい場合、ルートのスタイルファイルの
app.cssapp.scssに以下のように編集します。

            
            //...
#app {
    margin: 0 auto;
    padding: 12px;
    //👇追加
    user-select: none;
}
        
これもマウスによる要素中のテキストコンテンツをまるごと選択禁止にすることができます。

ブラウザによっては
onselectstartが時代遅れ(?)で効かない、ということもあるらしいので、現在はcssからuser-select: none;を指定するほうがより有効な対策なのかもしれません。

不安なら両方とも対策しておけば良いでしょう。


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

【Svelte.js入門】ReactやVueに挫折した人でも大丈夫!Svelteとfirebaseでシンプルアプリ開発

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

iframeのリソースキャッシュを無効化するメタタグ

こちらはSvelteアプリとは直接関係はないのですが、最終的にSvelteアプリをウェブページのiframeタグに埋め込んで公開したり、スタンドアロンなSPAで配布したりする場合に設定しておきたい設定です。

他のブログようなブラウザの画面の更新頻度がほとんどない用途とは異なり、HTMLブラウザゲームは非常に短い頻度で画像の生成や描画が切り替わります。

標準のブラウザでは、読み込んだ画像やテキストデータ等はキャッシュとして裏で取り溜めてくれる機能が働いてくれるので、一度読み込んだウェブページにあるリソースを再度サーバーから取得する手間が省けてより高速に描画することができます。

これがゲームアプリとなると、ブラウザ内部で常時計算処理されて生成される画像を高頻度で書き換えてブラウザに表示させる際に、キャッシュ機能が有効になっていることで描画が更新されないなど、おかしな挙動をする可能性があります。

ということで、iframeでHTMLアプリをキャッシュなしで動作させるときのおまじないとして、
index.htmlなどのメインファイルのメタタグに以下のような属性を追加しておきましょう。

            
            <!DOCTYPE html>
<html lang="ja">
    <head>
        ...略
        <!-- 👇以下の3つのタグでブラウザキャッシュを無効化 -->
        <meta http-equiv="Pragma" content="no-cache">
        <meta http-equiv="Cache-Control" content="no-cache">
        <meta http-equiv="Expires" content="0">
        ...略
    </head>
...略
        
ただし、jsファイルなどのスクリプトファイルはこの設定でもキャッシュしてしまうようなので、スクリプトの読み込みURL値にハッシュを組み込んだクエリ名を使うなどもうひと工夫必要となります。


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

【Svelte.js入門】ReactやVueに挫折した人でも大丈夫!Svelteとfirebaseでシンプルアプリ開発

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

まとめ

以上、今回はSvelteでゲームコントローラーを模したコンポーネントを作成して、スプライトを操作する方法を紹介してみました。

この記事で説明したポイントを再掲すると、

            
            1. 親子関係にあるSvelteコンポーネント間のイベントの渡し方
2. ゲーム画面上のコンポーネントを右クリック禁止にする
3. ゲーム画面上の文字テキストの選択を禁止にする
4. iframeのリソースキャッシュを無効化するメタタグの書き方
        
ということでした。

今回の記事が理解できれば、Svelteゲームの開発の基本的な流れって、
「コンポーネントを追加する→ハンドラを作成する」を地道に繰り返していくような作業になります。

Svelteに限らずJavascriptを使ったプロの方が教えてくれる動画講座・「
JavaScriptとHTMLで「レトロ風RPG」を作ろう 全コード解説
」なども非常に分かりやすい作成手順で解説されていますので、興味があれば講座を受講を考えてみてはいかがかと思います。

次回は、RPG風のレトロな会話ウィンドウの機能を追加してみようと予定しています。