メインコンテンツへスキップ
見出し画像

【cluster】外部通信するクラフトアイテムの開発ノウハウ(2)・・・具体例編

    第2回、具体例編です。

    ※ プログラミング初心者向けの解説ではなく、開発経験がある方に向けた実例の紹介記事です。

    (N&C's べいべー誕生を勝手に祝って贈るシリーズ記事です)

    実際のアイテムの例:
    『名と言葉を刻める石碑 (CraftMilestone)』

    このアイテムのソースコードを晒してみようと思います。
    ざっくり何が必要なのか参考にしていただければと思います。

    クラフトアイテム側のスクリプト

    client/src/craft_milestone.ts

    主要部分のソースコードです。
    生の Javascrpt ではなく TypeScript で記述しています。

    import $$, {PlayerHandle$, SubNode$, TextInputStatus } from "./_cluster_extended"
    
    const states = {
        owner: {
            set: (v: string) => { $.state.owner = v },
            get: () => $.state.owner as string,
        },
        pass: {
            set: (v: string) => { $.state.pass = v },
            get: () => $.state.pass as string,
        },
        players: {
            set: (v: Record<string, PlayerHandle$>) => { $.state.players = v },
            get: () => $.state.players as Record<string, PlayerHandle$>,
            has: (v: PlayerHandle$) => v.idfc in states.players.get(),
            put: (v: PlayerHandle$) => {
                const p = states.players.get()
                p[v.idfc] = v
                states.players.set(p)
            },
            del: (v: PlayerHandle$) => {
                const p = states.players.get()
                delete p[v.idfc]
                states.players.set(p)
            },
        },
        text: {
            set: (v: string) => { $.state.text = v },
            get: () => $.state.text as string,
        },
        timestamp: {
            set: (v: number) => { $.state.time = v },
            get: () => $.state.time as number,
        },
    }
    
    const META_SET_PASS = 'SET_TEXT'
    const META_MILESTONE = 'MILESTONE'
    const CMD = 'craftMilestone'
    
    $$.onStart(() => {
        states.owner.set('')
        states.pass.set('')
        states.players.set({})
        states.text.set('設定してください')
        states.timestamp.set(0)
        updateView()
    })
    
    const updateView = () => {
        $$.subNode('Text').setText(states.text.get())
    }
    
    const needSetup = () => {
        return states.owner.get() === '' || states.pass.get() === ''
    }
    
    $.onUpdate((deltaTime) => {
        if (needSetup()) {
            return
        }
        const now = Date.now()
        if (states.timestamp.get() < now - 1000 * 3600) {
            post()
            states.timestamp.set(now)
        }
    })
    
    $.onInteract(player => {
        const _player = player as PlayerHandle$
        if (needSetup()) {
            states.owner.set(_player.idfc)
            _player.requestTextInput(META_SET_PASS, '識別用の文字列(最大16文字)を入力してください')
        }
        else if (!states.players.has(_player)) {
            states.players.put(_player)
            _player.requestTextInput(_player.idfc, 'お名前(' + _player.userDisplayName + ')と共に刻む言葉(最大16文字)を入力してください。')
        }
    })
    
    $$.onTextInput((text: string, meta: string, status: TextInputStatus) => {
        if (meta === META_SET_PASS) {
            if (status === TextInputStatus.Success && text.length > 0 && text.length <= 16) {
                states.pass.set(text)
                post()
            }
            return
        }
        if (meta in states.players.get()) {
            const player = states.players.get()[meta]
            if (status === TextInputStatus.Success) {
                post({
                    idfc: player.idfc,
                    name: player.userDisplayName,
                    text: text.slice(0, 16),
                })
            } else {
                states.players.del(player)
            }
        }
    })
    
    const post = (input: undefined|any = undefined) => {
        const request = JSON.stringify({
            cmd: CMD,
            [META_MILESTONE]: {
                owner: states.owner.get(),
                pass: states.pass.get(),
                input: input,
            },
        })
        $$.callExternal(request, META_MILESTONE)
    }
    
    $$.onExternalCallEnd((response, meta, errorReason) => {
        if (meta === META_MILESTONE) {
            if (errorReason) {
                $.log(errorReason)
            }
            if (response) {
                const r = JSON.parse(response) as errorResponse|successResponse
                if (r.result === false) {
                    $.log(r.message)
                } else {
                    const lines: string[] = ["踏破者よ、汝の名と言葉を刻め。\n"]
                    for (const item of r.items) {
                        const date = new Date(item.time)
                        const line = date.toLocaleString() + "\t" + item.name + "\n「" + item.text + '」'
                        lines.push(line)
                    }
                    states.text.set(lines.join("\n"))
                    states.timestamp.set(Date.now())
                    updateView()
                }
            }
        }
    })
    
    type errorResponse = {
        result: false
        message: string
    }
    type successResponse = {
        result: true
        items: item[]
    }
    type item = {
        time: number
        idfc: string
        name: string
        text: string
    }
    

    そこそこ複雑なアイテムのスクリプトを複数書いてきた経験から自然に培ったノウハウとして一番大きいのは、『$.state.* を直接使わない。代わりに型を制約したセッター/ゲッターを使う』というポリシーです。

    経験的に、不用意なバグの大部分は型チェックで予め(書いている途中で)検出して防ぐことができますし、型の支援が効いていれば IDE の支援によって開発は随分ラクになるのですが、こと、アイテムのスクリプトでは、永続化したい値を Sendable 型として $.state.* に格納する必要があるので、ここが一番のネックになります。

    名前をタイプミスしている(例えば上のコードの中で $.state.player と書いて undefined になっちゃう)のに気づけなくて時間が溶けた…などという無駄なことも当初は頻発しがちでしたが、このポリシーを徹底するようにしてから、スクリプトアイテム開発の精神的な衛生環境が随分改善された・・・と思っています。

    client/src/_cluster_extended.ts

    公式に配布されている型定義ファイルの更新が遅れているため、公式ドキュメントとの差分を補うためにでっち上げたものです。(多言語対応などの準備のためにメンテナンスがしばらく滞っているとのこと。メンテナンスが再開されると嬉しいですね。)

    /// <reference path="../node_modules/@clustervr/cluster-script-types/index.d.ts" />
    
    interface ClusterScriptExtended extends ClusterScript {
        subNode(name: string): SubNode$
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#onExternalCallEnd
        onExternalCallEnd(callback: ((response: null | string, meta: string, errorReason: null | string) => void)): void
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#callExternal
        callExternal(request: string, meta: string): void
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#onTextInput
        onTextInput(callback: ((text: string, meta: string, status: TextInputStatus) => void)): void
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#onStart
        onStart(callback: (() => void)): void
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#computeSendableSize
        computeSendableSize(arg: Sendable): number
        // https://docs.cluster.mu/script/interfaces/ClusterScript.html#material
        material(materialId: string): MaterialHandle$
    }
    
    enum TextInputStatus {
        Success = 1,
        Busy,
        Refused,
    }
    
    interface MaterialHandle$ {
        setBaseColor(r: number, g: number, b: number, a: number): void
        setEmissionColor(r: number, g: number, b: number, a: number): void
    }
    
    interface SubNode$ extends SubNode {
        // https://note.com/cluster_official/n/nfb2ead17b6b4#1b6e94fe-52e6-454a-b07c-e5af4b2a70a4
        setText(text: string): void
        // setTextAlignment(alignment: TextAlignment): void
        // setTextAnchor(anchor: TextAnchor): void
        setTextColor(r: number, g: number, b: number, a: number): void
        setTextSize(size: number): void
    }
    
    interface PlayerHandle$ extends PlayerHandle {
        requestTextInput(meta: string, title: string): void
        // https://docs.cluster.mu/script/interfaces/PlayerHandle.html#idfc
        idfc: string
        // https://docs.cluster.mu/script/interfaces/PlayerHandle.html#userDisplayName
        userDisplayName: string
    }
    
    interface ItemHandle$ extends ItemHandle {
    }
    
    export {SubNode$, PlayerHandle$, TextInputStatus};
    
    const $$ = $ as ClusterScriptExtended
    export default $$
    

    トランスパイルのための設定ファイル

    ここから下の3つのファイルは、TypeScript のソースコードを Javascript に変換(トランスパイル)するために必要な設定ファイルです。

    これらを準備した client ディレクトリでコマンド `npm run build` を実行すると、エラーが無ければ client/ClusterScripts/CraftMilestone.js が生成されるので、それを Unity 上で ScriptableItem のソースコードアセットに設定すればOKです。

    client/package.json

    {
      "scripts": {
        "build": "webpack",
        "watch": "webpack -w"
      },
      "devDependencies": {
        "@clustervr/cluster-script-types": "^1.2.3",
        "ts-loader": "^9.5.1",
        "typescript": "^5.3.3",
        "webpack": "^5.89.0",
        "webpack-cli": "^5.1.4"
      }
    }
    

    client/tsconfig.json

    {
      "compilerOptions": {
        // ソースマップを有効化
        "sourceMap": true,
        // TSはECMAScript 5に変換
        "target": "ES5",
        // TSのモジュールはES Modulesとして出力
        "module": "ES2015",
        // 厳密モードとして設定
        "strict": true
      }
    }

    client/webpack.config.js

    const TerserPlugin = require("terser-webpack-plugin");
    
    module.exports = {
        // モード値を production に設定すると最適化された状態で、
        // development に設定するとソースマップ有効でJSファイルが出力される
        mode: 'production',
    
        // メインとなるJavaScriptファイル(エントリーポイント)
        entry: {
            CraftMilestone: './src/craft_milestone.ts',
        },
        output: {
            filename: '[name].js',
            path: `${__dirname}/ClusterScripts`,
        },
        optimization: {
            minimize: true,
            minimizer: [new TerserPlugin({
                terserOptions: {
                    compress: true,
                    ecma: 2015,
                    mangle: true,
                    toplevel: true,
                }
            })],
        },
    
        module: {
            rules: [
                {
                    // 拡張子 .ts の場合
                    test: /\.ts$/,
                    // TypeScript をコンパイルする
                    use: 'ts-loader',
                },
            ],
        },
        // import 文で .ts ファイルを解決するため
        // これを定義しないと import 文で拡張子を書く必要が生まれる。
        // フロントエンドの開発では拡張子を省略することが多いので、
        // 記載したほうがトラブルに巻き込まれにくい。
        resolve: {
            // 拡張子を配列で指定
            extensions: [
                '.ts', '.js',
            ],
        },
        experiments: {
            //全体を囲う即時実行関数式が消える。
            outputModule: true
        }
    };
    

    サーバー側のスクリプト(Lambda関数)

    server/src/beta.ts

    外部通信機能で呼び出された時に最初に実行されるプログラムです。
    (名前がbetaなのは、かつて外部通信が正式昇格前のベータ機能だった名残なので、今となっては router.ts とかに変えるほうが良いです。)

    import {APIGatewayProxyCallbackV2, APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context} from 'aws-lambda'
    import {debug} from "./beta/lib/env";
    import craftMilestone from "./beta/craftMilestone";
    // ...
    
    export async function handler(event: APIGatewayProxyEventV2, context: Context, callback: APIGatewayProxyCallbackV2)
        : Promise<APIGatewayProxyResultV2> {
    
        if (debug) {
            console.info('event:', event);
        }
    
        const body = JSON.parse(event.body!)
        if (debug) {
            console.info('body:', body)
        }
        const data = JSON.parse(body.request)
    
        if ('cmd' in data && data.cmd === 'craftMilestone') {
            return await craftMilestone(event, data)
        }
        // ....
    }
    

    複数のアイテムから呼び出されるので、どのアイテムからのリクエストなのかを判別して、それに対応したプログラムを呼び出す「ルーティング」の役割を担当します。(….で省略した部分に、他のアイテム用の処理を呼び出すコードが並んでいます。)

    server/src/beta/craftMilestone.ts

    クラフトアイテム「名と言葉を刻める石板」からのリクエストを処理するプログラムです。

    import {APIGatewayProxyEventV2} from "aws-lambda";
    import {callExternalResponse, craftMilestoneTableName, debug, region} from "./lib/env";
    import {DynamoDBClient, PutItemCommand, QueryCommand} from "@aws-sdk/client-dynamodb";
    
    const META_MILESTONE = 'MILESTONE'
    
    type MilestoneData = {
        owner: string
        pass: string
        input: undefined | {
            idfc: string
            name: string
            text: string
        }
    }
    
    export default async function craftMilestone(event: APIGatewayProxyEventV2, data: any) {
    
        if (data[META_MILESTONE]) {
            return await processMilestone(data[META_MILESTONE] as MilestoneData)
        }
        return errorResponse(META_MILESTONE + ' undefined')
    }
    
    const errorResponse = (message: string) => {
        return callExternalResponse(200, JSON.stringify({result: false, message: message}));
    }
    
    function newClient() {
        return new DynamoDBClient({
            region: region,
        })
    }
    
    async function processMilestone(data: MilestoneData) {
        if (debug) {
            console.info('data:', data)
        }
    
        const client = newClient()
    
        const partitionKey = data.owner + '_' + data.pass
    
        if (data.input) {
            const now = Date.now()
            const sortKey = now + '_' + data.input.idfc
    
            const output = await client.send(new PutItemCommand({
                TableName: craftMilestoneTableName,
                Item: {
                    PartitionKey: {S: partitionKey},
                    SortKey: {S: sortKey},
                    Time: {N: String(now)},
                    Idfc: {S: data.input.idfc},
                    Name: {S: data.input.name},
                    Text: {S: data.input.text},
                },
            }))
            if (debug) {
                console.info('output:', output)
            }
        }
    
        const output = await client.send(new QueryCommand({
            TableName: craftMilestoneTableName,
            KeyConditionExpression: "PartitionKey = :pk",
            ExpressionAttributeValues: {":pk": {S: partitionKey}},
            ScanIndexForward: false,
            ReturnConsumedCapacity: "TOTAL",
            Limit: 10,
        }))
        if (debug) {
            console.info('output:', output)
        }
        const items = []
        if (output.Items) {
            for (const item of output.Items) {
                items.push({
                    time: Number(item.Time.N),
                    idfc: item.Idfc.S,
                    name: item.Name.S,
                    text: item.Text.S,
                })
            }
        }
    
        return callExternalResponse(200, JSON.stringify({
            result: true,
            items: items,
        }))
    }
    

    受け取ったデータがあればデータベースに書き込んで、次に最新10件のデータをデータベースから読み込んで応答に入れて返す、という流れになっています。

    データの格納先には DynamoDB というデータベースを使っています。
    (ちなみに、データベースというと RDB じゃないの?と思われた方もいらっしゃるかもしれませんが、Lambda関数からRDBを使うのは、特別な場合を除いてご法度だったりします。この説明は長くなりすぎるので割愛します。)

    server/src/lib/env.ts

    // 環境変数
    const region = process.env.DDB_REGION
    const craftMilestoneTableName = process.env.DDB_TABLE_CRAFT_MILESTONE!
    const debug = process.env.DEBUG === '1'
    const verify = process.env.VERIFY_TOKEN!
    
    function callExternalResponse(statusCode: number, response: string) {
        return {
            statusCode: statusCode,
            body: JSON.stringify({
                verify: verify,
                response: response,
            }),
        }
    }
    
    export {region, tableName, craftMilestoneTableName, debug, verify, callExternalResponse}
    

    トランスパイルのための設定ファイル

    ここから下の3つのファイルは、TypeScript のソースコードを Javascript に変換(トランスパイル)するために必要な設定ファイルです。

    これらを準備した server ディレクトリで `npm run build` を実行すると、プログラムのソースコードにエラーが無ければ server/dist/beta/index.js が生成されます。

    server/package.json

    {
      "name": "panda-tools",
      "version": "1.0.0",
      "description": "",
      "private": true,
      "directories": {
        "lib": "lib"
      },
      "scripts": {
        "watch": "nodemon -e ts --watch 'src/**/*.ts' --exec 'npm run build'",
        "build": "webpack --mode production --config webpack.config.ts"
      },
      "devDependencies": {
        "@aws-sdk/client-dynamodb": "^3.549.0",
        "@aws-sdk/client-s3": "^3.549.0",
        "@aws-sdk/lib-dynamodb": "^3.549.0",
        "@aws-sdk/s3-request-presigner": "^3.549.0",
        "@types/aws-lambda": "^8.10.97",
        "@types/totp-generator": "^0.0.8",
        "@types/webpack": "^5.28.0",
        "encoding": "^0.1.13",
        "jimp": "^0.22.10",
        "nodemon": "^2.0.16",
        "terser-webpack-plugin": "^5.3.7",
        "ts-loader": "^9.3.0",
        "ts-node": "^10.7.0",
        "typescript": "^4.6.4",
        "webpack": "^5.91.0",
        "webpack-cli": "^5.1.4",
        "webpack-node-externals": "^3.0.0"
      }
    }
    

    server/tsconfig.json

    {
      "compilerOptions": {
        "target": "es5",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
        "module": "commonjs",                                /* Specify what module code is generated. */
        "outDir": "./dist",                                   /* Specify an output folder for all emitted files. */
        "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
        "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
        "strict": true,                                      /* Enable all strict type-checking options. */
        "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
      },
      "include": [
        "./src/**/*.ts"
      ]
    }
    

    server/webpack.config.ts

    import {Configuration, IgnorePlugin} from 'webpack'
    
    const config: Configuration = {
        target: 'node',
        optimization: {
            minimize: false,
        },
        entry: {
            beta: {
                import: './src/handlers/beta.ts',
                filename: "beta/index.js",
            },
        },
        output: {
            path: `${__dirname}/dist`,
            libraryTarget: 'commonjs2',
            asyncChunks: false,
        },
        externals: ['aws-sdk'],
        module: {
            rules: [
                { test: /\.ts$/, use: [ { loader: 'ts-loader' } ]}
            ],
        },
        resolve: {
            extensions: ['.js', '.ts'],
            alias: {
                'node-fetch': `${__dirname}/node_modules/node-fetch/lib/index.js`,
            },
        },
        plugins: [
            new IgnorePlugin({
                resourceRegExp: /^cardinal$/,
                contextRegExp: /./,
            }),
        ]
    };
    
    export default config;
    

    SAM のための設定ファイル

    ここから下の2つのファイルは、SAM (Serverless Application Model) を使ってサーバー用プログラムが動くようにするために必要な設定ファイルです。

    server/template.yml

    AWSTemplateFormatVersion: '2010-09-09'
    Transform: AWS::Serverless-2016-10-31
    Description: >
      SAM Template for panda-tools
    
    Parameters:
      DdbRegion:
        Type: String
        Default: ap-northeast-1
      DdbCraftMilestoneTable:
        Type: String
        Default: CraftMilestone
      VerifyToken:
        Type: String
        Default: TO_BE_CONFIGURED
    
    Resources:
      HttpApi:
        Type: AWS::Serverless::HttpApi
    
      BetaFunction:
        Type: AWS::Serverless::Function
        Properties:
          Runtime: nodejs20.x
          Handler: index.handler
          Timeout: 5
          MemorySize: 2048
          Architectures:
            - arm64
          CodeUri: dist/beta/
          Events:
            Track:
              Type: HttpApi
              Properties:
                ApiId: !Ref HttpApi
                Method: POST
                Path: /beta
          Policies:
            - DynamoDBCrudPolicy:
                TableName: !Ref DdbCraftMilestoneTable
          Environment:
            Variables:
              DDB_REGION: !Ref DdbRegion
              DDB_TABLE_CRAFT_MILESTONE: !Ref DdbCraftMilestoneTable
              VERIFY_TOKEN: !Ref VerifyToken
              DEBUG: "1"
    
      CraftMilestoneDynamoDBTable:
        Type: AWS::DynamoDB::Table
        Properties:
          TableName: !Ref DdbCraftMilestoneTable
          KeySchema:
            - AttributeName: PartitionKey
              KeyType: HASH
            - AttributeName: SortKey
              KeyType: RANGE
          AttributeDefinitions:
            - AttributeName: PartitionKey
              AttributeType: S
            - AttributeName: SortKey
              AttributeType: S
          BillingMode: PAY_PER_REQUEST
    

    server/samconfig.toml

    version = 0.1
    
    [default.global.parameters]
    region = "ap-northeast-1"
    
    [default.validate.parameters]
    
    [default.deploy.parameters]
    stack_name = "panda-tools"
    s3_prefix = "panda-tools"
    capabilities = "CAPABILITY_IAM"
    resolve_s3 = true
    image_repositories = []
    
    [default.local_start_api.parameters]
    host = "0.0.0.0"
    

    Github Actions のワークフロー

    サーバー側のスクリプトを、実際にサーバーにアップロードして動作する状態にするには、`sam deploy` コマンドを実行する必要があるのですが、以下の設定を作っておけば、その作業を GitHub に任せることができます。

    .github/workflows/deploy.yml

    on:
      push:
        branches:
          - main
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-python@v5
          - uses: aws-actions/setup-sam@v2
          - uses: aws-actions/configure-aws-credentials@v4
            with:
              aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
              aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
              aws-region: ap-northeast-1
          - name: Set up QEMU
            uses: docker/setup-qemu-action@v3
            with:
              platforms: linux/arm64
          - run: sam validate
          - run: sam build --use-container
          - name: sam deploy
            env:
              DDB_REGION: ${{ secrets.DDB_REGION }}
              DDB_TABLE: ${{ secrets.DDB_TABLE }}
              SALT: ${{ secrets.SALT }}
              CIPHER_KEY: ${{ secrets.CIPHER_KEY }}
              CIPHER_IV: ${{ secrets.CIPHER_IV }}
              VERIFY_TOKEN: ${{ secrets.VERIFY_TOKEN }}
            run: >
              sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
              --parameter-overrides
              DdbRegion=$DDB_REGION
              DdbTable=$DDB_TABLE
              Salt=$SALT
              CipherKey=$CIPHER_KEY
              CipherIv=$CIPHER_IV
              VerifyToken=$VERIFY_TOKEN
    

    なおここで参照している ${{secrets.*}} などは、GitHub のレポジトリの設定 Settings > Secrets and variables > Actions から設定しておきます。

    解説

    解説編に続きます。


     
     
    国産メタバースプラットフォームに住んでいるパンダです。 メタバースクリエイター活動っぽいことをして遊んでいます。

    あなたへのおすすめ