この章ではゲームの華である戦闘のAPIを解説します。
戦闘に関係するAPIは
- 移動系API
- 戦闘系API
- 結果系API
の3種類に分類できます。 これを繰り返して戦闘します。
出撃するマップを選んだ時の「出撃API」と、戦闘後に進軍を選んだ時の「進軍API」がなぜか同じレスポンスです。 羅針盤が回っている時には既に行き先は決定しています。
レスポンスには移動先の情報が含まれていますが、本当に知性を感じられない設計になっています。
特に重要な戦闘の属性は api_color_no という意味の分からないキー名が振られています。
夜戦スタートとかは判明したんですが潜水艦の判定を出してるのがどこなのかは最後まで判明しませんでした。
あと物資がもらえるマスを通っても物資が増えないという現象に遭遇しました。 これも最後まで何をすれば回収できるのか判明しませんでした1。
移動先の戦闘属性に従って適切な戦闘APIを叩く必要があります。 戦闘APIは「通常戦闘」「夜間戦闘」「夜から昼」の3種類があります。 「通常戦闘からの夜戦」と「夜間戦闘」は別のAPIです。
戦闘APIを叩くと戦闘結果が返ってきます。 信じがたいほど理解しにくいJSONで、知性の欠如を感じます。
例えば砲撃の結果は、 api_hougeki の中に api_def_list と api_damage の2つの配列2が入っています。
{
"api_hougeki": {
"api_def_list": [10, 3, 12, 1, ...],
"api_damage": [25, 3, 15, -1, ...],
...
}
}このデータを、api_def_list の n 番目に書かれた艦3に api_damage の n 番目に書かれた数字をダメージとして与える、というルールで処理していきます。
api_hougeki を配列に、その中の要素をハッシュにしたほうが圧倒的に理解しやすい表現です。
{
"api_hougeki": [
{"attacker": 1, "defender": 10, "damage": 25},
{"attacker": 11, "defender": 3, "damage": 3},
{"attacker": 4, "defender": 12, "damage": 15},
{"attacker": 12, "defender": 1, "damage": -1},
...
]
}サイズが気になるなら、可読性は落ちますがハッシュではなく配列にしてもいいでしょう。
{
"api_hougeki": [
[ 1, 10, 25],
[11, 3, 3],
[ 4, 12, 15],
[12, 1, -1],
...
]
}この場合コードによる補足が無いと理解しづらいですが、api_hougeki が配列なだけで遥かにマシです。
APIレスポンスは人間の理解できる構造を表現するべきであり、
元のレスポンスは for 文 というコンピュータの処理の都合に合わせて作られていて不適切です4。
なお、去年の冬くらいに、 「戦闘APIを叩いたあとは10秒間待たないと次のAPIリクエストがことごとくエラーになる」 ように仕様変更されました。 この変更により、ただでさえ面倒な戦闘が致命的に面倒になったので、 イベント以外のプレイを完全に辞めました。
結果APIを叩くとそのマスでの戦闘が終了します。 夜戦に行かない場合も結果APIを叩きます。 レスポンスは関心がないので調べてませんが、経験値とかドロップとかが書かれているんだと思います。
マップの終端に到達した場合や進撃せずに撤退する場合は、母港画面に戻り、艦隊情報の取得などの一連APIが叩かれます。
この一連のAPIの中にある /kcsapi/api_auth_member/logincheck というAPIが、
実は出撃状態だったら出撃を完了するという処理をしています。
まったく名が体を表していないし、ユーザからしたら完全に副作用です。いみわかんない。
出撃中に猫った場合、再ログインさせられてれて母港に飛ぶので出撃状態が解除されます。
戦闘の流れはとにかく理解しにくいです。 完全に場当たり的な実装を繰り返しているのが想像できます。 レスポンスも整理されておらず、違うAPIなのに同じようなレスポンスを返していたりします。 出撃という行動のモデリングができてないと思われます。
RESTでは、物事をリソースとしてURLで表現し、HTTPメソッドを正しく使ってそのリソースを操作します。 RESTとAPIは相性がよく、よい設計の助けになります。
出撃-戦闘-結果 の一連のAPIをRESTfulにしてみましょう。 戦闘に限らずゲームはリソース作成/操作の積み重ねです。 全てのリソースを頭から再評価すれば最後の状態を復元できるようにします。
まず1つ目のリソースは出撃です。英語だとsotieです。
まず出撃するには POST メソッドで新しい sotie のリソースを作成します。
map_id や fleet_id などがパラメータになるでしょう。
POST /soties
レスポンスはこのようになります。
{
"id": 2341234,
"fleet_id": 1,
"area_id": 3,
"map_id": 2,
"done": false
}すでに出撃中の場合5、APIは 400 Bad Request を返して失敗します。
この出撃のリソースには何時でも、何回でもアクセスでき、常に同じ情報が取得できます6。
GET /soties/2341234
出撃を終了するには DELETE メソッドを使います。
DELETE /soties/2341234
リクエストを見るだけで done が true に設定されると想像できるでしょう7。
移動もリソースと考えて良いと思います。
リソース名はmoveです。
リソースは名詞というルールがあるので、動詞のmoveではなく名詞のmoveであることを忘れてはいけません。
POST メソッドで移動を作成します。
POST /soties/2341234/moves
移動できる場合は、移動先の情報と次にすべきことを示したデータが取得できます。
例えば戦闘が必要なマスに移動した場合はこのような感じでしょうか。
{
"id": 98765,
"cell": 1,
"type": "battle",
"submarine": true,
"boss": false,
"next" "battle"
}みんな大好き物資マスはこんな感じですね。
{
"id": 98766,
"cell": 2,
"type": "supply",
"items": {
"steel": 15,
"fuel": 5
},
"next": "move"
}移動できない場合8は、400 Bad Request を返します。
レスポンスの中身に戦闘しろ、とか帰還しろ、のように書いてもいいと思います。
もちろん作ったリソースは GET で何度でも情報は取得できるし、
更に全ての移動経路も取得できるAPIも欲しいですね。
GET /soties/2341234/moves/98765
GET /soties/2341234/moves
戦闘するときも、まず POST メソッドでbattleを作ります。
POST /soties/2341234/battles
元のAPIだと通常戦闘なのか、夜戦なのか、などによって呼び出すAPIが違いましたが、 戦闘というリソースは一つのはずなので分けるべきではありません。
このようなレスポンスを返しましょう。
{
"id": 132435,
"type": "normal"
}とりあえず戦闘のリソースを作りました。
このとりあえずというのが重要です。
サーバは現在の状態を知っているので、sotie_id と cell が自動で記録されます。
APIレスポンスに載せるかどうかは別の話です。
通常戦闘の場合、陣形を選択します。
これは、作った戦闘のリソースの陣形を変更すると考えます。
存在するリソースへの変更は PUT を使います。
PUT /soties/2341234/battles/132435
formation=3
これは何度でも実行できるべきです。
また、変更できない場合9は、400 Bad Request を返してもいいし、
200 Success を返しつつ変更は無視してもいいでしょう。
戦闘結果を取得しましょう。
GET /soties/2341234/battles/132435/result
もちろん GET なので何度叩いても良いし、常に同じ結果を返します。
最初にリクエストされた時に戦闘の計算を行い、戦闘結果を確定し、データベース等に記録します。
艦隊へのダメージや、燃料砲弾の減算の反映なども行います。
内部的に持っている昼戦確定フラグのようなものも立てておきます。
いったん昼戦確定フラグが立ったら、陣形の変更は二度とできないようにしなければなりません。
2度目以降のアクセスは、データベース等に記録された内容を返します。
通常戦闘はこのあと夜戦に行くことができます。 やはりこれも戦闘リソースを変更すると考えます。
PUT /soties/2341234/battles/132435
night_battle=true
夜戦した場合の戦闘結果も同様です。
GET /soties/2341234/battles/132435/result
夜戦結果が無かったら計算を行い、記録し、結果を返します。
戦闘リソースとしては1つなので、昼戦の結果に更に夜戦の結果が追加されたような形になります。
夜戦だけの結果を返すのは間違っています。
もちろん夜戦確定フラグのようなものを立てておきます。
もう night_battle=false にすることはできません。
戦闘が終わったら経験値と艦娘ゲットの時間です。
PUT で終了を伝えましょう。
PUT /soties/2341234/battles/132435
complete=true
その後
GET /soties/2341234/battles/132435/result
を叩くと、経験値やドロップが追加された戦闘結果が取得できます。
ゲームでは順々に処理されていくので無理ですが、APIではこのようにリクエストすることも可能です。
PUT /soties/2341234/battles/132435
formation=3&night_battle=false&complete=true
これでresultを叩けば一発で結果が取得できます。
コンピュータの都合でJSONの構造を決めるのも間違っていますが、 ゲームの都合でAPIを実装するのも間違ったことです。
現在のAPIでは、何かエラーがあるとすぐに猫画面を出し、 とにかくリロードさせ、全てを諦めて母港に飛ばします。 この仕様は本物の糞です。
最後にどの状態だったのか、サーバは知っているはずです。 きちんと設計すれば直前の状況まで復旧することは可能です10。 今回の実装例だと、過去も含めて全ての出撃を再現可能です11。
この点パズドラは本当に上手くやっていると思います。
たぶんこの戦闘APIの設計には色々反論したい人がいると思います。
夜戦は新しいリソースとして作るべきだとか、それぞれのresult分けるべきだとか、
POST /battles の瞬間に結果を確定させるべきだとか、まぁいろいろ思うところはあると思います。
これもただの一例なので、よく考えて作ろうとか俺はこんなふうに思考して作ってるって部分が伝わればいいと思います12。
このAPI設計の問題点として、明らかに更新の処理が複雑になるのが目に見えています。
どのパラメータを弄れるかというのがかなり状態に依存するので、
さらに内部を綺麗に設計しないと巨大な if 文の闇になるでしょう。
ただし他の部分に闇がなくなるので、
このような闇を一箇所に押し込めた設計は完全な悪では無いと思います。
- リクエストとレスポンスを1対1対応させる
- JSONは処理の都合ではなくあるべき構造に合わせる
- リソースの概念を理解しAPIを整理する
- 状態での分岐を減らす
根本的な問題は、度重なる仕様変更でどんどん戦闘システムが複雑になっていることです。 その対応として場当たり的な拡張を続け、はっきり言ってシステムは破綻しています。 艦これが圧倒的につまらない原因はこの複雑な戦闘システムです。
外から見ただけでは分からないのでここでは触れていませんが、 実際の戦闘の処理も相当複雑なコードになっていると思います。 特にイベントボスの弱点に特定の装備を設定しているところなど、 API設計の能力を見る感じではたぶん相当複雑でカオスなコードになっているんじゃないでしょうか。 あと3回くらいイベントをやった後くらいには、 戦闘の処理をする関数を含むファイルは社内で『聖典』などと呼ばれるようになると予測します。 そうなるともう手が付けられず、誰も変更の影響範囲を正しく把握できず、 少しの変更でもバグが出て実装に何週間もかかるような状態になります。
まずはとにかくゲームシステムの単純さを取り戻すことが何より重要だと思います。
Footnotes
-
オリョクル? なにそれ美味しいの? ↩
-
誰が攻撃したかは戦闘結果に関係ないので無視している ↩
-
敵味方艦混合でインデックスが振られる 味方艦:1-6 敵艦:7-12 みたいな感じに ↩
-
元レスポンスの配列は固定長 つまり固定回数の
forを回している可能性がある 圧倒的にダサい ↩ -
つまり
doneがfalseなsotieが存在する場合 ↩ -
このような性質を冪等性と言う HTTPの
GETは冪等性を持つべきとされている ↩ -
この方法を論理削除という 記録を残す必要がなければ単に物理削除してもよい ↩
-
戦闘しないといけない場合や終端の場合など ↩
-
少数編成の場合や通常戦闘でない場合 ↩
-
現状でも可能だと思うんですけどね・・・出撃終了条件を変な扱いしてるから・・・ ↩
-
出撃時の艦隊の状態を保存する必要があります ↩
-
こわいこわいRESTfulおじさんに見つかりませんように・・・ ↩