LoginSignup
10
9

More than 3 years have passed since last update.

Scratch Extensionをつくってわかったこと

Last updated at Posted at 2019-12-23

はじめに

皆さんご存知ブロックプログラミングができるScratchですが、いつの間にかExtensionとして自分でブロックを作れるようになったようです 1。色々あってコーディングが苦手な友達の卒研の手伝いでScratch Extensionを書くことになったので、そこでわかったことをざっとまとめようと思います。Extensionついて、作り始めるための環境づくりや準備なんかについてはいろいろネットにありましたが、その他気になるところについてはドキュメントがあまり見つからなかったので、これが誰かの参考になれば幸いです。
また、自分もjavascriptに関しては素人も同然なので残念なコードや解釈があってもご容赦を。

公式ドキュメントと有志のみなさん

  • Scratch 3.0 Extensions
    Scratch Extensionを作るにあたってまず公式ドキュメントは読んでおいたほうが良いです。これを読むことで基本的に必要な要素や構造はわかります。

  • Scratch 3.0の拡張機能を作ってみよう
    有志のWikiでも基本的なExtensionの作り方がまとめられています。

これらから得られる情報のうち、ここからを読むのに必要な情報をまとめます。

  • Extensionとして追加するクラスのgetInfo()関数が返す情報で以下のことを定義する
    • 宣言するブロック
      • そのブロックの形
      • その呼び出し時に実行する関数
      • そのブロックに表示される文章
      • そのブロックがとる引数
    • 引数がつかうプルダウンメニュー
  • ブロックに表示される文字列の中で[]で囲んだ文字列は引数として扱われ、中の文字列はプロパティ名になる
  • 宣言された引数は、実行される関数の第1引数に含まれる

引数関連のサンプル
//こんなブロックを宣言したら
//前略
opcode: 'supaFunc',
blockType: BlockType.COMMAND,
text: '[TEXT]を追加', // <- TEXTのところが入力欄になる
arguments: {
    TEXT: {
        type: ArgumentType.STRING,
        defaultValue: 'ことば'
    },
//中略
supaFunc(arg){
    console.log(arg.TEXT); //ことばor記入された文字列
}

上のように書くとこんな見た目のブロックができる。

  • プルダウンメニューの内容はリストで書くが、"リストを返す関数の名前"を値とすることで動的なメニューにすることができる

コードや挙動からわかったこと

ここからはドキュメントに書かれていないが、既存のコードや実際の振る舞いを通してわかったことを書きます。

ブロックの実行部分の関数が受け取る引数は1つじゃない

ドキュメントからは「ブロックの処理時に呼ばれる関数が受け取るのは定義された引数たちを持つ第1引数(argsとよく書かれる)だけ」と受け取れますが、引数はそれだけではありません。標準で用意されているブロックのコードを見てみましょう。参照先はcontrolブロックの関数、条件分岐やループのブロックのなかの< >まで待つというフラグを監視して処理をsleepさせるブロックの処理です。

scratch-vm/src/blocks/scratch3_control.js
    waitUntil (args, util) {
        const condition = Cast.toBoolean(args.CONDITION);
        if (!condition) {
            util.yield();
        }
    }

出典 : scratch-vm scratch3_control.js l99-104

見たとおり2つ目の引数も受け取っていることがわかります。このutilはBlockUtilityクラスのオブジェクトで、「現在自分が実行されているスプライト」や「このブロックは子ブロックかどうか」などの情報を持っています。これをつかわないとIFやLOOPなどの処理を再現することができず、変数の情報なども得られません。

これを見つけるまではひたすらthis.runtimeを掘り進めて時間を無駄にしたのでめんどくさがらずコードを読むことは大切です

LOOPやIFなどとおなじC型ブロックの処理の再現にはutil.startBranch()

ExtensionではblockTypeをBlockType.CONDITIONALBlockType.LOOPとすることで既存のIFやLOOPと同じ形のブロックを作ることができます。しかし何も意識せずに実行関数を書いても中にいれたブロックは実行されません。再び標準ブロックのコードを見ます。参照先は同じくcontrolブロックの関数、< >まで繰り返すといういわゆるwhileを再現したブロックの処理です。

scratch-vm/src/blocks/scratch3_control.js
    repeatWhile (args, util) {
        const condition = Cast.toBoolean(args.CONDITION);
        // If the condition is true (repeat WHILE), start the branch.
        if (condition) {
            util.startBranch(1, true);
        }
    }

出典 : scratch-vm scratch3_control.js l118-125

このutil.startBranch()関数が中のブランチを実行するための関数です。自分でIFやLOOPに近いブロックを作るときはこれを参考にしましょう。おまけにこの関数の定義を見てみます。

scratch-vm/src/engine/block-utility.js
    /**
     * Start a branch in the current block.
     * @param {number} branchNum Which branch to step to (i.e., 1, 2).
     * @param {boolean} isLoop Whether this block is a loop.
     */
    startBranch (branchNum, isLoop) {
        this.sequencer.stepToBranch(this.thread, branchNum, isLoop);
    }

出典 : scratch-vm block-utility.js l99-104

第1引数のbranchNumは"何番目のブランチを実行するか"です。指定する際のブランチは先頭のものが1、そこから2,3...となります。ブランチの数はブロックのbranchCountというプロパティが参照されます。ここを10にすれば、ブロックを入れる箇所が10箇所のブロックができます。
第2引数のisLoopはループ処理なのかどうかです。ここをtrueにすると、このstartBranch()を呼び出した関数が再度実行されます。

ブロックから変数の一覧と値を取得する

普通の?Extensionでは変数の一覧なんて必要ないのかもしれませんが、今回は「複数のリストの要素の組み合せの全パターンのリストをつくるブロック」を作りたかったのでどうしても既存のリストと値を取得する必要がありました。ちなみに「リストのブロック」を受け取る様にすると、要素を全部くっつけた文字列が渡されるので気をつけてください。これも実行時引数utilをつかって実現できます。

    const Variable = require('../../engine/variable');

    sample (args,util){
        console.log(util.target.getAllVariableNamesInScopeByType(Variable.SCALAR_TYPE)) //変数の名前のArray
        console.log(util.target.getAllVariableNamesInScopeByType(Variable.LIST_TYPE)) //リストの名前のArray

        console.log(util.target.lookupVariableByNameAndType("hoge",Variable.SCALAR_TYPE)) //名前が"hoge"の変数のオブジェクト
        console.log(util.target.lookupVariableByNameAndType("fuga",Variable.LIST_TYPE)) //名前"fuga"のリストのオブジェクト
    }

また、今回の様にブロックが使うプルダウンメニューを作るための関数を作る場合は、ブロックから呼び出すわけではないためutilが引数にありません。そんなときはthis.runtimeが持つ関数を使って以下のようにします。基本的にはthis.runtime.getEditingTarget()を使って取得していくのが無難な気がします。

    const Variable = require('../../engine/variable');

    sample_nonBlock (){
        const nowSprite = this.runtime.getEditingTarget(); // このブロックがあるスプライト本体
        const stage = this.runtime.getTargetForStage(); // デフォルトで存在する「ステージ」のスプライト

        console.log(nowSprite.getAllVariableNamesInScopeByType(Variable.SCALAR_TYPE)) //グローバル変数とこのスプライトのローカル変数の名前のArray
        console.log(stage.getAllVariableNamesInScopeByType(Variable.LIST_TYPE)) //グローバルなリストの名前のArray

        console.log(stage.lookupVariableByNameAndType("hoge",Variable.SCALAR_TYPE)) //名前が"hoge"のグローバル変数のオブジェクト
        console.log(now_Sprite.lookupVariableByNameAndType("fuga",Variable.LIST_TYPE)) //名前"fuga"のローカルなリストのオブジェクト
    }

getAllVariableNamesInScopeByType()lookupVariableByNameAndType()Targetクラスにある関数です。他にも変数のidから変数本体を探すlookupVariableById()などもあるので変数絡みを触るなら必見です。

getAllVariableNamesInScopeByType()/lookupVariableByNameAndType()の定義
scratch-vm/src/engine/target.js
    /**
     * Look up a variable object by its name and variable type.
     * Search begins with local variables; then global variables if a local one
     * was not found.
     * @param {string} name Name of the variable.
     * @param {string} type Type of the variable. Defaults to Variable.SCALAR_TYPE.
     * @param {?bool} skipStage Optional flag to skip checking the stage
     * @return {?Variable} Variable object if found, or null if not.
     */
    lookupVariableByNameAndType (name, type, skipStage) {
        if (typeof name !== 'string') return;
        if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
        skipStage = skipStage || false;

        for (const varId in this.variables) {
            const currVar = this.variables[varId];
            if (currVar.name === name && currVar.type === type) {
                return currVar;
            }
        }

        if (!skipStage && this.runtime && !this.isStage) {
            const stage = this.runtime.getTargetForStage();
            if (stage) {
                for (const varId in stage.variables) {
                    const currVar = stage.variables[varId];
                    if (currVar.name === name && currVar.type === type) {
                        return currVar;
                    }
                }
            }
        }

        return null;
    }

出典 : scratch-vm target.js l201-235

scratch-vm/src/engine/target.js
    /**
     * Get the names of all the variables of the given type that are in scope for this target.
     * For targets that are not the stage, this includes any target-specific
     * variables as well as any stage variables unless the skipStage flag is true.
     * For the stage, this is all stage variables.
     * @param {string} type The variable type to search for; defaults to Variable.SCALAR_TYPE
     * @param {?bool} skipStage Optional flag to skip the stage.
     * @return {Array<string>} A list of variable names
     */
    getAllVariableNamesInScopeByType (type, skipStage) {
        if (typeof type !== 'string') type = Variable.SCALAR_TYPE;
        skipStage = skipStage || false;
        const targetVariables = Object.values(this.variables)
            .filter(v => v.type === type)
            .map(variable => variable.name);
        if (skipStage || this.isStage || !this.runtime) {
            return targetVariables;
        }
        const stage = this.runtime.getTargetForStage();
        const stageVariables = stage.getAllVariableNamesInScopeByType(type);
        return targetVariables.concat(stageVariables);
    }

出典 : scratch-vm target.js l479-500


これらの関数の戻り値はVariableクラスです。このクラスのオブジェクトはhoge.nameにブロックとしての表示名、hoge.valueに変数/リストとしての値を持っています。また、hoge.typeも持っており、これがVariable.LIST_TYPEだとhoge.valueの値がArrayオブジェクトになります。これを使うことでリストの中身をリストとして取得することができます。

変数やリストの値を書き換えたあと反映させる

Targetクラスのおかげで変数やリストを取得し、Variableクラスのオブジェクトのvalueを直接操作することで変数の値が変更できるようになりました。しかし、Scratchには変数やリストの中身を表示するモニターがあります。リストの内容を変更した場合、ただ変更しただけではこのモニターの表示が更新されません。
スクリーンショット 2019-12-23 23.46.59.png
反映のためには、値を編集したあとVariableクラスのオブジェクトの特定のプロパティを更新する必要があるようです。同じことをしている標準ブロックのコードを見ます。参照先は変数/リストを操作するブロックの関数、<リスト>に()を追加するというブロックの処理です。

scratch-vm/src/blocks/scratch3_data.js
    addToList (args, util) {
        const list = util.target.lookupOrCreateList(
            args.LIST.id, args.LIST.name);
        if (list.value.length < Scratch3DataBlocks.LIST_ITEM_LIMIT) {
            list.value.push(args.ITEM);
            list._monitorUpToDate = false;
        }
    }

出典 : scratch-vm scratch3_data.js l126-133

ここにある通り、typeがVariable.LIST_TYPEVariableオブジェクトの_monitorUpToDatefalseにすることで変更後の値をモニターに反映することができます。

ブロックの引数名が特定の文字列のとき動きがおかしい
→ 名前と値が異なるプルダウンメニューを作れる

これは偶然の産物というか気づきなのですが、blockのargumentsの名前を特定の文字列にすると引数の挙動が変わります。
最初はリストの一覧のメニューの引数の名前をLISTにしていたのがきっかけでした。

            text: '[LIST]をパターンに追加',
            arguments: {
                LIST:{
                    type: ArgumentType.STRING,
                    menu: 'listMenu'
                },
            }

こういうブロックを宣言したところ、普段はargs.TEXT = "文字列"となるところをargs.LIST = {/*object*/}が戻りました。
スクリーンショット 2019-12-24 0.17.30.png
明らかにLISTという名前の時のみこの現象が起こります。コードを調べに調べた結果、ブロックを実行する際に使われるExecuteクラスにこんな記述を見つけました。

scratch-vm/src/engine/execute.js
        // Store the static fields onto _argValues.
        for (const fieldName in fields) {
            if (
                fieldName === 'VARIABLE' ||
                fieldName === 'LIST' ||
                fieldName === 'BROADCAST_OPTION'
            ) {
                this._argValues[fieldName] = {
                    id: fields[fieldName].id,
                    name: fields[fieldName].value
                };
            } else {
                this._argValues[fieldName] = fields[fieldName].value;
            }
        }

出典 : scratch-vm execute.js l305-319

どうやら引数の名前が'VARIABLE'or'LIST'or'BROADCAST_OPTION'のときのみ、引数のアイテムがObjectの扱いをされるようです。しかし、プルダウンメニュー本体のMenuのitemはドキュメントによるとstringのArrayのはずなのです。さらに調べるとExtension用のライブラリの一つextension-manager.jsにこんな記述がありました。

scratch-vm/src/extension-support/extension-manager.js
    /**
     * Fetch the items for a particular extension menu, providing the target ID for context.
     * @param {object} extensionObject - the extension object providing the menu.
     * @param {string} menuItemFunctionName - the name of the menu function to call.
     * @returns {Array} menu items ready for scratch-blocks.
     * @private
     */
    _getExtensionMenuItems (extensionObject, menuItemFunctionName) {
        // Fetch the items appropriate for the target currently being edited. This assumes that menus only
        // collect items when opened by the user while editing a particular target.
        const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage();
        const editingTargetID = editingTarget ? editingTarget.id : null;
        const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget);

        // TODO: Fix this to use dispatch.call when extensions are running in workers.
        const menuFunc = extensionObject[menuItemFunctionName];
        const menuItems = menuFunc.call(extensionObject, editingTargetID).map(
            item => {
                item = maybeFormatMessage(item, extensionMessageContext);
                switch (typeof item) {
                case 'object':
                    return [
                        maybeFormatMessage(item.text, extensionMessageContext),
                        item.value
                    ];
                case 'string':
                    return [item, item];
                default:
                    return item;
                }
            });

        if (!menuItems || menuItems.length < 1) {
            throw new Error(`Extension menu returned no items: ${menuItemFunctionName}`);
        }
        return menuItems;
    }

出典 : scratch-vm extension-manager.js l335-371

なんとMenuのitemにはobjectが許されるようです。しかもObjectの特定のプロパティ、具体的にはtextvalueのみが保持されるようです。

検証の結果がこれです。上からMenuの項目、名前がLISTなどじゃない引数のときの値、名前がLISTのときの値をそれぞれ出力したものです。
スクリーンショット 2019-12-24 1.00.16.png

Menuは

item : [
    {text: "表示される名前", value: "LISTなど以外で保持される値"},
    {text: "表示される名前", value: "LISTなど以外で保持される値"},
    ...
]

という形で書けば項目名以外の情報を持ったままプルダウンメニューになれることがわかります。また引数の名前が'VARIABLE'or'LIST'or'BROADCAST_OPTION'以外のときはvalueの方の値がargsに入り関数に渡されます。一方名前が'LIST'などのときはargs.LISTなどはオブジェクトになり、args.LIST.idにvalueが、args.LIST.nameにtextが入ることがわかります。

これを使うことことで項目名にある表示される項目名と関数に渡される値が異なるプルダウンメニューを作ることができます。

未だにわからないこと

散々やってもわからなかったことを書きます。

変数やリストの作成後即時反映

Targetクラスを読んだ方はお気づきかもしれませんが、このクラスはcreateVariable()関数を持っています。これは名前の通り変数を作る事ができる関数です。これを内包する形で別のクラスにも変数を作成する関数を持っているものがあります。しかし、実体としての変数やリストは作れるのですが右の全ブロック一覧に反映されません。Runtimeクラスにプロジェクトの変更を知らせるものらしき関数もあるのですが、実行してもとくに変化はありませんでした。
一応右のブロック一覧のタブを変更して戻ってくると反映されます。

タブ移動で変数が出現する様子
ezgif.com-resize.gif

変数ブロックから変数自体の名前やidを取得する

ブロックから変数の一覧と値を取得するの冒頭で触れたように、ブロックの引数として変数やリストのブロックを受け取ったときはその値か要素をすべてスペースなしで結合した文字列が渡されます。
スクリーンショット 2019-12-24 1.41.54.png
ここからどうやってもそのブロックが示す変数やリストに到達できませんでした。標準ブロックが同じことをしていないところから、おそらく仕様上できないのではないかと考えています。

終わりに

ScratchのExtension機能はまだβ版らしく、今後本実装に伴って公式非公式問わずドキュメントがもっと充実していくだろうと思います。これはそれまでのつなぎみたいなもんです。もし読んだ方で、「こうやったら変数ブロックから変数の名前とれたよ」とかあったらぜひ教えて下さい。誰かの卒研に間に合えばとても喜びます。

個人的にはGoogleのカード表示以来久々にjavascriptに触りましたが、なれていないからかやっぱり細かいところで引っかかります。型とか...typeofしてもobjectのClassは教えてくれないんですね。しかし今回の1件で、既存のコードを読んで処理内容などを類推するの、謎解きみたいでたのしいなと思えました。またやりたいかというと微妙です。
最後に、javascriptや謎の処理の解読にアドバイスをくれたQiitadonの方々、いつもありがとうございます。毎度助かっています。自分も提供できるほど知識が増えるといいなぁ…


  1. ただし現状ではエクスポート/インポートの機能がないのでローカルに立てたものでのみ使えます。 

10
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9