Adventure Land

Adventure LandというMORPG風のゲームがある。数か月前くらいにできて、まだプロトタイプだそうだが、cursors.io, agar.io, diep.ioのようにHTML5ベースのゲームだ。

HTML5ということはJavaScriptで動いているわけで、クライアントサイドでごにょごにょやる層も出てくるわけだが、他と違うのはその仕組みがもともとゲームに備わっていることだ。

試しにスクリプト機能をいろいろといじってみた。ただしプロトタイプ版のため、現時点とは仕様が変わる可能性がある。

CODE

ゲーム画面のCODEを押すとCodeMirrorを利用したエディタが開き、そこにスクリプトを入力して実行できる。

ENGAGEで実行すると、画面右下にiframeによる小ウインドウが作成され、そこにjQueryやデフォルトのヘルパー関数とともにスクリプトが読み込まれ(関数内evalされ)、既定のフック関数はevalしたスコープからiframe内にexportされる。
UNENGAGEを押すとiframeを削除して、スクリプトがアンロードされる仕組みだ。

デフォルトでは自動的にモンスターを探してアタック・回復を行うスクリプトが設定されている。
チャットウインドウに/savecodeと入力すれば保存できる(保存しないと次回起動時は空になってしまう)。/loadcodeで読み込み、/runcodeで読み込みとENGAGEを行える。(オプションで1か2のスロット番号を指定できる)
iframeなのでゲーム画面にはparentを通じてアクセスすることになる。

標準スクリプトの修正

攻撃と回復を自動で行うスクリプトが入っていると書いたが、回復スクリプトにはバグがある。
デフォルトの関数はこのような感じだ。

function use_hp_or_mp()
{
    if(safeties && mssince(last_potion)<600) return;
    var used=false;
    if(new Date()<parent.next_potion) return;
    if(character.mp/character.max_mp<0.2) parent.use('mp'),used=true; 
    else if(character.hp/character.max_hp<0.7) parent.use('hp'),used=true;
    else if(character.mp/character.max_mp<0.8) parent.use('mp'),used=true;
    else if(character.hp<character.max_hp) parent.use('hp'),used=true;
    else if(character.mp<character.max_mp) parent.use('mp'),used=true;
    if(used) last_potion=new Date();
}

characterは自キャラのオブジェクトだ。呼び出すと最低600msかクールタイムの間隔で、HPかMPが1でも減っているとpotを使ってしまう。
回復量は低級のHPポット(hpot0)で200、MPポット(mpot0)で300なので、例えばこのように変更する。元の関数はiframeのwindowにあるのでCODE内に書けばCODEからの実行ではもちろんCODE側が優先される。

function use_hp_or_mp() {
    if (safeties && mssince(last_potion) < 600) return;
    var used = false;
    if (new Date() < parent.next_potion) return;
    else if (character.max_hp - character.hp >= 200) parent.use('hp'), used = true;
    else if (character.max_mp - character.mp >= 300) parent.use('mp'), used = true;
    if (used) last_potion = new Date();
}

potの購入もスクリプトで行える。character.items[].nameや.qで所持量を確認し、buy(itemName, count)で購入する。NPCが売っているアイテムは、現在地に関係なく買えるようだ。
場所に関してはサーバ側で制限していないようで、UI操作だと入り口に近づかないとポータルが使えないが、parent.transport_to(name, id)を使うと場所に関係なく移動できる。もちろん名前は調べる必要があるが、ブレークポイントを設定しておけば値を調べるのは難しくない。

コマンドの拡張

/savecodeのようにチャットウインドウに/commandと入力するとチャットの代わりにコマンドとなる。
未定義のコマンドはhandle_command(command, args)関数に最初のワードがcommand、以降の文字列がargsとして渡される。

例えば/whisperというプライベートメッセージコマンドがあるが、相手をフォーカスしておかないと使えないので/tell NAME MESSAGEというコマンドを定義してみる。
※コードはゲーム本体はsnake_caseだが、独自部分はそれとわかりやすいようにlowerCamelCaseにした。

function handle_command(command, args) {
    parent.$('#chatinput').focus();
    if (command == 'tell') {
        if (args = args.match(/^(\S+)\s+(\S.*)/)) {
            parent.private_say(args[1], args[2]);
            return true;
        }
    }
    return false;
}

このままではコマンドが増えたときに混沌としてしまうので、オブジェクトを使い呼び出しテーブルを作る。

var commands = {};

function handle_command(command, args) {
    var result = false;
    if (commands[command]) {
        result = commands[command].call(this, command, args);
    }
    parent.$('#chatinput').focus();
    return result;
}

commands.tell = function (command, args) {
    if (args = args.match(/^(\S+)\s+(\S.*)/)) {
        parent.private_say(args[1], args[2]);
        return true;
    }
};

フック

スクリプトが実行できると放置ゲームの性質を帯びてくるが、グラフィックのあるゲームのためやや負荷が高い。また現在の制限では2キャラまで戦闘メンバーを同時ログインできるが、そうすると負荷も倍になる。
そこで負荷を下げるために、ウインドウがアクティブでない場合に描画を無効化してマップの描画を停止してみる。HTML製のUIなのでステータスなどは更新されるのも利点だ。ちなみに描画ライブラリにはPixi.jsを使っているようだ。
parent側を修正するが、スクリプトをアンロードしたときには元に戻す必要が出てくる。UNENGAGE前にはon_destroyが呼ばれるのでこれを利用する。クラスメソッドなので戻すときは単にdeleteすれば元の(prototypeの)関数が呼ばれる。

var destroyHandlers = [];

function on_destroy() {
    destroyHandlers.forEach(function (f) {
        f();
    });
}

var onFocus = function () {
    delete parent.renderer.render;
};
var onBlur = function () {
    parent.renderer.render = $.noop;
};
$(parent.window).on('focus', onFocus).on('blur', onBlur);
destroyHandlers.push(function () {
    onFocus();
    $(parent.window).off('focus', onFocus).off('blur', onBlur);
});

余談だが、parent側のUIを変更するためにjQueryでElementを取得する場合は$()ではなくparent.$()を使う必要がある。

リモートコマンドの実行

複数キャラを制御する場合、普通はコマンド用のサーバを立ち上げてWebSocketで通信したりする。必要なUIを独立した画面で作成できてゲーム側への依存が減るのが利点だ。しかし折角コード実行の仕組みがゲームに備わっているので、サーバレスにプライベートメッセージを使って命令を送る形で実装してみる。
チャットメッセージの処理自体は置き換えが複雑かつ仕様変更に弱いが、プライベートメッセージの場合add_pmchatを呼んで別ウインドウにも表示する処理がある。これを置き換えるとよさそうだ。
on_destroy時に確実に元に戻さないといつまでも古いスクリプトが残るので注意が必要だ。描画の時と違いprototypeの関数ではないので元の関数を保存しておく。parent内に適当なプロパティ名で保存するのもいいかもしれない。

var origAddPmChat, origAddPartyChat;
destroyHandlers.push(function () {
    if (origAddPmChat) {
        parent.add_pmchat = origAddPmChat;
    }
    if (origAddPartyChat) {
        parent.add_partychat = origAddPartyChat;
    }
});
origAddPmChat = parent.add_pmchat;
origAddPartyChat = parent.add_partychat;
if (!origAddPmChat || !origAddPartyChat) {
    return;
}
parent.add_pmchat = function (toOrOwner, owner, message) {
    if (!onRemoteCommand(owner, message)) {
        origAddPmChat(toOrOwner, owner, message);
    }
};
parent.add_partychat = function (owner, message) {
    if (!onRemoteCommand(owner, message)) {
        origAddPartyChat(owner, message);
    }
};

ついでなのでパーティチャットコマンドでもできるようにした。onRemoteCommandに処理を入れる。

var leaderName = 'Leader';
var supervisorNameRe = /^(?:Trusted|Player|Names)$/;

function onRemoteCommand(owner, message) {
    if (!supervisorNameRe.test(owner)) {
        return false;
    }
    match = message.match(/\/(\S+)\s+(.+)/);
    if (!match) {
        return false;
    }
    if (/^(?:we|you|leader)$/.test(match[1])) {
        if (match[1] == 'we' || (match[1] == 'you' && owner != character.name) || (match[1] == 'leader' && leaderName == character.name)) {
            var command = match[2].split(/^(\S+)(?:\s+(\S.*)|()$)/);
            if (!/^(?:we|you|leader)$/.test(command[1])) {
                game_log("party command: " + match[2]);
                handle_command.call({owner: owner}, command[1], command[2]);
            }
        }
    } else {
        game_log("unknown party command: for=" + match[1] + ", to=" + match[2]);
    }
    return true;
}

誰からの命令でも構わず受けてしまうと困るので発言者名で制限を入れた。
これで/tell Hoge /you commandのような形で命令を送信できる。
handle_command()経由なので/runcodeのようなネイティブな命令は実行できない。それをするにはparent.say(msg)/込みで送る。上記スクリプトはそのままにhandle_command側で送信処理をするように変更する。

function handle_command(command, args) {
    var result = false;
    if (commands[command]) {
        result = commands[command].call(this, command, args);
    } else {
        result = true;
        if (/^\//.test(command)) {
            parent.say(command + " " + (args || ''));
        } else {
            game_log("Unknown command: /" + command + " " + (args == null ? '' : args));
        }
    }
    parent.$('#chatinput').focus();
    return result;
}

これで/tell Hoge /you /runcodeのような形でコードのリロード指示ができる。
もっと簡単に送信できるように/we /you /leaderコマンドも定義する。/leaderはメインキャラ宛のメッセージとして作った。leaderName宛に送るか、自分がleaderNameと判断して実行するかだが、コマンドサーバが中央にない方式なので内部の状態がキャラ間でばらけると、期待通りに実行できなくなる点は覚えておく必要がある。

commands.we = commands.you = commands.leader = function (command, args) {
    parent.party_say("/" + command + " " + (args == null ? '' : args));
};
/*
commands.leader = function (command, args) {
    parent.private_say(leaderName, "/" + command + " " + (args == null ? '' : args));
};
*/

これで/you /runcodeのような形でチャットを介してパーティメンバーにリロード指示ができる。簡単になった。
チャット関係はコードに失敗するとハウリングのようにキャラ間でメッセージを送りあってしまうので注意が必要だ。実際に失敗した経験からわかったが、一応spam防止の安全装置があり、一定時間に一定数以上のメッセージを送れないようにはなっているようだ。

まとめ

基礎的な拡張例をまとめてみた。プロパティの調査などはブラウザの開発ツールを使うのがよい。ゲームではJSONをダンプする命令があるなどと説明されているが、行数が増えると表示が重くなるので使い物にならない。
独自コマンドの実行や命令送信・状態の取得設定コマンドを載せてしまえば、あとはコマンド拡張とトレード・移動・戦闘ルーチンの修正をするだけになる。その段階で満足なものができると、あとはインクリメンタルゲームとして遊ぶだけという雰囲気だ。
普通のRPGとして遊ぶにはドロップがかなり厳しい。PvPエリア・サーバもあるが、レギュレーションのような制限もまだないので、後発だとコード以前の育成に時間がかかる。
とはいえプロトタイプなので、今後どうなるかはわからない。
手動操作もできるMORPG上での放置ゲームというのは(過去にもあったのかもしれないが)、目新しく感じて少しのめりこんでしまった。