メインコンテンツまでスキップ

共通化する処理としない処理の見分け方

タグ:

似た処理を共通の置き場にまとめる時、どれを出してどれを残すかという判断が要ります。早く出しすぎると使いにくい部品ができ、出さずにいると同じコードが散らばります。

個人で作っているブラウザゲームで、この判断をしました。1 つ目は猫を撫でる放置ゲーム(置いておくだけで数値がたまっていくゲーム)で、そのあと犬にお手をさせる 2 つ目を作ることになり、その段階で共通部分を切り出しました。

この記事では、その時に共通側へ出したものと残したものを並べて、何を基準に分けたかをまとめます。ゲームに限った話ではないです。同じ画面構成の管理ツールを 2 つ目に作る時、似た API クライアントを 2 本目に書く時にも、同じ判断が必要になります。

題材を知らなくても書ける処理は、最初から共通側に置ける​

共通化の判断には、待つ必要がないものと、あるものがあります。前者は、アプリが何を扱っているかと関係なく書ける処理です。

1 つ目を作っている時点から共通の置き場に出していたのは、次の 3 つでした。

置いたもの中身
更新ループ画面の描き直しを回す。1 フレームの経過時間に上限を付けて、タブを戻した時の飛びを防ぐ
保存領域の読み書き読み書きを例外処理ごと包む。保存できない設定のブラウザでも動かす
大きな数の表示10000 を超えたら万・億などの単位で短く表示する

この 3 つに共通しているのは、猫というゲームの中身を知らなくても書ける点です。猫が出てこようが犬が出てこようが、更新ループの書き方は変わらないです。他人のプロジェクトにそのまま貼り付けても意味が通る処理だと言い換えてもよいです。こうしたものは、使いみちが 1 か所しかなくても共通側に置けます。

使いみちが 1 か所の間は、共通と固有の線が引けない​

アップグレード(コインを払って能力を上げる要素)の費用計算やボタン一覧の組み立ては、猫のフォルダに置いたままにしました。仕組みとしては他の放置ゲームでも使えそうに見えます。ただ、中には猫の種類ごとの倍率や、撫でる回数といった猫固有の都合が混ざっています。

どこまでが共通で、どこからが猫の都合かは、猫版しか存在しない時点では決められないです。「これは他でも使いそうだ」という感触はありますが、それは推測です。2 つ目がどんな形になるかを知らないまま線を引くことになります。

使いみちが 1 か所の間は共通部分の線が引けず、2 か所目が現れると重複した部分に線が引けることを示した図

ここで早く線を引くと、早すぎる抽象化(まだ 1 か所でしか使っていない処理を、共通部品として作り込んでしまうこと)になります。この場合に困るのは、コードが増えることよりも、共通側に猫の都合が入り込むことです。2 つ目を作る時、共通側を使うために 2 つ目の仕様を共通側に合わせるか、共通側に分岐を足すかのどちらかになります。

2 か所目が現れると、何が同じで何が違うかが見える​

2 つ目は、猫版の色と絵だけを差し替えた「スキン違い」にはしなかったです。遊んだ時の手触りから作り分けています。

観点1 つ目(猫)2 つ目(犬)
舞台部屋庭
操作の意味撫でるお手をさせる
動き横から横へ歩く奥から手前へ走ってくる
待ってくれる時間6 秒10 秒
独自の仕組みなしコンボ(1 秒以内に続けると 1 回ごとに +10%、最大 +90%)

この作り分けが、共通化の判断に効いてきます。色と絵だけの差し替えなら、まるごとコピーして定数を書き換えれば済み、何が共通かを考える機会が来ないです。中身を変えたことで、同じ仕組みに見えていた部分のうち本当に同じなのはどれかを、2 か所で突き合わせられるようになりました。

大事なのは、2 つ目という回数ではないです。使いみちが実際に 2 か所目として現れたかどうかです。同じアプリの中で似た画面を 2 つ目に作る時でも、判断の材料がそろうという点は変わらないです。逆に、2 つ目を作る予定があるというだけでは材料にならないです。

共通側に出したものと、残したもの​

突き合わせた結果、共通の置き場に移したのは次の 4 つです。

移したもの中身
アップグレードの計算費用の計算、購入できるかの判定、保存されたレベルの検証、放置時間の丸め
画面まわりの小道具要素の取得、変化した時だけのテキスト更新、通知の表示、ボタン一覧の組み立て
ドット絵の部品四角形の描画と、ハート・コイン・星の絵
ドット文字の描画中身は 1 行も変えずに場所だけ移動

アップグレードの計算は、猫版から型の都合を抜いて、どのゲームからでも呼べる形にしました。

/** コスト = ceil(base × growth^Lv) */
export function upgradeCost(def: UpgradeBase, level: number): number {
return Math.ceil(def.baseCost * Math.pow(def.growth, level));
}

/** 購入できれば購入して true を返す。 */
export function tryBuyUpgrade<Id extends string>(
save: { coins: number; levels: Record<Id, number> },
def: UpgradeBase & { id: Id },
): boolean {
const level = save.levels[def.id];
if (level >= def.maxLevel) return false;
const cost = upgradeCost(def, level);
if (save.coins < cost) return false;
save.coins -= cost;
save.levels[def.id] = level + 1;
return true;
}

引数で受け取るのは、コインとレベルを持っているという最低限の形だけです。アップグレードの種類を表す名前は型引数で受け取るので、猫版の 4 種類と犬版の 4 種類をそれぞれの型のまま渡せます。呼び出す側は、自分のゲームの型を当てはめるだけの短い関数になりました。

/** 購入できれば購入して true を返す。 */
export function tryBuy(save: SaveData, id: UpgradeId): boolean {
return tryBuyUpgrade(save, upgradeDef(id));
}

一方、共通側に出さなかったものも同じくらい重要です。

残したもの理由
登場するキャラの種類と倍率猫の毛色と犬の犬種で、持っている項目から違う
アップグレードの名前・説明・費用系統は揃えたが、同じものは置かない方針にした
待ってくれる時間やコンボの設定作り分けた部分そのもの
振るまいのシミュレーション横歩きと奥から手前で、座標の持ち方が違う
絵と飾りの描画部屋と庭で中身が別物
能力値の計算式今は同じだが、分かれる見込みがある

判断に迷ったのが、最後の能力値の計算式です。2 つを並べると、式としては今のところ同じです。

// 猫版
export function computeStats(levels: Record<UpgradeId, number>): Stats {
return {
coinPerPet: 1 + levels.petPower,
autoPetsPerSec: 0.5 * levels.autoPet,
visitInterval: Math.max(0.8, 3 * Math.pow(0.85, levels.cushion)),
kindCount: Math.min(KINDS.length, 1 + levels.visitors),
};
}
// 犬版
export function computeStats(levels: Record<UpgradeId, number>): Stats {
return {
coinPerPaw: 1 + levels.treat,
autoPawsPerSec: 0.5 * levels.puppies,
visitInterval: Math.max(0.8, 3 * Math.pow(0.85, levels.lights)),
breedCount: Math.min(BREEDS.length, 1 + levels.breeds),
};
}

それでも残したのは、2 つの理由からです。1 つは、フィールド名がすべて違うため、共通化するには名前を揃えるか、名前の対応表を渡す仕組みを足すことになる点です。元の 4 行より、仕組みのほうが大きくなります。

もう 1 つは、この式が今後ずれる見込みがある点です。犬版にはコンボがあり、1 回あたりのコインはこの関数の外でさらに倍率がかかります。遊びやすさの調整で片方の係数だけを動かす場面も想定できます。同じ形をしていても、同じ理由で動くとは限らないです。

テストは書き換えずに、切り出しの検証に使える​

共通化で一番ありがたかったのは、すでに書いてあったテストをそのまま流用できたことです。切り出しの時点で、猫版には 20 件のテストがありました。購入の判定、放置報酬の上限、壊れた保存データの補正、猫が来て撫でられて去るまでの流れなどを見ています。

切り出しは、この 20 件を 1 件も変えずに行いました。テストを変えないまま全部通るなら、外から見た動きは変わっていないことになります。新しく検証用のコードを書く必要がないので、共通化の作業そのものにだけ集中できます。

そのまま使えたのは、次の点がそろっていたからです。

  • テストが、内部でどのファイルに処理が置かれているかではなく、外から見た振るまいを見ていた
  • 切り出しの前後で、テストファイルに手を入れなかった
  • 1 つのコミットに切り出しだけを入れ、2 つ目の機能追加を混ぜなかった

逆に言うと、切り出しの途中でテストを直さないといけなくなったら、それは手が滑って動きを変えた合図になります。テストを直してから進めるのではなく、切り出しのほうを見直す場面です。

テストも、コードと同じように共通の置き場へ移せます。ドット文字の描画を共通側へ移した時、そのテストも同じ場所へ移しました。移しておくと、2 つ目からも同じテストが効く形になります。元の場所に残すと、共通側のコードに対して片方のフォルダからテストが伸びることになり、どちらのゲームのためのテストなのかが読めなくなります。

見分ける 4 つの問い​

ここまでの判断を、4 つの問いに整理しました。上から順に見ていきます。

題材を知らなくても書けるか、使いみちはまだ 1 か所だけか、2 か所の中身はいま違うか、片方だけ変えたくなる見込みがあるかの 4 つを順に判定し、はいなら右に抜けて結論、いいえなら下の問いへ進むフローチャート

1 つ目の問いが、最初から共通側に置いてよいものを拾います。更新ループや保存領域の読み書きのように、アプリの題材が何であっても書き方が変わらない処理です。

2 つ目の問いは、推測を外すためのものです。「将来使いそう」は 2 か所目に数えないです。実際にコードを書き始めて、同じ処理が必要になった時点で初めて数えます。

3 つ目と 4 つ目の問いが、能力値の計算式を残した時に使った基準です。見た目が同じであることと、同じ理由で同じ値を返し続けることは別です。片方の都合で変えたくなる見込みがあるなら、共通化した時点から分岐が入る準備をしていることになります。

進め方の面では、切り出しを単独のコミットに分けておくと後が楽です。機能追加と混ぜると、動きが変わった時に、切り出しのせいか新機能のせいかを分けられないです。切り出しのコミットでは、既存のテストが変わっていないことが差分から見て取れる状態にしておきましょう。

まとめ​

共通側に出すものと残すものを、推測ではなく実際の重複を見てから決めました。同じ判断をする時は、次の点を押さえておくとよいでしょう。

  • 題材が何であっても書き方が変わらない処理は、使いみちが 1 か所でも共通の置き場に出せる
  • それ以外は、2 か所目の使いみちが実際に現れてから、何が同じで何が違うかを見て決める
  • 形が同じでも、片方だけ変えたくなる見込みがあるものは、それぞれの場所に残しておく