MSXでスクロール処理 内部処理説明
ソースコードはこちら
https://github.com/sailorman-msx/games/tree/main/src/sample010
MAPデータの作成
さて、スクロール処理の説明です。今回のサンプルコードではdata_map.asmにMAPデータを定義しています。
MAPDATA_2BIT_TILES:
;
; MAPデータ 45x45 のサイズのマップを定義
; 2bitで1タイルを表現(横12x縦45=540byte)
; このデータをもとにして横45x縦45=2025byteのMAPデータをRAMに展開する
;
; +0 +4 +8 +12 +16 +20 +24 +28 +32 +36 +40 +44
;
defb $55, $55, $55, $55, $55, $55, $55, $55, $55, $55, $55, $40 ; + 0
defb $40, $00, $00, $00, $00, $60, $00, $40, $00, $00, $00, $40 ; + 1
defb $45, $55, $45, $14, $51, $44, $44, $55, $45, $15, $54, $40 ; + 2
defb $44, $00, $04, $04, $40, $44, $44, $41, $01, $00, $04, $40 ; + 3
defb $44, $55, $44, $44, $54, $44, $44, $01, $15, $15, $44, $40 ; + 4
defb $44, $40, $04, $44, $40, $44, $45, $51, $01, $00, $04, $40 ; + 5
(以下、略)
MAPデータは最初にExcel方眼紙でタイルデータをペタペタ作成して、4タイルで1バイト(2ビットで1タイル)になるようにしています。MAPの横45タイルを表現するのに必要なバイト数は12バイトです。ただし、12バイトだと12x4タイル=48タイル分になってしまうため、MAPデータをメモリ(RAM)に展開するときには3タイル分を取り除いています。MAPデータは読み込んだ後にWK_MAPAREAが指すアドレスに1タイル1バイトずつセットしています。
上図のExcel方眼紙に書いてある数字を"タイル番号(TILE#)"として定義していて、その数字が1バイトずつWK_MAPAREAに展開されるイメージです。
このMAPデータをタイルに展開して、WK_MAPAREAに埋め込んでいく処理をCreateMapArea(map.asm)サブルーチンで実施しています。
;--------------------------------------------
; SUB-ROUTINE: CreateMapArea
; マップデータを作成する
; MAPDATA_2BIT_TILESのデータをもとに
; WK_MAPAREAにタイル情報を生成する
;--------------------------------------------
CreateMapArea:
;--------------------------------------------
; MAPDATA_2BIT_TILESのデータを読み込み
; 1バイトあたり2タイルずつMAPAREAを埋めていく
;--------------------------------------------
ld hl, WK_MAPAREA
ld (WK_MAPAREA_ADDR), hl
ld hl, MAPDATA_2BIT_TILES
ld c, 45
;--------------------------------------------
; 横1列(12バイト分よみこむ)
;--------------------------------------------
CreateMapAreaLoop1:
ld b, 12
CreateMapAreaLoop2:
ld a, (hl) ; MAPのタイルデータ(2bit/1tile情報)を1バイト読み込む
; 読み込んだタイルデータを2ビットずつに分解してMAPAREAにセットする
call GenTileData
inc hl
djnz CreateMapAreaLoop2 ; Bレジスタの値をデクリメントしてゼロでなければ繰り返す
;
; 12バイト目の下位6ビットはMAPデータとしては不要なデータである。そのため
; 最後の6ビットを無効とするため、WK_MAPAREA_ADDRのアドレスを3バイト戻す
;
ld ix, (WK_MAPAREA_ADDR)
dec ix
dec ix
dec ix
ld (WK_MAPAREA_ADDR), ix
; Cレジスタの値をデクリメントして0になるまでCreateMapAreaLoop1を繰り返す
ld a, c
dec a
jr z, CreateMapAreaEnd
ld c, a ; デクリメントした値をCレジスタに格納する
jr CreateMapAreaLoop1
CreateMapAreaEnd:
ret
call GenTileData という箇所があります。それが1バイトを2ビットずつに分解してWK_MAPAREAに格納する処理になっています。WK_MAPAREA_ADDRという変数は「WK_MAPAREAのどのアドレスに値をセットするのか?」というポインタ変数になっています。
さて、タイル分割の処理ですが、GenTIleDataは次のようになっています。わりと単純です。
;-----------------------------------------------
; SUB-ROUTINE: CreateMapArea
; Aレジスタに格納されている情報を2bitずつに分解し
; MAPAREAに4バイト分格納する
;-----------------------------------------------
GenTileData:
push bc
ld c, a ; Aレジスタの値をCレジスタに退避
; MAPデータの格納先アドレスをIXレジスタにセット
ld ix, (WK_MAPAREA_ADDR)
; WK_MAPDATA_ADDR + 3に値をセットする
and 00000011B ; Aレジスタの値を00000011BでAND演算し、結果をAレジスタにセットする
ld (ix + 3), a
ld a, c ; Cレジスタに退避してある値を戻す
; WK_MAPAREA_ADDR + 2に値をセットする
and 00001100B ; Aレジスタの値を00001100BでAND演算し、結果をAレジスタにセットする
sra a ; 2ビット右にシフト
sra a
ld (ix + 2), a
ld a, c ; Cレジスタに退避してある値を戻す
; WK_MAPAREA_ADDR + 1に値をセットする
and 00110000B ; Aレジスタの値を00110000BでAND演算し、結果をAレジスタにセットする
sra a ; 4ビット右にシフト
sra a
sra a
sra a
ld (ix + 1), a
ld a, c ; Cレジスタに退避してある値を戻す
; WK_MAPAREA_ADDR + 0に値をセットする
and 11000000B ; Aレジスタの値を11000000BでAND演算し、結果をAレジスタにセットする
sra a ; 6ビット右にシフト
sra a
sra a
sra a
sra a
sra a
and 00000011B ; 右シフトしたときに第7ビットの値がのこっているためANDで消す
ld (ix + 0), a
; WK_MAPAREA_ADDRの値を4バイト進める
inc ix
inc ix
inc ix
inc ix
ld (WK_MAPAREA_ADDR), ix
pop bc
ret
第7ビット、第6ビットの処理だけ特殊で、右シフトしたあとに 00000011B でANDしていますね。これはSRAという右ビットシフト命令に癖があって、第7ビットが1だとずーっとそのビットを保持しているためです。
仮に右にシフトする前の状態が 10000001B だとした場合、SRAを1回実施すると、11000000B になります。第7ビットの値が不変なのです。
10000001B をSRA6回繰り返すと、11111110B になります。そのため、シフトが終わった後で、00000011B でAND演算することでその余計なビットも削除しているのです。このサブルーチンでは1バイトを4タイルぶんに分割して、WK_MAPAREAに4バイトぶんつめて、WK_MAPAREA_ADDRのポインタを4バイト進めてリターンします。12バイト目の後ろ6ビットぶん(MAPデータとしては3バイトぶん)は無効なデータであるため、呼び出し元で除去しているのがわかると思います。
ビューポート座標
ビューポートというのはMAP全体の一部分(見えてるところ)です。
見えてるところの左上の座標は、本来のMAP上での座標と一致させています。今回はこの座標を「ビューポート座標」と定義しています。
上図の場合、WK_VIEWPORTPOSX=0、WK_VIEWPORTPOSY=0としてMAP上の論理座標を特定し、そこから右に10タイル、下に10タイルぶんを表示すればいいんだな。という判定に使っています。
ビューポート情報の作成
次に「見えているところ」の画面情報の作成についてです。CreateViewPortというサブルーチンで行っています。ちょっと長いですが全量は以下のとおりです。この処理では、WK_MAP_VIEWAREAというメモリ上のアドレスにタイル情報を画面の情報(文字の集まり)に展開してセットしています。地味にしんどいです。
;-----------------------------------------------
; SUB-ROUTINE: CreateViewPort
; MAPデータ上の論理座標をもとにして
; ビューポート(WK_MAP_VIEWAREA)を作成する
; WK_VIEWPORTPOSX: ビューポートX座標
; WK_VIEWPORTPOSY: ビューポートY座標
; WK_MAPAREA_ADDR: マップ情報
; WK_MAP_VIEWAREA: ビューポート情報(20x20バイト)
; WK_VIEWPORT_ADDR: ビューポート情報作成用ワーク
;
; ビューポート座標はタイルデータと1対1の関係
;-----------------------------------------------
CreateViewPort:
; マップデータの先頭(左上)を決定する
ld hl, WK_MAPAREA
; 論理座標をもとにしてマップデータのY座標を求める
ld a, (WK_VIEWPORTPOSY)
; Y座標が0の場合はY座標の変換は何もしない
or 0
jr z, CreateViewPortLoop1End
ld b, a
CreateViewPortLoop1:
add hl, 45 ; HLレジスタに45を足すと次のY座標になる
djnz CreateViewPortLoop1
CreateViewPortLoop1End:
; 論理座標をもとにしてマップデータのX座標を求める
ld d, 0
ld a, (WK_VIEWPORTPOSX)
ld e, a
add hl, de; HLレジスタにX座標を加算する
;--------------------------------------------------------------
; 上記処理にてマップデータの座標(ビューポートの左上のデータ)
; が確定する
;--------------------------------------------------------------
ld (WK_MAPAREA_ADDR), hl ; WK_MAPAREA_ADDR変数にHLレジスタ(左上に表示すべきマップ座標)をセット
ld hl, WK_MAP_VIEWAREA ; HLレジスタにWK_MAP_VIEWAREAのアドレスをセット
ld (WK_VIEWPORT_ADDR), hl ; WK_VIEWPORT_ADDR変数を初期化
ld c, 10 ; ビューポート内部のタイル数は縦10タイル
CreateViewPortLoop2:
ld b, 10 ; ビューポート内部のタイル数は横10タイル
CreateViewPortLoop3:
ld hl, (WK_MAPAREA_ADDR) ; タイル情報をHLレジスタにセット
; Aレジスタに格納するとアドレスの下位1バイトが入ってくるから注意!!
ld a, (hl)
;-------------------------------------------
; タイル情報によってキャラクタコードを
; ビューポート情報にセットする
;-------------------------------------------
; タイル番号に4をかけると表示するキャラクターの左上が決定する
ld de, 0
ld hl, 0
ld e, a
ld h, 4
call CalcMulti
ld de, hl
ld hl, CHAR_TILES
add hl, de
ld ix, hl ; IXレジスタに1タイルの左上のキャラクターコードのアドレスをセット
; 左上のキャラクター情報をセット
ld hl, (WK_VIEWPORT_ADDR)
ld a, (ix + 0)
ld (hl), a
; 右上のキャラクター情報をセット
inc hl
ld a, (ix + 2)
ld (hl), a
; HLレジスタの値に19を加算してタイルの下部分をセットするアドレスに進める
add hl, 19
; 左下のキャラクター情報をセット
ld a, (ix + 1)
ld (hl), a
; 右下のキャラクター情報をセット
inc hl
ld a, (ix + 3)
ld (hl), a
; タイル情報のアドレスをインクリメント
ld hl, (WK_MAPAREA_ADDR)
inc hl
ld (WK_MAPAREA_ADDR), hl
; 1タイル分のセットが完了したため、WK_VIEWPORT_ADDRに2を加算する
ld hl, (WK_VIEWPORT_ADDR)
add hl, 2
ld (WK_VIEWPORT_ADDR), hl
djnz CreateViewPortLoop3
CreateViewPortLoop3End:
; 10タイル行分処理するまで繰り返す
ld a, c
dec a
jr z, CreateViewPortEnd
; WK_VIEWPORT_ADDRに20を加算する
; (20を加算すると下タイル行の先頭アドレスになる)
ld hl, (WK_VIEWPORT_ADDR)
add hl, 20
ld (WK_VIEWPORT_ADDR), hl
; タイル情報のアドレスに35を加算すると次の下タイル行となる
ld hl, (WK_MAPAREA_ADDR)
add hl, 35
ld (WK_MAPAREA_ADDR), hl
ld c, a
jr CreateViewPortLoop2
CreateViewPortEnd:
ret
CreateViewPortLoop1Endあたりまでで、「MAP上の論理座標のどの部分をビューポート情報とすべきなのか」を特定しています。
CreateViewPortLoop2以降が、実際にビューポート情報を作成している箇所になっています。以下、ちょっとしたテクニックの箇所です。
; タイル番号に4をかけると表示するキャラクターの左上が決定する
ld de, 0
ld hl, 0
ld e, a
ld h, 4
call CalcMulti
ld de, hl
ld hl, CHAR_TILES
add hl, de
ld ix, hl ; IXレジスタに1タイルの左上のキャラクターコードのアドレスをセット
これ、何をやっているかというとタイル情報の数字でタイルを構成するキャラクターを判別するために使っています。IXレジスタにはどのキャラを使えば良いのかその先頭アドレスがセットされます。今回は16ドットx16ドット=2x2キャラですね。CHAR_TILESはdata_map.asmで以下のようになっています。
CHAR_TILES:
; TILE#0
defb '$' ; +0 (床:左上)
defb '$' ; +1(床:左下)
defb '$' ; +2(床:右上)
defb '$' ; +3(床:右下)
; TILE#1
defb '&' ; +4 (ブロック:左上)
defb '&' ; +5 (ブロック:左下)
defb '&' ; +6 (ブロック:右上)
defb '&' ; +7 (ブロック:右下)
; TILE#2
defb $71 ; +8(ドア:左上)
defb $72 ; +9(ドア;左下)
defb $73 ; +10(ドア:右上)
defb $74 ; +11(ドア:右下)
; TILE#3
defb $75 ; +12 (ブロック:左上)
defb $75 ; +13 (ブロック:左下)
defb $75 ; +14 (ブロック:右上)
defb $75 ; +15 (ブロック:右下)
タイル情報の数字が1(TILE#1)だった場合、1に4をかけると4です。その数値をCHAR_TILESのアドレスに加算すると、TILE#1のアドレスが特定されるので、その内容にそってビューポート情報にキャラクタデータを書き込んでいる。というものになっています。
まとめると、CreateViewPortサブルーチンは以下の一連の処理をしています。
・ビューポート座標をもとにしてWK_MAPAREAからタイル情報を取り出す
・取り出したタイル情報をもとにしてタイルを構成する実際のキャラクター(文字)を決める
・1タイルあたり2x2バイトの情報をWK_MAP_VIEWAREAに詰めていく。
やってることは単純なんですが、けっこう疲れました・・・。
ビューポート情報を画面に出力する
上記処理で作成されたのはメモリ上にデータを詰め込んだだけなので、まだ画面には出力していません。そのため、メモリ上のビューポート情報を画面(VRAM)に出力する必要があります。DisplayViewPortサブルーチンがそれを実施しています。
;-----------------------------------------------
; SUB-ROUTINE: DisplayViewPort
; WK_MAP_VIEWAREAに格納されている20x20バイトの
; キャラクター情報をビューポート域に表示する
;
; ビューポートの左上はVRAMの1821H
;
;-----------------------------------------------
DisplayViewPort:
ld hl, WK_MAP_VIEWAREA
ld (WK_VIEWPORT_ADDR), hl
ld hl, $1821
ld (WK_VIEWPORT_VRAMADDR), hl
; カウンタ変数に20をセット(縦20行繰り返すためのカウンタ)
ld a, 20
ld (WK_VIEWPORT_COUNTER), a
DisplayViewPortLoop1:
ld hl, (WK_VIEWPORT_VRAMADDR)
ld de, hl ; DEレジスタ:転送先VRAMアドレス
ld bc, 20 ; BCレジスタ:転送量(横20バイト)
ld hl, (WK_VIEWPORT_ADDR) ; HLレジスタ:転送元アドレス
call LDIRVM
ld hl, (WK_VIEWPORT_ADDR) ; WK_VIEWPORT_ADDRのアドレスを20バイト進める
add hl, 20
ld (WK_VIEWPORT_ADDR), hl
ld hl, (WK_VIEWPORT_VRAMADDR) ; WK_VIEWPORT_VRAMADDRのアドレスを$20(32文字ぶん)進める
add hl, $20
ld (WK_VIEWPORT_VRAMADDR), hl
ld a, (WK_VIEWPORT_COUNTER)
dec a
jr z, DisplayViewPortEnd
ld (WK_VIEWPORT_COUNTER), a
jr DisplayViewPortLoop1
DisplayViewPortEnd:
ret
ビューポート情報はWK_MAP_VIEWAREAのアドレスに20x20キャラクターでできているので、20バイトずつをVRAMの画面の特定行に20行分、ペタペタ転送しています。これは説明いらない気がします。
CreateViewPortでビューポート情報を作成して、作成したビューポート情報をDisplayViewPortでVRAMに転送(画面に表示)
簡単な作りです。
あとはキャラクターの動きにあわせてWK_VIEWPORTPOSXやWK_VIEWPORTPOSYの座標を変えて、この2つの処理を呼べばMAP上の論理座標にあわせて画面がスクロールしているように見える。という流れになります。
プレイヤーの動きにあわせてMAP上の論理座標を変える
sprite.asmでUndoMoveというサブルーチンがありましたが今回、以下のように変更しています。(そもそも当たり判定のサンプルあたりから無駄なコードになっていた・・)
;--------------------------------------------
; SUB-ROUTINE: UndoMove
; 移動範囲外に移動した場合は表示位置を元に戻す
; 移動先のVRAM情報が床以外であっても表示位置を元に戻す
;--------------------------------------------
UndoMove:
;--------------------------------------------
; 座標制限判定
;--------------------------------------------
ld a, (WK_PLAYERDIST)
; 左を押されたときだけの処理
; 左スクロール
cp 7
jr z, UndoMoveLeftScroll
; 右を押されたときだけの処理
; 右スクロール
cp 3
jr z, UndoMoveRightScroll
; 上を押されたときだけの処理
; 上スクロール
cp 1
jr z, UndoMoveUpScroll
; 下を押されたときだけの処理
; 下スクロール
cp 5
jr z, UndoMoveDownScroll
jr UndoMoveEnd
UndoMoveLeftScroll:
ld a, (WK_PLAYERPOSX)
; スプライトのX座標が5以上であればスクロールは行わない
cp 5
jr nc, UndoMoveEnd
; ビューポートX座標が0の場合はスクロールはせずスプライトだけ動かす
ld a, (WK_VIEWPORTPOSX)
or 0
jr z, UndoMoveEnd
; ビューポートX座標を-1してビューポートを左にスクロールさせる
dec a
ld (WK_VIEWPORTPOSX), a
call CreateViewPort
call DisplayViewPort
jr UndoMoveSetOldPos
UndoMoveRightScroll:
ld a, (WK_PLAYERPOSX)
; スプライトのX座標が16でなければスクロールは行わない
cp 16
jr nz, UndoMoveEnd
; ビューポートX座標が35の場合はスクロールはせずスプライトも動かす
ld a, (WK_VIEWPORTPOSX)
cp 35
jr z, UndoMoveEnd
; ビューポートX座標を+1してビューポートを左にスクロールさせる
inc a
ld (WK_VIEWPORTPOSX), a
call CreateViewPort
call DisplayViewPort
jr UndoMoveSetOldPos
UndoMoveUpScroll:
ld a, (WK_PLAYERPOSY)
; スプライトのY座標が5以上であればスクロールは行わない
cp 5
jr nc, UndoMoveEnd
; ビューポートY座標が0の場合はスクロールはせずスプライトも動かす
ld a, (WK_VIEWPORTPOSY)
or 0
jr z, UndoMoveEnd
; ビューポートX座標を-1してビューポートを上にスクロールさせる
dec a
ld (WK_VIEWPORTPOSY), a
call CreateViewPort
call DisplayViewPort
jr UndoMoveSetOldPos
UndoMoveDownScroll:
ld a, (WK_PLAYERPOSY)
; スプライトのY座標が16でなければスクロールは行わない
cp 16
jr nz, UndoMoveEnd
; ビューポートY座標が35の場合はスクロールはせずスプライトも動かす
ld a, (WK_VIEWPORTPOSY)
cp 35
jr z, UndoMoveEnd
; ビューポートY座標を+1してビューポートを下にスクロールさせる
inc a
ld (WK_VIEWPORTPOSY), a
call CreateViewPort
call DisplayViewPort
UndoMoveSetOldPos:
; X座標、Y座標を元に戻す(移動させない)
ld bc, 2
ld de, WK_PLAYERPOSX
ld hl, WK_PLAYERPOSXOLD
ldir
UndoMoveEnd:
ret
ちょっと無駄コードも散見してて、ダラダラ長い感じですね。すみません・・。
やってることは単純で、上下左右押された方向に対してスプライトを動かすんだけれど、スクロールさせなきゃいけない場所に移動したらスクロールさせるという処理になっています。
ドラクエなんかだと常に自分が画面の中心にいる、そんなビューポート設計になっていますが、今回のロジックにすることで自キャラのアクション性は高まります。下図の白いところは自由に動き回れます。
たとえば、カーソルキーの左が押されたらMAP上の論理座標のWK_VIEWPORTPOSXをデクリメント(1減らす)てCreateViewPort、DisplayViewPortを呼び出し、スプライト座標は移動前の座標に戻しています(スプライトは移動させない)
あわせて、スクロール限界座標(XYともに0から35まで)になったらスクロールさせずにスプライトだけ移動させています。
まとめ:そんなに難しい代物ではない
いかがでしたでしょうか?読みづらいソースコードで申し訳ない気持ちですが、意外と単純なロジックでスクロールは実現できることがわかったかと思います。基本的なタイル情報だけをMAPデータにすることでデータ量も削減できています。今回のロジックであれば80x80タイルくらいまでは容量的に対応可能かと思います。
今回のサンプルで初めて出てくるタイルがあったと思います。ゲームって「あれ?あのキャラやタイルはなんだろう?」という知的探究心も醍醐味のひとつですよね。
タイル単位のスクロール、キャラクタ単位のスクロール
また、今回は1タイル(16ドットx16ドット)のスクロールロジックです。これを8ドットスクロール(半タイル)にしようとするとこれまた難儀なことになります(汗)
ただし、MAPデータさえ出来てしまえば、敵キャラなんかもMAPデータ内だけで動き回らせることも可能です。ただし16ドットx16ドット単位で敵キャラを動かすとそれはとてつもなく難易度ハードコアなゲームになってしまうことでしょう。
なのでテクニックとしてはビューポート上にいない状態であれば動きを少なくして、ビューポート内にいる場合だけ8ドット単位で動かす。といったテクニックも必要になるかと思います。ということで、次回は敵キャラを出現させてみましょう。
次回:敵キャラを動かす!
敵キャラ・・、敵キャラねえ・・
うーん・・。しんどい(汗)
まあ、楽しく続けていきましょう!
では、また!!