Misskeyはマルチプレイ対応のPlayを制作は可能

:::note warn
今回紹介する手法は実用的なものではありません。
あくまで、「Misskeyはマルチプレイ対応のPlayを制作は可能」を主張することが目的であることに注意して下さい。
:::

# はじめに
Misskeyではマルチプレイ対応のPlayを制作可能です。では実際どのように実現しているのかを確認しましょう。

# 仕組み
マルチプレイ可能なPlayの仕組みをざっくりと説明すると、「Pageをデータ共有サーバーのように扱うことでマルチプレイを達成している」です。
具体的には`Mk:api("pages/show" {pageId: pageId})`でデータを取得し、そこに何らかの処理を加えて`Mk:api("pages/update" pageObj accessToken)`でデータの更新を行うことでマルチプレイを可能にしています。
さて、ここで以下のような問題が発生します。
* Pageはデータに対して処理を行わない
* `pages/update`にはクレデンシャルが必要
* Pageから何か変更を通知してくれるわけではない

## Pageはデータに対して処理を行わない
これに関しては各ユーザー側がデータを受け取った際に処理を行うことで解決できます。例えば一定時間更新のないユーザーの除外なども他のユーザーが代わりに行う必要があります。

## Pageから何か変更を通知してくれるわけではない
Pageは基本的に何もできないので、他のユーザーによる変更があっても一切通知しません。そのため、定期的にAPIで変更がないかを確かめる必要があります。

## `pages/update`にはクレデンシャルが必要
これに関しては現状どうしようもありません。だからこそ、このマルチプレイは実用的ではないのです。実際に運用する場合には利用ごとにアクセストークンを変えたほうがいいでしょう。

# 実物
https://voskey.icalo.net/play/9kp84tummh
<details>
<summary>コード</summary>

```aiscript
/// @ 0.16.0

/*
このプレイ作成のリスクを正しく理解していないうちは決して真似しないでください。
イカロさんありがとう(言い逃れ)
*/

//定数
let usersLimit = 5
let cycleTime = 500
let cuttingTime = cycleTime+1000
let tag = "#ぼすきー公園"
let playerSpeed = 0.5
let accessToken = readline("Access Token")

//変数
var avatarEmoji = ":vomin:" //ユーザーアバター
var funcKey = "entry"       //呼び出す関数
var stopInterval = null     //インターバル停止関数
var field = "" //フィールド
var usersList = []
var userElem = null
var userPos = {x: 0, y: 0}
var errorData = null

//関数宣言
@removeUser(users){
    return users.filter(@(user){Date:now()-user.at<cuttingTime})
}
@getData(){
    let result = Json:parse(Mk:api("pages/show" {pageId: "9kp7b183ip"}).script)
    if (Core:type(result)=="error") {
        errorData = result
        funcKey = "error"
        return null
    }
    return result
}
@setData(obj){
    let script = Json:stringify(removeUser(obj))
    //未実装
    let resp = Mk:api("pages/update" {
        pageId: "9kp7b183ip"
        title: "database"
        name: "database"
        content: []
        variables: []
        script: script
    } accessToken)
    if (Core:type(resp)=='error') {
        errorData = resp
        funcKey = "error"
        error()
    }
    resp
}
@renderEntryUi(){
    Ui:render([
        Ui:C:container({
            align: 'center'
            children: [
                Ui:C:mfm({ text: "" } "entryMessageBox")
            ]
        })
    ])
}
@renderParkUi(){
    Ui:render([
        Ui:C:container({
            align: 'center'
            children: [
                Ui:C:textInput({
                    onInput: @(text){
                        avatarEmoji = text.slice(0 64)
                        if (avatarEmoji=="") avatarEmoji = ":vomin:"
                        userElem.ae = avatarEmoji
                        parkUiUpdate(usersList)
                    }
                    default: ":vomin:"
                    label: "絵文字"
                })
                Ui:C:mfm({ text: "" } "field")
                Ui:C:buttons({
                    buttons: [
                        {text: "←", onClick: @(){movePlayer("h")}}
                        {text: "↓", onClick: @(){movePlayer("j")}}
                        {text: "↑", onClick: @(){movePlayer("k")}}
                        {text: "→", onClick: @(){movePlayer("l")}}
                    ]
                })
                Ui:C:button({
                    text: "退出"
                    onClick: @(){
                        exit()
                        funcKey = "exit"
                    }
                })
                Ui:C:postFormButton({
                    text: "投稿する"
                    rounded: true
                    primary: true
                    form: { text: "" }
                } "pfb")
            ]
        })
    ])
}
//エージェントで呼び出される関数群
@entry(){
    let users = getData()
    if (users==null) return null
    let userIds = users.map(@(user){user.id})
    if (users.len < usersLimit || userIds.incl(USER_ID)) {  //定員以下、またはユーザーがすでに公園内にいる場合
        if (!userIds.incl(USER_ID)) {
            users.push({
                id: USER_ID
                at: Date:now()
                ae: ":vomin:"
                ps: {
                    x: 0
                    y: 0
                }
            })
        }
        else {
            let filtered = users.filter(@(user){user.id==USER_ID})
            if (filtered.len < 1) {
                funcKey = "error"
                return null
            }
            let elem = filtered[0]
            elem.at = Date:now()
            avatarEmoji = elem.ae
            userPos.x = elem.ps.x
            userPos.y = elem.ps.y
        }
        if (Core:type(setData(users))!="error") {  //ログイン情報をねじ込む
            renderParkUi()      //Uiを組み立てる
            park()              //動かすための初期登録
            funcKey = "park"    //状態をparkにする
        }
    }
    else {
        Ui:get("entryMessageBox").update({text: `現在{users.len}人が公園にいます。{Str:lf}公園の定員は{usersLimit}人です。`})
    }
}
@parkUiUpdate(users){
    field = users.map(@(user){`$[position.x={user.ps.x},y={user.ps.y} {user.ae}]`}).join(Str:lf)
    Ui:get("field").update({text: field})
    Ui:get("pfb").update({form: { text: `{tag}{Str:lf}{field}{THIS_URL}` }})
}
@park(){
    //データの取得
    usersList = getData()
    if (usersList==null) return null
    let filtered = usersList.filter(@(user){user.id==USER_ID})
    if (filtered.len < 1) {
        funcKey = "error"
        return null
    }
    userElem = filtered[0]
    userElem.at = Date:now()
    userElem.ae = avatarEmoji
    userElem.ps.x = userPos.x
    userElem.ps.y = userPos.y
    //データのアップロード
    setData(usersList)
    //Uiの更新
    parkUiUpdate(usersList)
}
//~
@exit(){
    stopInterval()  //エージェントを止めて
    let users = getData()
    if (users==null) return null
    let filtered = users.filter(@(user){user.id!=USER_ID})
    setData(filtered)
    Ui:render([
        Ui:C:container({
            align: 'center'
            children: [
                Ui:C:mfm({ text: "またきてね。" })
                Ui:C:postFormButton({
                    text: "投稿する"
                    rounded: true
                    primary: true
                    form: {
                        text: `[公園]({THIS_URL})で遊んだ。`
                    }
                })
            ]
        })
    ])
}
//~
@error(){
    //インターバルを止める
    stopInterval()
    if (Core:type(errorData)=="error") Mk:dialog(errorData.name Json:stringify(errorData.info))
    //メッセージを表示
    let uiList = if (Core:type(errorData)=="error" && errorData.info.code=="RATE_LIMIT_EXCEEDED") {
        [
            Ui:C:mfm({ text: "APIの回数制限に到達しました。" })
        ]
    }
    elif (Core:type(errorData)=="error" && errorData.info.code=="AUTHENTICATION_FAILED") {
        [
            Ui:C:mfm({ text: "アクセストークンの認証に失敗しました。" })
        ]
    }
    else {
        [
            Ui:C:mfm({ text: "エラーが発生しました" })
            Ui:C:postFormButton({
                text: "連絡する"
                rounded: true
                primary: true
                form: {
                    text: `@yamada_20020924 {THIS_URL}でエラーが発生しました。`
                }
            })
        ]
    }

    Ui:render([
        Ui:C:container({
            align: 'center'
            children: uiList
        })
    ])

}

//関数系変数
let funcs = {
    entry: entry
    park: park
    exit: exit
    error: error
}
//funcKeyの値に応じて関数を呼ぶ
@agent(){
    funcs[funcKey]()
}

@movePlayer(c){
    if ( c == "h" ) userPos.x -= playerSpeed
    if ( c == "j" ) userPos.y += playerSpeed
    if ( c == "k" ) userPos.y -= playerSpeed
    if ( c == "l" ) userPos.x += playerSpeed
    userElem.ps.x = userPos.x
    userElem.ps.y = userPos.y
    parkUiUpdate(usersList)
}

renderEntryUi()
stopInterval = Async:interval(cycleTime, agent, true)


//以下はメモ

/*
---
id: str
at: num
ae: str
ps: 
    x: num
    y: num
*/
```
</details>