さて、いよいよ PC-8001 で弾幕を実現したいと思います。今回はC# で実装したリスト構造を Z80で実現して、可能な限り高速化を図ってみたいと思います。


  • 画面の初期化
弾は画面全域に配置されます。そのため、弾の色で全画面を初期化して、弾の移動ではアトリビュートを操作しないのが良いです。一番数が出ている対象物を高速化対象の最優先で考えるのが、レトロPC系のプログラムでは重要です。

今回は弾は白のセミグラフィックとして、画面を初期化します。初期化はなるべく高速で処理するように記述しました。メモリが許すのであれば、push を羅列して、B レジスタによるループ回数を減らすと良いでしょう。push による画面の初期化はこちらでも解説しています。
;
; VTextをセミグラの白色で初期化する
;
GRPClear:
ld (.stack), sp
ld d, ATRB.White | ATRB.SemiGrph;
ld hl, ADRS.VTextEnd
ld sp, hl
ld hl, 0
ld e, CRTC.Digits
ld c, CRTC.Lines
.clear ld b, ATRB.Max - 1
.loop1 push de
djnz .loop1
ld e, 0
push de
ld e, CRTC.Digits
ld b, CRTC.Digits / 2
.loop2 push hl
djnz .loop2
dec c
jr nz, .clear
.stack equ $ + 1
ld sp, 0
ret


  • 弾ワークの初期化
先のワークエリアの確保で描画アドレスを用意したのは、以前の位置を消去するためです。弾の存在確認はリスト構造だと不要です。この存在確認というのは、対象が多ければ多いほど、処理速度の足かせとなります。その点、リスト構造であれば存在リストを追いかけるだけですから、判定が不要な分、さらに高速化が期待出来ます。

弾の構造体に次のワークという2バイトのワークを用意しています。最初は全て未使用ですから、全てのワークを空きリストの先頭から数珠つなぎで並べてしまいます。
;
; リストの先頭アドレス
;
WORK:
.Empty ds 2 ; 空リスト
.Valid ds 2 ; 使用中リスト

;
; 弾のワークを初期化する
;
BLTInit:
ld (.stack), sp
ld bc, BLT.MAX * 256 ; B ループ数
ld hl, WORK.Bullet ; HL 最初のワーク位置を
ld (WORK.Empty), hl ; 空リストの先頭に入れる
ld de, BLT.SIZE ; DE 弾ワークサイズ
.loop ld sp, hl ; SP ワークの次のアドレス位置
add hl, de ; HL 次のアドレスに
inc sp
inc sp
push hl ; ワークに次のアドレスを格納
djnz .loop ; ループする
pop af ; 最後は
push bc ; 次のアドレスを 0 にする
.stack equ $ + 1
ld sp, 0
ld (WORK.Valid), bc ; 使用中アドレスを 0 にする
ret
これで、空のワークアドレスを取得する際は WORK.Empty を参照するだけでOKです。弾の移動処理は、WORK.Valid から辿れば全てを処理出来ます。但し、弾の発射や移動終了毎に、このリストが途切れないように管理する必要はあります。


  • 弾を発射する
弾を発射するには、空きエリアの確保からです。リスト構造で初期化してありますので、空きワークは WORK.Empty に書いてあるのを読むだけです。ただ、可能性として、既に空きがなくなっている事もあります。その場合は、この値は $0000 になっていますので、それは判定する必要があります。
1リスト最初の状態
空きエリアのアドレスを使う時は、そのワークに保存されている BLT.NEXT アドレスを、WORK.Empty に書き込みます。これで空リストが切断されず繋がります。
2空リストから取り外す
一方、新しく追加する弾の次のアドレスには、現在の WORK.Valid の値を書き込みます。そして、この追加するワークの先頭アドレスを、新しく WORK.Valid に追加するワークアドレスを設定します。これで使用中リストも繋がったままとなります。
3使用中リストに繋ぎ替える
; 弾を発射する
; Acc : 発射方向
; DE : 発射位置 X,Y (160x50)
;
BLTLaunch:
ex af, af’ ; Acc 角度待避
ld HL, (WORK.Empty) ; HL 空きエリア先頭
ld a, h
or l ; $0000 確認
ret z ; 空きがなければ終了
ld c, (hl)
inc hl
ld b, (hl) ; BC 次の空きアドレスを
ld (WORK.Empty), bc ; 空きリストの先頭に
ld bc, (WORK.Valid) ; BC 使用中リストアドレスを
ld (hl), b
dec hl
ld (hl), c ; 使用中の次のアドレスとして
ld (WORK.Valid), hl ; 自分を使用中リストの先頭とする
inc hl
inc hl
ex af, af’ ; Acc アドレス復帰
ld (hl), a ; 角度
inc hl
ld (hl), $80 ; Y座標小数部
inc hl
ld (hl), e ; Y座標整数部
inc hl
ld (hl), $80 ; X座標小数部
inc hl
ld (hl), d ; X座標整数部
inc hl

ld a, e ; Acc Y座標
and %11111110 ; Y座標のLSBを0にして2倍相当に
add a, GRPVTextOffset ; テーブルの下位アドレスを加算
ld c, a
ld b, GRPVTextOffset / 256 ; HL VTextオフセットテーブル
ld a, (bc) ; Acc VText下位アドレスに
add a, d ; X位置を加算
ld (hl), a ; 描画アドレス下位を格納
inc hl
inc c ; テーブルアドレス更新
ld a, (bc) ; Acc VText上位アドレスに
adc a, 0 ; 桁上がり加算
ld (hl), a ; 描画アドレス上位を格納
ret
初期化では、初期位置の小数部に 0x80 を格納する事で 0.5 を加算しています。これは、なるべく最初から動きやすくするためです。0x00 を格納すると、すぐに動き出さない可能性があります。10進数で説明すると 0.0 + 0.9 = 0.9 だと整数部に変化が出ないため動きませんが、0.5 + 0.9 = 1.4 だと整数部に動きがあるため、すぐに動きが反映されます。


  • 弾を移動する
弾の移動はいくつかの処理部に分かれます。一番最初は処理対象アドレスと以前のワークアドレスの取得です。一番最初ですから、以前のワークは $0000 として無かった事にします。

ld hl, (WORK.Valid) ; 現在のワーク
ld de, 0 ; 以前のワーク
最初に現在のワークが有効かどうかを確認します。無効であれば即座に終了します。また、複数の弾処理では最初にここに処理が戻ってくるため、.loop とラベルを付けておきます

.loop ld a, h
or l
ret z ; 現在のワークが 0 なら終了
さて、ここから先の処理では、弾の座標移動を行いますが、既に HL, DE のレジスタを使っていますので、これでは複雑な処理は何も出来ません。そのため、現在のアドレス、以前のアドレスに加えて、次のアドレスも一旦スタックに待避してしまいます。

私の Z80コーディングのクセなのですが、一つスタックを積んだら(pushしたら)、コメントで [ を一つ追加します。二つ目を積んだら [[ と二重括弧としてコメントします。これで、スタックの深さを気にしながらコードが組めるようになります。スタックを戻したら(popしたら)]] と括弧閉じるをコメントします。アセンブラで暴走する危険要因の大半がスタックなので、このようにコメントしています。ret 時点で括弧の数がゼロに戻っていなければ、確実に暴走します

push hl ; [HL 現在のワークアドレス
push de ; [[DE 以前のワークアドレス
ld e, (hl)
inc hl
ld d, (hl) ; DE 次のアドレス
push de ; [[[DE 次のアドレスを待避
ワークの角度を拾って、XY移動成分を取得します。HL が現在のワーク位置なので、少しずつ移動させながら値を読み込んでいます。三角関数呼び出しで DE,BC に各成分値が返却されます。

inc hl
ld a, (hl) ; Acc 角度
inc hl
call TrigMtrc ; 角度からXY成分取得
いよいよ移動処理です。最初にY座標から処理します。

ld a, (hl) ; Acc Y座標小数部
add a, c ; Y移動小数部を加算
ld (hl), a ; 新しいY座標小数部を保存
inc hl
ld a, (hl) ; Acc Y座標整数部
adc a, b ; Y移動整数部を加算
ld (hl), a ; 新しいY座標整数部を保存
ld c, a ; C Y整数部を待避
inc hl
HL ポインタから Yの小数部を読み込み、Y成分の小数部と加算します。この結果 CF が変化しますので、それが壊れない命令だけを使用して、加算結果をワークに戻して、次の Y 整数部の値をポインタから読み込みします。16ビットの inc / dec では CF は変化しないので、こういう時は便利なのです。

読み込んだ Y 整数部に Y成分整数値を CF フラグを加味しながら加算します。これで、下位からの桁上がりが反映されます。加算結果は元のワークに戻しますが、あとでアドレス計算に Yの整数部は必要なので、空いている C レジスタにコピーしておきます。

ld a, (hl) ; Acc X座標小数部
add a, e ; X移動小数部を加算
ld (hl), a ; 新しいX座標小数部を保存
inc hl
ld a, (hl) ; Acc X座標整数部
adc a, d ; X移動整数部を加算
ld (hl), a ; 新しいX座標整数部を保存
inc hl
続いて X座標の更新処理です。やってる事は Y座標と変わりません。X座標の整数部は Acc に入ったままなので、そのまま画面範囲判定を行います。

cp 80 ; Xが画面範囲外なら
jr nc, .outsc ; .outsc 終了処理に
ld b, a
ld a, c ; Acc Y座標が
cp 50 ; 画面範囲外なら
jr nc, .outsc ; .outsc 終了処理に
仮想的な画面サイズは縦方向は50となります。cp の結果、CF = 0 という事は画面範囲外なので、終了処理 .outsc にジャンプします。X座標判定してから Acc の内容を空いている B レジスタに待避しているのは、少しでも余分な処理を入れたくなかったためです。また、条件判定で jr を用いているのは、最も流れるであろう下方向の処理サイクル数が少ないためです。もし、.outsc にジャンプするほうが多いのであれば、これは逆に jp とするか、あるいは判定方法を逆にします。

これで XYは無事移動して、まだ画面内にある事も判定出来ましたので、描画アドレスを算出します。消して描いての処理のためにアドレス計算して、さらにワークにも保存しています。実は移動や描画よりもっと重い、シューティングゲームで最も重い処理は当たり判定なのですが、私は PC-8001では、この描画アドレスだけで判定してしまおうと思っています。自機の当たり判定は狭く、敵機の当たり判定は広く、これが私のモットーとなります。スレスレで回避すると気持ちいいよね?

rra
push af ; [AF Y座標の最下位ビットを待避
sla a ; 25行単位のY位置を2倍相当に
add a, GRPVTextOffset ; テーブルの下位アドレスを加算
ld e, a
ld d, GRPVTextOffset / 256 ; DE VTextオフセットテーブル
ld a, (de) ; Acc VText下位アドレスに
add a, b ; X位置を加算
ld c, (hl) ; C 以前の描画アドレス下位
ld (hl), a ; 描画アドレス下位を保存
ld iyl, a ; IYL にアドレス下位を保存
inc e ; テーブルアドレス更新
ld a, (de) ; Acc VText上位アドレスに
adc a, 0 ; 桁上がり加算
inc hl
ld b, (hl) ; BC 以前の描画アドレス
ld (hl), a ; 描画アドレス上位を保存
ld d, a
ld e, iyl ; DE 描画アドレス
Acc に残っている Y整数値を右シフトして LSB を CF に取り出します。Acc の値も必要なので ex af,af' で待避できないため、フラグを保存するためスタックに積みます。続いて左シフトして LSB は 0 にします。これで 25行相当の2倍の値が Accに入りますので、座標テーブルアドレスに変換します。

あとはここからデータを引っ張りながら X座標をアドレスに加算していくのですが、下位アドレスの待避に空きレジスタがなかったので、ここで禁断の未定義命令を使用しています。ld iyl,a は、インデックスレジスタ IY の下位に値を入れる動作をします。push / pop やメモリに入れるよりは高速です。
※ それでも遅いけど…
…あれ?折角用意した GRPVTextAdrs というアドレス計算サブルーチンを使っていないですね。そうなんです、実行速度を考えると、こういう速度が必要な部分で、かつ使用レジスタを使い切ってる状態だと、サブルーチンコールする余裕すらないのです…。このサブルーチンは結局使いませんでしたが、まあ、テキスト表示などで使う事もあろうかと…
※ 仮想で縦50行なのでまず間違いなく不要です。

; 現在の弾を消去する
xor a
ld (bc), a ; 以前の描画を消す

; 新しい位置に弾を描画する
pop af ; ]AF Y奇数 CF フラグ
jr c, .under
ld a, $33 ; Acc 弾パターン上付き
db $21 ; LD HL,nn 2バイトジャンプ
.under ld a, $CC ; Acc 弾パターン下付き
ld (de), a ; ドットを描画
さて、先のアドレス計算で、こそーっと以前のアドレスを BC レジスタに抜き出してたので、その中に $00 を入れる事で以前の表示を消してしまいます。その後、Yの座標の LSB の状態が格納されているフラグを復帰して、CF の状態によって、弾の形状を決めています。ここで、ちょっと変わったジャンプテクニックを使っています。

pop hl ; ]]]HL 次のワークアドレス
pop af ; ]]AF 以前のワークは捨てる
pop de ; ]DE 現在のワークを以前のワークとする
jp .loop
これで全ての処理が完了したので、次の弾に処理を移行するため、保存しておいたレジスタの情報を復帰します。ここでちょっとしたテクニックですが、ループ開始直後に、現在、以前、次回という順番にスタックに積みました。ここから戻すときに、次回を現在に、現在を以前に変更するため、pop の順番を意図的に変更しています。意外と16ビットレジスタの値の入れ替えって面倒なんですよね…。

これでレジスタの入れ替えも完了しましたので、ループ先頭である .loop にジャンプして、処理の継続となります。
究極タイガーヘリ - Switch

エムツー
2021-10-28


  • 弾移動の終了/リストの繋ぎ替え
画面範囲外に弾が移動した場合は、まずは現在の弾を消してしまいます。

.outsc ld e, (hl)
inc hl
ld d, (hl) ; DE 以前の描画アドレス
xor a
ld (de), a ; 弾を消す
リストの繋ぎ替えのため、レジスタに待避していたアドレスを全て元に戻します。以前のアドレスが $0000 なら使用中リストの先頭書き換え、それ以外なら以前のワークの次のアドレス書き換えになるので、ここで処理が分岐します。可能性としては、先頭の書き換えのほうが少ないと思うので、jr の分岐でトップ繋ぎ替え処理に飛ばすようにしています。
※ ここは再考の余地があります。

pop bc ; ]]]BC 次のワークアドレス
pop hl ; ]]HL 以前のワークアドレス
pop de ; ]DE 現在のワークアドレス
ld a, h ; 以前のアドレスが
or l ; 0 なら
jr z, .vldtop ; 使用中リストの先頭を書き換える
以前のワーク HL の次のアドレスを、処理中の次のアドレス BC に置き換えます。その後、空のリストに今のワークを繋ぎ替える処理に飛ばします。
※ よく通る処理に無条件ジャンプは勿体ないなあ…

ld (hl), c ; 以前のワークに
inc hl ; 次のワークアドレスを格納する
ld (hl), b
dec hl
jp .seters ; 空のリスト更新に
使用中の先頭アドレスを書き換えるのはもの凄く簡単です、次に繋がるアドレス BC を使用中リストの先頭に配置するだけとなります。

.vldtop ld (WORK.Valid), bc ; 新しいアドレスをリスト先頭に
さて、今のワークは空になったので、空リストに接続します。

push hl ; [HL 以前のワークを待避
ld hl, (WORK.Empty) ; HL 空リスト先頭
ld (WORK.Empty), de ; 空リスト先頭を現在のアドレスに
ex de, hl ; HL 新しい空リスト先頭
ld (hl), e
inc hl
ld (hl), d ; 以前の空アドレスを次にする
pop de ; ]DE 以前のワークアドレス
ld l, c
ld h, b ; HL 新しいアドレス
jp .loop
HL を使いたいので、一旦スタックに積みます。空のリスト WORK.Empty を取り出して、現在のワーク DE を WORK.Empty にぶっ込みます。空のリストに入っていたアドレスは今まで空の先頭だったので、現在のワークの次のアドレスに、今まで空だったアドレスを入れる事で数珠つなぎが完了します。

あとは、以前のワークをスタックから復帰して、次のアドレスを現在のアドレスに変更したら、弾移動のループトップに戻して処理を継続します。

以上で弾幕の実装が完了しました。テストでは、一定間隔毎に画面中央付近から、32方向に一斉に弾を発射させてみました。だいたい画像と同じ速度で PC-8001 実機でも動作します。(たぶん)
danmaku
記事の途中で軽く触れていますが、ここからゲーム化した場合は、今の速度では絶対に動きません。何しろ最も重い処理である「当たり判定」が入っていないのですから。それでも、単純な表示でここまで速度が出てくれると、なんとなく嬉しいですよね。PC-6001mkⅡだとこの速度は出なかったので…。

この動作可能なプログラムは以下からダウンロード出来ます。
angle.zip

※ 2021/12/20 追記
VRAMアドレス算出ルーチンにバグがあったので修正しました。動作上、このルーチンを使用していなかったので、気がつくのが遅れました。

実行は MON<ret>L<ret> として読み込んだ後、GD100<ret> としてください。実行中に [Esc] を押すと終了します。参考になれば幸いです。