改変版(改悪版)のタスクシステム教を脱出しつつある人に向けた普通の設計復帰支援


前提を疑う

普通の設計?

前回の記事で結びの言葉として「普通の設計を始めましょう」と書きました。

※前回の記事: https://qiita.com/qiitatosh/items/239fb5837f1d3eeb9c6a

改変版(改悪版)タスクシステム教を脱出しようとし始めたばかりの人であればこう思うでしょう。「普通の設計」ってなんだ? まず見せてみろ、と。実はこの「見せてみろ」が出てくる時点ですでに前提がおかしいです。世の中には完ぺきで万能な明確に認識可能な形を持った設計パターンがどこかに存在している。それは疑いようのない事実だ。その当たり前の存在を自分はまだ見れていないからちょっと探しているだけで、そんな大きな話はしていないのだ、というような。まずはこの視点から脱出しましょう。

チート≠普通

  • RPGで言えば、強力なチート武器なんて存在しないので、普通に敵の弱点属性を突いたり有利属性で防御したりと適切な行動を学びましょう。
  • 野球で言えば、絶対当たるインチキバットなんて存在しないので、普通に練習に打ち込んでコース予測精度を高めて適切なスイングをしましょう。
  • ソフトウェア開発の格言で言えば、銀の弾丸や魔法の杖なんて存在しないので、普通に1つ1つ問題に向き合って解決していきましょう。

普通の設計について言えばこうなります。

 「普通の設計とやらを見せてみろ」=
 「多数の問題をまとめてぶっ飛ばせるチート武器、魔法の杖、銀の弾丸をよこせ」
  →そんなものはないです

 「普通の設計を始めましょう」=
 「多数の問題に対して1つ1つ誠実に向き合って解決していきましょう」
  →Good!

チートツール探しはあきらめて着実に行きましょう。普通の設計を始めましょう。

諸悪の根源、抽象インタフェース

複数の問題をまとめて一撃で解決する銀の弾丸はありません。しかし本来存在しない諸問題をわざわざ作り出して場に混沌を導くダメなやり方はあります。あなたが欲しかったのは霧を晴らす銀の弾丸ではなく、霧を作り出していた諸悪の根源の排除かもしれません。

改変版(改悪版)タスクシステムで問題となるのは抽象インタフェースです。

抽象インタフェースの無いコード

試しに抽象インタフェース無しにクラス定義、リスト定義、更新処理呼び出しを書いたコード例を示します。

typedef struct _Point2D {
    int x, y;
} Point2D;

class Character {
private:
    Point2D m_position;
    int m_hp;
public:
    void set_position(const int x, int y);
    Point2D get_position(void);
    void set_hp(const int hp);
    int get_hp(void);
};

static Character chara[10];

void on_enter_frame(void)
{
    // 更新処理1:乱数でキャラクタ座標を更新。
    for (int i = 0; i < 10; i++) {
        chara[i].set_position(rand() % 3 - 1, rand() % 3 - 1);
    }

    // 更新処理2:新しいキャラクタ座標を基に当たり判定。衝突したものはHPを1つ減らす。
    for (int i = 0; i < 10; i++) {
        Point2D a = chara[i].get_position();
        for (int j = 0; j < 10; j++) {
            Point2D b = chara[j].get_position();

            if (i == j) {
                continue;
            }
            if (collision(&a, &b)) {
                a.set_hp(a.get_hp() - 1);
                b.set_hp(b.get_hp() - 1);
            }
        }
    }

    // 更新処理3:キャラクタ情報を基に描画する
    for (int i = 0; i < 10; i++) {
        Point2D r = chara[i].get_position();
        draw(CHARA_PICTURE, r.x, r.y);
    }
}

どうでしょうか。素朴です。入門書のサンプルコードの様に普通に読み下せるのではないでしょうか。コード中では更新処理ブロックを3つ以上書けています。10個でも100個でもいくらでも書けます。キャラクターリスト定義も別途弾丸リスト、当たり判定領域作業用リストなどを追加で定義できます。

改変版(改悪版)タスクシステムでは更新処理が1つか2つに制限されます。またリスト定義はTask型のもの1つだけです。自縄自縛状態が常でした。

劇的なものではないですが、たしかに数の差が表れています。改変版(改悪版)タスクシステムおよび間違った抽象化インタフェース導入が何をしでかしているのか、霧を生み出しているのは誰か、その証拠です。

改変版(改悪版)タスクシステム導入前との違い

改変版(改悪版)タスクシステム導入前の、設計方法がわからない状態に戻っただけではないか? すこし違います。プログラマ自身の経験が増えている、という点が。依存関係の複雑さでがんじがらめになって痛い目を見た経験から、その問題を避けるための努力を惜しまない気持ちが芽生えているはずです。それが行動や判断にすこし影響を与え、積み重なって大差として現れてくるのです。

普通の設計

設計力の段階分け

行動や判断とは何を指すのか。それを示すにはまず状況の把握が必要です。

個人的な意見ですがこんな感じの5段階に設計力は分かれます。

  1. プログラミング言語の文法を覚える。 →文字出力と文字入力の関数を使って文字だけのゲームが作れる。
  2. 入出力機器をプログラムから扱う方法を覚える。 →画像表示やジョイパッドを使ったテストアプリが作れる。
  3. 可変長配列、連結リスト、辞書構造などのデータ構造を覚える。 →1画面のミニゲームが作れる。テトリスとか。
  4. データ構造と制御構造について手探りレベルで設計方針を1つ立てて開発し、方針にそぐわないゲーム仕様を切り捨てられる →中規模のゲームが作れる。
  5. データ構造と制御構造について複数の設計方針を確立、たいていのゲーム仕様を実現できる →大規模のゲームが作れる。

もともとゲームの作り方がわからなかった人(1番)が改変版(改悪版)タスクシステム導入によりすこし前進できたという成功体験を得ることは確かにあります(2~3番)。それに気を良くしてまともなゲーム(4番)を作ろうとすると沼にぶちあたって沈みます。

実はまともなゲームプログラム(4, 5番)にはそれまでの実験用小プログラム(1, 2, 3番)には無かった新たな障害が発生します。そして改変版(改悪版)タスクシステムはその障害度合いを悪化させる性質があります。プログラマの行動・判断によって度合いは増減し、漫然と放置すると障害が拡大し続けて身動きがとれなくなります。

依存関係の複雑さ

障害とは具体的には依存関係の複雑さです。より具体的にはクラス定義、変数定義、関数定義などの間で参照や呼び出しにより生じた依存関係の数です。元となる定義数が多ければ多いほどそれらの関係性も指数的に増えていき、下手な設計では組み合わせ爆発を起こして手に負えない数になります。

すべての組み合わせをチェックしないと不具合が無いとは言いきれない、そして組み合わせ数は手に負えない数に膨れ上がっている。いつ終わるともしれない悪夢です。

関係性の組み合わせ爆発 vs きれいな構造

関係性の組み合わせ爆発の発生を抑え込む方法は基礎的なルールを守り通すことです。例えばグローバル変数渡しの代わりに引数で渡しましょうといった。

ソフトウェアが小規模なうちは基礎的なルールを破っても影響はあまりありません。先の箇条書きでいえば1~2番のうちは破ってもなんとかなります。しかし3番以上には対応できません。改変版(改悪版)タスクシステムを使うことで非効率ながら破ったまま3番に上がれたりしますが、それでも4番以降は無理です。基礎がなってないからです。

改変版(改悪版)タスクシステムから脱出するとまずは2番に戻った気持ちになります。そこから3番以降に上がる際には銀の弾丸は無いと心得ましょう。それまでおざなりだった基礎的なルールについて考えを改め、1つ1つ真摯に対応します。一度依存関係の複雑さで痛い目を見た経験があれば、それらのルール施工が複雑さを抑えている効果を実感できるはずです。

基礎的なルール

個人的に気を付けている基礎的なルールをいくつか挙げます。これらをしっかり守れば、改変版(改悪版)タスクシステム無しでもゲームは組めます。

  • 抽象インタフェースクラスは原則使わない。これは劇薬であって、絶縁力が高すぎて本来切ってはいけないものまで切ってしまいがち。決してちょっと見た目がきれいになるから程度の理由で持ち出すものではない。必要にせまられて仕方なく使う位でちょうどよい。
  • グローバル変数渡しの代わりに引数渡しを活用する。
  • 継承の代わりにメンバ変数で持つ
  • メンバメソッドはなるべく自身のメンバ変数にのみ依存する。複数の構造体にまたがるような処理は親クラスのメンバメソッドに持ち上げるか、独立した静的関数として定義する。
  • データ表現用の受動的な構造体を意識して作る。Vector3のような扱いやすさが良い。
  • タイマー同期処理の中で処理する。タイマー非同期処理中に受け付けたデータはいったんバッファリングして、タイマー同期処理内で取り出して処理する。バッファリング方法にはバッファ変数を用意する、キューを使う、ハンドルとトリプルバッファを使うといった方法がある。

最後のタイマー同期処理、非同期処理はまた別の構成もあると思います。ただ同期処理と非同期処理の併存は比較的難しいので、最初はタイマー処理を基本とするのがよいでしょう。

あとがき

前の記事、抜け穴示すとか言いながらタスクシステム叩いただけで終わってたわ。続き書いたわ。
10年以上前のUnityとか無かった頃のメモをベースにしてるので内容古いかも。