PC-8001 1ドットスクロールゲーム
長年、(某ゲームに似ている)PC-8001用の1ドットスクロールゲームを作っています。
最初のきっかけは、単にPC-8001でフルカラーの1ドットスクロールをしてみたかったからだけです。
昔昔、中高生の頃にやりたくてもできなかったことです。今の環境と能力ならできると思い立って、久しぶりにZ80アセンブラプログラムを始めました。
2018/12/24に左上に"Q"を出力するプログラムを書いてからはや7年以上。これは完成しませんが(遅すぎですねー)、その他いろいろ楽しくプログラミングを続けています。
さて、誰に役に立つかはわかりませんが、一応一部を書き記しておくことにしました。興味あるお方に読んでいただければ幸いです。
今回は(次回があるか不明)マップの表示とスクロール処理についてです。
画面サイズ
ゲーム画面のスクロール部分のサイズは、64x25キャラクタとしています。
最初のバージョンでは高速化のために都合が良かったので横は64キャラクタになっているのですが、現在のバージョンではおそらくその必要がないはずです。将来的には縦横比を考えて少し増やすかもしれませんがこれは余談。
縦は24行にしていた時もありますが、とりあえずは目一杯使うことで25行に。メモリや定数さえ変更すれば行数は変えられるはずですが、本当に変えたときちゃんと動作するかどうかは神のみぞ知るというところです。
オリジナルの半分のサイズの28(112ドット)行を縦スクロールで全部表示させるテストも行ってみましたが、速度とメモリ容量の影響も多くて使用しないことにしました。どうせオリジナルと同じようにできない部分も多々あるので、ここは諦めます。
また、残機やスコアはスクロールエリア外に表示して、メインループ時には書き換えしないようにします。当然、重いし色が短所になるので重ね合わせなどできませんので。
メモリ
マップ処理のワークエリアは以下のようなアドレスになっています。
MapVram1/MapVram2はマップデータを展開したキャラクタデータのワークエリアで、AttrVramがアトリビュートデータを展開したワークエリアになります。
$CE00~$CE3F: MapVram1 0行目
$CE40~$CE7F: MapVram2 0行目
$CEBF : $00
$CEC0~$CEFF: AttrVram 0行目
$CF00~$CF3F: MapVram1 1行目
$CF40~$CF7F: MapVram2 1行目
$CFBF : $00
$CFC0~$CFFF: AttrVram 1行目
...
$E600~$E63F: MapVram1 24行目
$E640~$E67F: MapVram2 24行目
$E6BF : $00
$E6C0~$E6FF: AttrVram 24行目
\$BFの00はアトリビュート処理用の番兵データで0固定の値になります。
このメモリの使い方だと当然\$80~\$BEまでは空いて無駄になってしまうので、キャラクタデータを詰め込んでいっています。ここは多少の無駄が出ていますが、とりあえず気にしないことにします。
当初アドレスの計算を高速化するために下位バイトをそろえるアドレスにしましたが、これだと横サイズが64バイト限定になってしまうし、また、今のバージョンではそうである必要もなさそうなので、メモリ効率のために配置を換える予定でいますが面倒でやっていません。そのうちやることでしょう。たぶん。
MapVram1とMapVram2は各行毎にリング構造となっていて先頭位置をスクロール毎にずらしていって、画面分64回をループしたらワークエリアの先頭に戻るような管理をしています。
最初は \$xx00が先頭で、63回スクロールが終ると\$xx3Fが先頭、\$xx3Eが末尾になり、次はまた\$xx00が先頭、\$xx3Fが末尾になるというわけです。
この後では物理的なアドレスは無視して、リング上になっている仮想的な位置で先頭と末尾と書くことにします。
これとは別にワークエリア(W_NextMapByteData)に、行数である25バイトの領域を用意しています。
これはスクロールにするごとにセットされる次のキャラクタデータを各行毎に1バイト用意している領域です。MapVram1の末尾にはこの値がセットされ、MapVram1の末尾データとこのデータを合わせてシフトすることで、MapVram2の末尾には横に1ドットずれた値がセットされます。
生成手順によっては、この25バイトは不要にすることもできるかもしれませんが、これまた修正が多そうだったのでいったんこのままにしています(いつか修正するかも)。
表示方式
基本方針は以下になります。
- マップデータを展開したワークエリアを二つ用意します。
- 一つは通常のデータ(MapVram1)で、もう一つはそれを1ドットシフトしたデータ(MapVram2)です。
- メインループ内で、MapVram1とMapVram2を交互にVramに転送して表示します。
- アトリビュートは通常とシフトで共通で使えるはずなので1つのワークエリアを用意します(AttrVram)。
- 1キャラクタ毎に1バイトを使って8001のセミグラフィックのカラーコードをそのまま書いておきます。
- メインループで毎回アトリビュート圧縮データからAttrVramに展開します。
- キャラクタデータの描画後に、AttrVramを頭から解析しながらVramのアトリビュートを作っていきます。
- 各キャラクタの描画はマップデータのVramへの転送後に直接Vramへ書き込みますが、色は白1色としてAttrVram上に白(\$F8)を書き込みます。
- Vramはちらつき防止のためにダブルバッファリングを使っています。
アトリビュートデータは毎回AttrVramから本物のアトリビュートに変換していくので結構時間が掛かります。
しかし、あとでキャラクタを合成するとき、PC-8001のアトリビュートの変更をしていくのは重いため、キャラクタが多くなると遅くなると判断し、表示のときに毎回変換することにしました(内藤さん作NewCityHeroのソースをみたら、キャラクタ表示時にアトリビュートを毎回展開と作成を繰り返していたのを後で知りました)。
PC-8001のアトリビュートは、4x2で同一色である必要があります。
各色の境界線でそれをなんとかしなければなりません。黒のボーダラインを引くことである程度ごまかせますが、一部斜めのラインの部分が悩ましいところです。黒の領域を多くすると解決するのですが、その場合は黒部分が多すぎて1ドットスクロールがカクカクのスクロールにみえるため、1ドットまではぱかぱか変化する色の変化を許容することにしました。
黄色と緑の変化はおもったより目立ちませんが、黄色と青の境界は結構目立ちます。少しでも目立たないようにするため、色によって黒の領域を調整することにしますがそれは別の話。
メインループ(表示関連)
以下のような順序で行っています。
- シフト無しマップデータをMapVram1からVramへ転送
- アトリビュート圧縮データをAttrVramに展開
- キャラクタ描画(VramとAttrVramへ書き込み)
- AttrVramからVramのアトリビュート領域を作成
- VSync待ちしてダブルバッファリングの切り替え
- マップ圧縮データを、各行展開して1キャラ分をW_NextMapByteDataに設置
- MapVram1の末尾データとW_NextMapByteDataからMapVram2の末尾データをシフトして生成
- シフトあるマップデータをMapVram2からVramへ転送
- アトリビュート圧縮データをAttrVramに展開
- キャラクタ描画(VramとAttrVramへ書き込み)
- AttrVramからVramのアトリビュート領域を作成
- VSync待ちしてダブルバッファリングの切り替え
- MapVram1の各行の末尾データをW_NextMapByteDataのデータで埋める
処理時間がかかるのは、1,8と4,11です。キャラクタが多かったりアンドアジェネシスが出現しているばあいは、3,10も長くなります。
4,11は、各行64バイトのデータを先頭から同一色の並びを計算しながらアトリビュートデータを作るため時間が掛かります。これはいろいろアルゴリズムを変更しながら高速化をしていきました。色の変化が大きいほど時間が掛かるため処理時間には多少のブレがあります。詳細は後述します。
6に関しては圧縮したマップデータを展開しながらの設置になるので、圧縮手段と直接関わり合いがあります。それなりに高速な圧縮方法にする必要があります。
マップデータの転送
1,8に対応する部分です。
MapVram1/MapVram2のデータ64x25バイトを各行LDIの羅列で転送しています(今は32個の羅列)。
MapVram1の先頭と末尾の間は完全な連続で無くリング化している部分に断絶があるので、2回に分けた転送になります。
ここはほぼ単純に(166424+α=)24576+αのステート数が掛かります。多分あまり効率のあげようもありません。
マップデータとその展開
マップはオリジナルのマップを半分に縮小して1024x512ドットを想定しています。PC-8001なので2x4ドットサイズでキャラクタやアトリビュートデータを作るため、512x128バイトのデータを作ることになります。もちろんそのままでは全くメモリに収まらないので圧縮することになります(デモではTinyゼビウスmk2のマップに合わせて384x128バイトになっています)。
エリア毎に圧縮データを展開することはせず、圧縮されたままマップを描きながら必要部分だけ展開していく方法にしました。
各行毎に、簡単なランレングスや4バイト/8バイトの特定パターンなどを適当なビットで表す方法にしていますが、もっと圧縮率を高めることもできると思います。その辺は後で変わる可能性も高いです。
今のところ、128行分のキャラクタデータを拡張RAM8kBに納めることも無理で、不要な行をコメントアウトして量を減らしてデモしています。
展開時は、表示している部分の末尾1バイトずつ取得するのが効率的なためそれができる構造が必要です。今回は、ワークエリアに今のマップデータの情報、長さと次のマップデータのアドレスを保存しています。
長さの分だけ同じデータを使うか、特定アドレスからのデータを転送するかなどを決めます。長さが0になったら次のアドレスのデータから展開を行うようにします。
アトリビュート圧縮データとその展開
アトリビュートデータは、ループ毎に展開する必要があるため単純なランレングスだけで圧縮していますが、ほとんどの場所で同じ色が続くので圧縮効率はよく、おおむね3kB程度になっています。
色を表すためには3ビットで十分なので、色3ビット+長さ5ビットで表しています。
長さが0の場合は次のバイトを長さとします。
各行先頭アドレスをワークエリアに保存して、毎回そのアドレスからデータを取得し、同一色を長さの数AttrVramに書き込むという処理を、各行64バイトになるまで繰り返していきます。
同一色が連続することが多いため高速化として、push で2バイトずつ書き込みます。このため、AttrVramは座標と逆順になっています(X=0が\$xxFF,X=63が\$xxC0)。
; アトリビュートデータを展開してアトリビュートバッファに書き込む
8FAA setAttrDataToAttrMap:
8FAA ED730D90 20 ld (.spWork), sp
8FAE DD212783 14 ld ix, NextMapAttr.workTop
8FB2 2100CF 10 ld hl, AttrVram.top + AttrVram.lineSize ; 右端から
8FB5 0619 7 ld b, AttrVram.lines
8FB7 .vLoop
8FB7 F9 6 ld sp, hl
8FB8 D9 4 exx
; b' = 行数
; hl' = アトリビュートバッファ
; hl = データアドレス
8FB9 DD6E32 19 ld l, (ix + NextMapAttr.nextAddrL)
8FBC DD664B 19 ld h, (ix + NextMapAttr.nextAddrH)
8FBF DD7E19 19 ld a, (ix + NextMapAttr.length)
8FC2 B7 4 or a
8FC3 2011 12 jr nz, .notDecode
; 前のデータが終わっていたら次のデータを読む
8FC5 CD1090 17 call decodeAttrData2
8FC8 DD7532 19 ld (ix + NextMapAttr.nextAddrL), l
8FCB DD744B 19 ld (ix + NextMapAttr.nextAddrH), h
8FCE DD7300 19 ld (ix + NextMapAttr.color), e
8FD1 DD7019 19 ld (ix + NextMapAttr.length), b
8FD4 1804 12 jr .decoded
8FD6 .notDecode
8FD6 DD5E00 19 ld e, (ix + NextMapAttr.color)
8FD9 47 4 ld b, a
8FDA .decoded
8FDA 0E20 7 ld c, ScrWByte / 2 ; push回数
8FDC .nextColor
8FDC 53 4 ld d, e ; d = e = 色
; b = 長さ(1以上)
8FDD CB38 8 srl b ; b = b / 2
; 元の長さが1だったときのみz=1でジャンプ
8FDF 281B 12 jr z, .oneLength
8FE1 08 4 ex af, af' ; cyをいったん保存
8FE2 78 4 ld a, b
8FE3 91 4 sub c ; 残りpush数とデータのpush数を比較
8FE4 301C 12 jr nc, .last ; if c <= b 末尾まで処理する
8FE6 ED44 8 neg ; a = c - b
; 同一カラーを2バイトごと書く
8FE8 .writeAttr1
8FE8 D5 11 push de
8FE9 10FD 13 djnz .writeAttr1
8FEB 4F 4 ld c, a ; pushした後の残り長さセット
8FEC CD1090 17 call decodeAttrData2
8FEF 08 4 ex af, af' ; 最初の長さが奇数だったらcy=1
8FF0 30EA 12 jr nc, .nextColor
8FF2 .oddColor
; 奇数部分の処理
8FF2 D5 11 push de ; dは前の色、eは次の色
8FF3 0D 4 dec c ; 残り長さを1Push分減らす
8FF4 2810 12 jr z, .lastNoPush ; ちょうど残り0になった場合終了
8FF6 05 4 dec b ; 長さを1バイト分減らす
8FF7 CC1090 17 call z, decodeAttrData2 ; 奇数部分が1文字の色の場合次のデータを得る
8FFA 18E0 12 jr .nextColor
8FFC .oneLength
8FFC 53 4 ld d, e
8FFD CD1090 17 call decodeAttrData2
9000 18F0 12 jr .oddColor
9002 .last
9002 41 4 ld b, c ; 残りpush回数
9003 .lastLoop
9003 D5 11 push de
9004 10FD 13 djnz .lastLoop
9006 .lastNoPush
; 次の行へ
9006 D9 4 exx
9007 DD23 10 inc ix ; 次のワーク
9009 24 4 inc h ; 次のアトリビュートバッファ
900A 10AB 13 djnz .vLoop
900D .spWork equ $ + 1
900C 310000 10 ld sp, 0
900F C9 10 ret
; アトリビュート圧縮データのデコード
; Input
; hl = データアドレス
; ix = ワークエリアアドレス
; Output
; hl = 次データアドレス
; e = アトリビュートデータ
; b = 長さ
9010 decodeAttrData2:
9010 7E 7 ld a, (hl)
; * 0b0aaallll 長さが16バイト未満
; * 0b1aaa1100 , length 長さが16バイト以上の場合
9011 CB27 8 sla a
9013 2812 12 jr z, .nextArea
9015 3005 12 jr nc, .short
9017 5F 4 ld e, a ; アトリビュートデータ
9018 23 6 inc hl
9019 46 7 ld b, (hl) ; 長さ
901A 23 6 inc hl
901B C9 10 ret
901C .short
901C E6E0 7 and AttrColor.colorMask
901E F618 7 or AttrColor.Semigraphic
9020 5F 4 ld e, a ; アトリビュートデータ
9021 7E 7 ld a, (hl) ; もう一度読み直し
9022 23 6 inc hl
9023 E60F 7 and 00001111b ; 長さ
9025 47 4 ld b, a
9026 C9 10 ret
9027 .nextArea
9027 DD6E64 19 ld l, (ix + NextMapAttr.nextAreaL)
902A DD667D 19 ld h, (ix + NextMapAttr.nextAreaH)
902D C31090 10 jp decodeAttrData2
キャラクタの表示
キャラクタデータはダブルバッファリングの裏のVramへ直接書き込みます。
色はAttrVramからVramへの転送前に、単純にAttrVramの対応する位置に白を書き込みます。
アトリビュートデータの生成
PC-8001のアトリビュートデータをAttrVramから毎回生成します。アトリビュートはX位置と色データの組み合わせが20回あるので、AttrVramの先頭から同一色の連続を計算してアトリビュートに書き込むという処理を繰り返します。
生成の詳細
1行分の処理は以下のようになっています。
; アトリビュートを計算して転送する
; 転送先 de ダブルバッファVRAM
; 転送元 hl アトリビュートバッファ
; Output
; de 次の行のVRAM先頭アドレス
; hl 次のアトリビュートワークアドレス
8C34 transVirtAttrToVram:
8C34 0EC0 7 ld c, AttrVram.offset
8C36 0614 7 ld b, Vram.attrMax ;アトリビュート.最大回数
8C38 7D 4 ld a, l
8C39 .setColor
8C39 2F 4 cpl ; $ffがX=0
8C3A 3C 4 inc a
8C3B 3C 4 inc a
8C3C 12 7 ld (de), a ; 座標セット
8C3D 1C 4 inc e
8C3E 7E 7 ld a, (hl) ; アトリビュート値セット
8C3F 12 7 ld (de), a
8C40 13 6 inc de
8C41 05 4 dec b
8C42 C8 11 ret z ; 20回オーバー
8C43 .sameColor
8C43 2D 4 dec l
8C44 BE 7 cp (hl) ; 同色の間繰り返す
8C45 28FC 12 jr z, .sameColor
8C47 7D 4 ld a, l
; 仮想アトリビュートの最後までチェック済みか?
8C48 B9 4 cp c ; c = $c0
8C49 30EE 12 jr nc, .setColor
; 色指定完了
; b の回残りを埋める
8C4B .last2
8C4B 3EF8 7 ld a, AttrColor.white
8C4D EB 4 ex de, hl
8C4E 3642 10 ld (hl), ScrWByte + 2 ; スコア表示用に64文字目以降は白
#if DEBUG_INFO == 1
8C50 23 6 inc hl
8C51 77 7 ld (hl), a
8C52 23 6 inc hl
8C53 05 4 dec b
8C54 280E 12 jr z, .loopEnd ; if B == 0なら末尾
8C56 3648 10 ld (hl), ScrWByte + 8
8C58 3EE8 7 ld a, AttrColor.white & 11101111b
#endif
8C5A 0E50 7 ld c, Vram.dataWidth ; 残りは $50, 白で埋める
8C5C 1801 12 jr .restLoop2
8C5E .restLoop
8C5E 71 7 ld (hl), c
8C5F .restLoop2
8C5F 23 6 inc hl
8C60 77 7 ld (hl), a
8C61 23 6 inc hl
8C62 10FA 13 djnz .restLoop
8C64 .loopEnd
8C64 EB 4 ex de, hl
8C65 C9 10 ret
\$xxFFがX座標0の色なのでそこからアドレスをDECしながら同一色を数えていきます。
番兵の\$00では必ず色が変わるので、行の終了チェックはそのタイミングで行いますが、cレジスタが空いているのでループ外でc=$c0 としておいて、cp c とすることで cp $c0よりわずかですが早くなります。
bレジスタで最大変更回数をチェックしていますが、実際にはマップデータだけで20回になることは無いため、\$8C42 の ret z の部分は省略できます。
実際の画面は以下のように領域分けされています。
- X=0~1:白で残機
- X=2~65:マップ
- X=66~79:白でスコアなど
通常はアトリビュートは 0, 色, 位置, 色, .... 80, 色 のように先頭は位置を0として1文字目の色を指定します。
しかし、 「PC-8001におけるμPD3301のビックリドッキリな振る舞い」によると『実はスクリーン開始位置または行開始位置で属性はリセットされない。そのため、確実に初期値を設定しておかないと前の行の最後の文字に指定した属性がそのまま継続してしまうのである。スクリーンの最初の文字ならスクリーンの最後の文字の属性が継続してしまう。』とあります。これにより、位置0の指定がない場合には前の行の最後の色がそのまま引き継がれるようなので、色変化を1回でも多くしたい1ため、2から書くようにしています。
では最初の行はどうなるのでしょうか?実際には白になっているような気もしますが、よく分からないので、そこは使わない(キャラクタデータを00にする)ことにしました。
前の行の最終文字はかならず\$F9なので、先頭の残機表示も同じ$F9で良いためこの方法で良いと判断しました。
例: 2, $D9, 40, $E9, 64, $F9, 80, $F9 .... 80, $F9
マップのデータの流れ
関連資料
- CodeKnowledge / 内藤時浩
- PC-8001におけるμPD3301のビックリドッキリな振る舞い / 川俣晶
-
マップだけだと最大回数20回を超えることはありませんが、キャラクタやスパリオを入れると2回変化があるためすぐ足りなくなります。 ↩


