DEFENDER を移植してみた

Defender は1980年代のビデオゲームで、何度か遊んだ記憶はあるのですが難しくてあまり先には進めなかった様に思います。でもチリチリしたレーザーが強く印象に残っていて「いつかはあのレーザーが撃ちたい!」と思っていたのです。 

ソースコードが GitHub にある (ライセンスはたぶんグレーかも?) と知った時レーザーをどういう仕組みで描画しているのか気になって調べた事があるのですがせっかくなので全体を移植してみました。 

左右に無限スクロールできるタイプなので Quest3 でくるくる回る椅子に座ってプレイすると面白そうです。その方向で進めましょう!

 

CPUを調べる

この基板の CPU は 6809。私は Z80 や 68000 の方が得意なのですが入社して初めて書いたコードが 6809 だったので数年前に買った車のナンバープレートを 6809 にするぐらいには好きな CPU です。
スタックポインタが2組あったりダイレクトページレジスタがあったりなかなか面白い CPU ですね。
この時代 (コンシューマだと PS や SS 以前) は C など高級言語で書くのではなくアセンブラで直接書いています。


VRAM構造を調べる

グラフィックは 4bit/pixel の16色ですがカラーパレットで256色 (BBGGGRRR) から選択できます。
古いハードなので VDP やスプライトなどのハードウェア支援はありません。VRAM に直接ドットを書き込んでいます。VRAM 構造としては1ピクセル n バイト(1バイトなら256色、2バイトなら65535色)や、文字表示に有利な RGB 各プレーン毎に横8ドットで1バイトなどの形式をよく見かけますが、この基板はどうでしょう?

ソースコードの画像データと表示ルーチンを見ながら調べていきます。
わかりやすそうなのは500点取った時に表示される3色の「500」かな。

*500 SCORE
C5P1    FCB    6,6
        FDB    C5D10,C5D11,ON66,OFF66
C5D10   FDB    $FFF0,$FF00,$FF00
        FDB    $F000,$F0F0,$F000
        FDB    $EEE0,$E0E0,$EE00
        FDB    $E0E0,$E0E0,$E000
        FDB    $DDD0,$D0D0,$DD00
        FDB    $D0D0,$D0D0,$D000
C5D11   FDB    $0F0F,$0F00,$0F00
        FDB    $FF00,$FF0F,$FF00
        FDB    $0E0E,$0E0E,$0E00
        FDB    $EE0E,$0E0E,$EE00
        FDB    $0D0D,$0D0D,$0D00
        FDB    $DD0D,$0D0D,$DD00
先頭から w, h, キャラデータが2組, 描画ルーチン, 消去ルーチン。(wはこの2倍が実際の長さになります)
F,E,D が各桁のパレットコードっぽいですね。3色が奇麗に3ブロックに分かれているので横方向にスキャンしたデータではないようです。最初、90度回転した縦スキャンかと思ったのですがどうも違うようです。
試行錯誤の結果、横2ピクセル毎に1ライン下がれば
FFF EEE DDD
F   E E D D
FFF E E D D
  F E E D D
FFF EEE DDD
ばっちり字形が現れました。
C5D11 は1ドット右にずれたデータです。データを4ビットシフトしながら読めばキャラデータを半分にできますが速度優先でこのような形になっているようです。

描画ルーチンで確認すると、ON66 は各レジスタをセットした後 ON661 に飛んで
ON661   PULS   X,D,Y
        PSHU   X,D,Y
        LEAU   262,U

を6回繰り返しています。システムスタック(転送元)から6バイト POP してユーザースタック(転送先)に PUSH しています。
アドレスをインクリメントしながら LOAD/STORE するよりスタックを使って PUSH/POP した方が早いので非力な CPU ではよく使われる高速化技法です。262加算は PUSH で戻った6バイト+256バイト加算で元のアドレスの右隣になります。また、回数分ループせずにループ展開したコードの途中に飛び込むのも代表的な高速化技法です。

 

レーザーを読み解く

VRAM 構造がわかったのでレーザーの描画ルーチンを探します。
ソースコードを LASER で検索して見つかる LASER FISSLE TABLE とあるのがレーザーのチリチリした部分のパターンで 32byte(64dot) 分をプレイヤー初期化時にランダム (00,01,10,11) に初期化したものを循環利用しています。(fissle は核分裂性のとか裂けやすいとか剥離性のって意味らしいです)

描画ルーチンとして一番怪しいのが DISPLAY LASERS とコメントされた部分です。
#OF LASERS と書かれたワークの数だけ何かを描画していますが描画しているキャラは MINI SHIP ?
どうやら残機表示だったようです。コメントが嘘って良くありますよねw

本物の描画ルーチンは RIGHT LASER/LEFT LASER とコメントのある部分です。
レーザーの先端部分に 8dot 分レーザーを描画して最後に 2dot 先端の明るい部分を描画しています。最後の2dot分はアドレスを加算していないので先端は 8dot/tick の速度で進みます。

        LDA    #4
        LDX    PD,U
        LDB    #$11
        CMPX   #$9800
        BHS    LRDIE
LASR1   STB    ,X
        LEAX   $100,X
        DECA
        BNE    LASR1
        LDB    #$99
        STB    ,X
        STX    PD,U
チリチリした部分は 6dot/tick の速度で先端を追いかけて行きます。ポストインクリメントが使えるのが良いですね。レーザーは消滅まで消去していないのでチリチリと先頭の隙間は先頭部分のままです。
LASR2   LDX    PD+2,U
        LDA    #3
LASR3   LDB    ,Y+
        STB    ,X
        LEAX   $100,X
        DECA
        BNE    LASR3
        STY    FISX
        STX    PD+2,U
最後にレーザー末尾を 2dot/tick で消していきます。こちらもまだ消してない部分はチリチリが残ります。
        CLR    [PD+4,U]
        INC    PD+4,U

7色に光るレーザーの色指定は COLTAB 。2tick に1度このテーブルからパレットに書き込んでいます。
最近はあまり見かけない BBGGGRRR の8bitカラーです。

256x1 の R8 テクスチャをレーザー本数分用意してテクスチャを書き換えながら表示することにしました。

 

スプライト

自機や敵キャラも同様にVRAMに書いているのですが流石に重そうなので事前にテクスチャに書き込んでそれをスプライト (Quad) として表示することにしました。前処理で作っておくべきですが面倒だったのでゲーム起動時に毎回やっています。
手詰めのガバガバな詰め方でも128x64の1枚ですべて収まりました。

 敵弾と自機のエンジン炎は乱数で生成したテーブルをずらしながら参照して描画しているのですが事前にパターンを並べてアニメーションさせています。


地形

地形のデータは256バイトで各bitで標高が増えるか減るかを表すデータを持っています(2048ドット分)。これを起動時に標高データのテーブル(1024バイト)に展開しています。(横2ドットで共有)
画面上部のミニマップに表示するデータは横256ドット分別に持っていますが前後半同じ内容なので折り返しの処理を省くため2ループ持ってるようです。これはテクスチャの Tiling/Offset を使って1ループ分だけ持つことにしました。ミニマップと実際の地形が微妙に違っているようで気持ち悪かったので試しに地形テクスチャを縮小表示してみたのですが地形は合うもののラインが細くなって視認性が悪かったので元のままにしています。

 

敵を作る

敵のロジックはできるだけ忠実に移植していくのですが、入り組んだブランチ (GOTO文) を綺麗に C# の条件分岐に落とせるとなかなか気持ちいいですよ!
nフレームに1回実行したい場合は NAP n,label でnフレームスリープした後 label にジャンプしています。coroutine とかよりこっちの方がスマートな気がしますね (この機能がある言語あるのかな?) 今回はタイマーを持って if (--napTimer <= 0){ } にしました。

オリジナルのX座標は下位5bitが小数点の2048dot、Y座標は下位8bitが少数。固定小数点だと滑らかさに欠けるかな?とfloatにしたのですが読む時や判定がクソ面倒なのでそのまま移植したほうが楽だったかも。(たまに妙に速い弾が飛んでくるのはどこかで何か間違えてるせいだと思う)
あと360度スクリーンにした関係で「画面外」という概念がなくなっているのでその部分は変更が必要です。例えば敵の通常弾は視野中央の120度範囲に入った後、180度範囲を出たら消去、などとしています。

ゲームに関係ない部分は適当で良し。プレイヤーの爆発などオリジナルは100行ぐらいあるんだけど目コピで

    pex.velocity = Random.insideUnitCircle * Random.Range(0.3f, 2.0f);

で済ませたり、自分が興味のある部分以外は手抜きできるのが趣味移植の良いところ。

 

敵の爆発

最初ランダムな色で破片を飛ばしているのかと思ったのですが、ちゃんと敵キャラを4x4に分割したパーツが飛び散っているようです。ただキャラに1dotズレたデータがある関係上サイズを偶数で持っているので空白になってしまっているパーツがそこそこあって淋しいので正確なサイズにして空白になるケースを減らしています。あと四角く飛ぶのが嫌だったので斜め方向はちょっと遅くして同心円状に近づけてみました。

 

360度スクリーンにする

2Dのゲームが何となく出来てきたら360度のシリンダー状に表示する方法を考えます。
個々のオブジェクトを3D空間に配置しても良いのですが今回はゲームは2Dのままシェーダーで捻じ曲げることにしました。

    float _Radius;
    float _Width;

    v2f vert (appdata v)
    {
        v2f o;
        float4 localPos = v.vertex;
        float3 worldPos = mul(unity_ObjectToWorld, localPos).xyz;

        float theta = (worldPos.x / _Width) * UNITY_TWO_PI;
        float newX = sin(theta) * _Radius;
        float newZ = cos(theta) * _Radius;

        float3 bentPos = float3(newX, worldPos.y, newZ);

        float4 viewPos = mul(UNITY_MATRIX_V, float4(bentPos, 1.0));
        o.vertex = mul(UNITY_MATRIX_P, viewPos);
        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        return o;
    }
こんな感じ。頂点シェーダーで処理しているので頂点の無い部分は曲がりません。なのでレーザーや地形表示用のメッシュなど横にデカい物は Quad ではなく360度を64分割した短冊状のメッシュを作っています。

ちなみにパレットの参照もシェーダーで行っています。
    float4 _Palette[16];

    fixed4 frag (v2f i) : SV_Target
    {
        float index = (tex2D(_MainTex, i.uv).r * 15.0);
        fixed4 col = _Palette[(int)index];
        if (col.a < 0.1) discard;
        return col;
    }

オブジェクトの座標が元のままなので視錐台カリングで消えないように MeshRenderer の bounds を2mぐらいに設定しました(力技)。


フレームレート

オリジナルには Time.deltaTime 的な処理はなく 16.6ミリ秒で動く前提です。PCなら60Hzはサポートされていると思いますが Quest は72Hzが基本です。座標移動は基本 velocity に増分を入れて座標に加算する方式なので volocity に 60/72 を掛ければ良さそうですが試してもあまり違いを感じませんでした。パレットチェンジなどは下手に補正するとムラになりそうだったのでそのままにしています。なのでちょっと色替えが早いかも。

 

サウンド

サウンドは (不正に入手した) ROM イメージを使って MAME でサウンドテストモードに入って録音したデータを波形エディタで切り出してワンショットの SE として鳴らしています。
深く考えずに mp3 で書き出したのですが mp3 は Unity にインポートする時先頭に無音が入っちゃうんですね。Ogg Vorbis なら大丈夫みたいなのでループさせたいエンジン音だけ Ogg Vorbis で作り直しました。
でもループする時ちょっとプチノイズが入るみたい… 波形エディタでゼロクロス点で切り出した筈なんだけどなぁ?
プライオリティや複数音の組み合わせもオリジナル通りに再現。自機のエンジン音だけは別 AudioSource で再生して同時に鳴るようにしてみました。

 

未実装

  • HYPER SPACE (ランダムワープ) は未実装です。360度スクリーンにしたのでランダムな位置にワープすると見失うし、地形の方を回すのも360度にした意味が… もしやるならスタートレックのワープのように残像を残しつつ高速移動して視線誘導する感じかなぁ? やりませんが。
  • 地上の人が全員死ぬと地形が無くなって宇宙空間みたいなステージになるんだけど単純に面倒だったのとそろそろ飽きてきたのでw


 

豆知識

  • 敵の Bomber 、ただの四角が飛んできているのかと思ったらプログラム内の名前は TIE 。数機編隊で飛んでくるしスターウォースのタイ・ファイターを横から見た絵だったんですねw よく見ると向こう側の羽根もある。(スターウォーズは1977年、本作は1980年) 
  • 自機のエンジン付近の色が右向きと左向きで違います。赤と緑なので翼端灯かとも思ったのですが地球の翼端灯とは逆なので暗に地球の物語ではないと示唆しているのかもしれません。 (本当かよw)

  • Mutant の移動ルーチン
        LDD    PLABX            ;X  CLOSE?
        SUBD   OX16,X
        ADDD   #380
        CMPD   #$700
        BLS    SCZ6             ;SEEK Y

たぶん+-0x380で判定したかったんだろうけど$が抜けているので-0x17c~0x584と右が広くなっていると思う。
(本気移植だとこれを再現するかどうか悩む奴)



X(Twitter)はあまり長い動画載せられないのでわざと下手くそプレイしたのでもうちょっと真面目にプレイした動画を置いておきます。(でも下手くそ)


 

たまには古いゲームを読むのも今まで自分が使ってこなかったテクニックに触れられて面白いものです。
この規模のゲームをざっくり移植するだけなら1週間ぐらいでできるので時間のある時に何か移植してみては如何ですか?


前の記事 次の記事
No Comment
コメントを追加
comment url