【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 から設定しておきます。
解説
解説編に続きます。
この記事が気に入ったらサポートをしてみませんか?