見出し画像

【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 から設定しておきます。

解説

解説編に続きます。


この記事が気に入ったらサポートをしてみませんか?