【Scratch拡張機能をJavascriptで自作】RPG風の吹き出しを表示する拡張機能


※ 当ページには【広告/PR】を含む場合があります。
2021/02/10
【Scratch拡張機能をJavascriptで自作】svg画像からRPG風のマップを生成する拡張機能を作る〜拡張機能の実装③
【Scratch拡張機能をJavascriptで自作】SVG画像からスプライトをアニメーション的に動かす拡張機能の作成方法

最近では不定期ながら著者のScratchの腕試しにと、ちょこちょこ業務の空き時間を見ながらレトロなRPG風〇〇を作ってみたりしております。

今回はセリフ吹き出し用の固定ウィンドウを表示する拡張機能を作成しましたので、デモプログラムと併せてご紹介します。


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

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

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

RPG風のセリフウインドウとは?

著者がファミコン世代の人間なので、既にRPG用のセリフ・ウインドウと言われてピンと来ないヤングなデジタル世代の方も増えてきているとは想像します。

この記事でいう往年の2Dスクロール型のRPG風のセリフウインドウとは、他のキャラクターとの会話や戦闘画面などのときに概ね画面の底に表示されてくる文字を表示してくれる枠のことです。

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

上の図のように、画面に割り込む形でメッセージを表示させてくれるような、まだ画面の小さかった据え置きのゲーム機では慣れ親しまれた機能です。

なお、今回作成した拡張機能を利用したデモプログラムはトップページの
necoquest(RPG風マップのデモ)からでも動作確認できます。(現状、拡張機能のテストの名目で作成しているプログラムですので、まともなゲームと呼ぶには程遠くはあります。ご了承ください。)


合同会社タコスキングダム|タコキンのPスクール【Pschool厳選】Scratchをしっかり学ぶためのオススメ書籍まとめ

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

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

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

意外と面倒な文字を含む画像の表示

Scratchプログラミングに親しんでいる方ほどその煩わしさを身にしみて分かってはいらっしゃるかと思います。

拡張機能なしでRPG風のセリフ用ウィンドウを作成する方法は
こちらのサイト(参考先: RPGの会話ウィンドウを実装しよう)にあるような、スプライトのコスチュームに文字フォントを割り当てる方法しかないので、まず最初に文字の画像を用意する必要があります。

以前この方法を試すために、一般のフォントファイルからフォント画像をゴッソリ引き抜く方法を検証してみました。

合同会社タコスキングダム|タコキンのPスクール
[Scratch上級者講座] 任意のフォントファイル(ttf)から個別のsvg画像に変換してコスチュームとして使う方法

一般的なttfファイルをScratchプロジェクトに導入するための自作拡張機能について解説します。

結果としてはなんとかフォント文字を画像として取り出せたものの、シェルスクリプトの力でねじ伏せた感があり、一般のScratchユーザーからの知識を逸脱していたのも事実です。(このため
「上級者向けの記事」とさせていただきましたが...)

そして今回のテーマというと、RPG風の吹き出しが出せる拡張機能の作成のお話です。

予め触れておきますが、拡張機能の作成はscratch-guiなどのソースコードを弄る必要があります。

webpackやReact.jsなどのそれなりのフロントエンドの知識が必要になりますので、今回のお題としては上級者より上の開発者向けの技術情報になりますことをご了承ください。


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

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

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

ズバッといきなりソースコード

このサイトでも何度も拡張機能の自作のネタを取り扱ってきましたので、作成手順の詳細は他の記事のほうをご参照いただくとして、今回はエクステンションの本体コード部分だけを紹介しておきます。

以下、今回のscratch-vmに設定する拡張機能(index.js)です。

            
            const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const TargetType = require('../../extension-support/target-type');
const Cast = require('../../util/cast');
const formatMessage = require('format-message');
const MathUtil = require('../../util/math-util');
const log = require('../../util/log');
const StageLayering = require('../../engine/stage-layering');

class Scratch3MessageWindow {
    constructor (runtime) {
        this._extVersion = '0.0.1';
        this.runtime = runtime;
        this._fontDrawableId = -1;
        this._fontSkinId = -1;
        this.emptyImg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0" viewBox="0 0 0 0"></svg>';
    }

    getInfo () {
        return {
            id: 'messagewindow',
            name: formatMessage({
                id: 'messagewindow.categoryName',
                default: 'MessageWindow',
                description: 'Label for messagewindow extension category'
            }),
            blocks: [
                {
                    opcode: 'initSkin',
                    blockType: BlockType.COMMAND
                },
                {
                    opcode: 'updateMessage',
                    blockType: BlockType.COMMAND,
                    text: formatMessage({
                        id: 'messagewindow.updateMessage',
                        default: 'stamp ( [STR] ) x [SCALE]',
                        description: 'render current costume on the background'
                    }),
                    arguments: {
                        STR: {
                            type: ArgumentType.STRING,
                            defaultValue: ''
                        },
                        SCALE: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 100
                        }
                    }
                },
                {
                    opcode: 'goMapToBack',
                    blockType: BlockType.COMMAND
                },
                {
                    opcode: 'goMapToFront',
                    blockType: BlockType.COMMAND
                },
                {
                    opcode: 'clearSkin',
                    blockType: BlockType.COMMAND
                },
                {
                    opcode: 'getVersion',
                    text: 'Version Info',
                    blockType: BlockType.REPORTER
                }
            ],
            menus: {}
        };
    }

    initSkin() {
        if (this._fontSkinId < 0 && this.runtime.renderer) {
            const skinId = this.runtime.renderer.createSVGSkin(this.emptyImg);
            this._fontSkinId = skinId;
            this._fontDrawableId = this.runtime.renderer.createDrawable(StageLayering.SPRITE_LAYER);
            this.runtime.renderer.updateDrawableProperties(this._fontDrawableId, {skinId: this._fontSkinId});
        }
    }

    updateMessage(args) {
        const text_ = Cast.toString(args.STR);
        const scale_ = Cast.toString(args.SCALE);
        if (this._fontSkinId >= 0) {
            const tArr = this._textSlice(text_);
            const windowOrigin = [2, 258];
            const fontSize_ = 14;
            const fontSvg = `<svg width="480px" height="360px" version="1.1" viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg">
            <g stroke-width="4">
            <rect transform="translate(2,3)" x="${windowOrigin[0]}" y="${windowOrigin[1]}" width="476" height="100" rx="15" ry="15" opacity=".25" stroke="#000"/>
            <rect x="${windowOrigin[0]}" y="${windowOrigin[1]}" width="476" height="100" rx="15" ry="15" fill="#fff" stroke="#000080"/>
            <text fill="#0000ff" font-family="sans-serif" font-size="${fontSize_}px" letter-spacing="0px" word-spacing="0px" xml:space="preserve">
                <tspan x="${windowOrigin[0] + 12}" y="${windowOrigin[1] + 25}">${tArr[0]}</tspan>
            </text>
            <text fill="#0000ff" font-family="sans-serif" font-size="${fontSize_}px" letter-spacing="0px" word-spacing="0px" xml:space="preserve">
                <tspan x="${windowOrigin[0] + 12}" y="${windowOrigin[1] + 55}">${tArr[1]}</tspan>
            </text>
            <text fill="#0000ff" font-family="sans-serif" font-size="${fontSize_}px" letter-spacing="0px" word-spacing="0px" xml:space="preserve">
                <tspan x="${windowOrigin[0] + 12}" y="${windowOrigin[1] + 85}">${tArr[2]}</tspan>
            </text>
            </g>
            </svg>`;
            this.runtime.renderer.updateSVGSkin(this._fontSkinId, fontSvg);
        }
    }

    clearSkin() {
        if (this._fontSkinId >= 0) {
            this.runtime.renderer.updateSVGSkin(this._fontSkinId, this.emptyImg);
        }
    }

    goMapToBack() {
        if (this._fontDrawableId >= 0 && this._fontSkinId >= 0) {
            this.runtime.renderer.setDrawableOrder(this._fontDrawableId, 1, StageLayering.SPRITE_LAYER);
        }
    }

    goMapToFront() {
        if (this._fontDrawableId >= 0 && this._fontSkinId >= 0) {
            this.runtime.renderer.setDrawableOrder(this._fontDrawableId, Infinity, StageLayering.SPRITE_LAYER);
        }
    }

    getVersion(args) {
        return this._extVersion;
    }

    _textSlice(text = '') {
        const splitText = [];
        const onelineLimit = 32;
        const sanitizedText = text.replace(/\s/g,'');
        const textLen = sanitizedText.length;
        if (sanitizedText) {
            const firstRow = sanitizedText.slice(0, onelineLimit);
            if (firstRow) {
                splitText.push(firstRow);
            } else {
                splitText.push('');
            }
            if (textLen > onelineLimit) {
                const secondRow = sanitizedText.slice(onelineLimit, onelineLimit * 2);
                splitText.push(secondRow);
            } else {
                splitText.push('');
            }
            if (textLen > 2 * onelineLimit) {
                const thirdRow = sanitizedText.slice(onelineLimit * 2, onelineLimit * 3);
                splitText.push(thirdRow);
            } else {
                splitText.push('');
            }
            return splitText;
        } else {
            return ['', '', ''];
        }
    }
}

module.exports = Scratch3MessageWindow;
        
このソースコードの機能を一つ一つ紐解きながら解説するととても長くなりますのでここでは全て解説できません。

一から詳しく理解されたい開発者の方は
scratch-renderの各メソッドの機能を良く理解しておく必要があります。

以前、scratch-renderに関して特集した記事を以下のリンクにありますので、併せてお読みください。

合同会社タコスキングダム|タコキンのPスクール
Scratchのディープな使い方〜レンダラー(scratch-render)だけを動かしてみる

Scratch-guiから、文字もしくは文字列からユニコード(16進数)に変換するための拡張機能を自作します。

この拡張機能をscratch-gui上で動作するようにすると、以下のようなブロックでGUI上に展開できます。

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

実際に動かして確認するには、本サイトトップページの
RPG風マップのデモプログラムから確認できます。

まだまだRPGと呼べるには程遠いのですが、拡張機能の持つScratchゲームの可能性には十分触れていただけたのではないかとと思います。


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

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

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

まとめ

今回はRPG風のセリフ用ウィンドウを操作するようの拡張機能のプロトタイプ作成に関してご紹介してみました。

不定期ではありますが、
こちらの記事内のデモプログラムでまだまだ今後改良を重ねていきます。

合同会社タコスキングダム|タコキンのPスクール
【ミ二ゲーム開発・リリースノート】とても狭い猫の冒険〜『猫くえすと』まとめ

サイト内で不定期ながら開発中のスクラッチ・ミニRPG『猫くえすと』のリリースノートです。

その度にどのような拡張機能を盛り込んでいくのかをこのような記事で紹介することもあるかと思います。

トップのサンプルゲームを動かしていただくだけでも有り難いのですが、やはり実際にご自身の手でScratchの拡張機能を自作して動かしてみるのが一番のプログラミング上達の近道ではないかと考えます。