文書の過去の版を表示しています。
目次
フルーツフィールド for PC-6001mkII
はじめに
フルーツフィールドは、REMくんを操作してフルーツを集めるパズルゲームです。 画面内にはREMくんの行く手を阻むブロックがありますので、動かしたり、壊したりしながら、フルーツをすべて集めてください。 画面内のフルーツをすべて集めればクリアです。
オリジナルは、工学社のPiOという雑誌の1986年9月号に掲載されました。 「8機種1)そろってフルーツフィールド」と題して、同時に8機種のダンプリストが掲載されました。
ただ、その中にPC-6001シリーズは含まれていませんでした。 当時、PC-8801mkII所有だった私は、暇なことに、PC-8801版もPC-8001版も入力し、遊んでいましたが、PC-6001シリーズを保有していたわけでもなかったので、対応機種から漏れていることに気づきもしませんでした。
しかし、今回、PC-6001mkIIの実機を入手したので、PC-6001の勉強もかねて、移植してみることにしたのでした。
ルール
画面内には矢印の刻まれたブロックがあります。 ブロックはREMくんが押すことで矢印の方向に障害物にぶつかるまで進みます。 REMくんは向いている方向にある隣接したブロックを押すことができます。 押すときは、矢印の先以外の三方向から押すことができます。 ブロックが障害物にぶつかった状態で、矢印の後ろ側から押すことでそのブロックを破壊することができます。 但し、矢印同士が向かい合った状態にあるときは壁同様、どちらのブロックも破壊も移動もできなくなあります。 面内のフルーツをすべて集めればステージクリアですが、どうしてもとけなくなった場合はギブアップしてそのステージを最初からやり直すことができます。 100ステージすべてをクリアするとゲームクリアです。
動作環境
- PC-6001mkII以降
- モード5/ページ3
PC-6001mkII実機、および、PC-6001VW、PC-6001VXで動作確認しています。 PC-6001mkII/6601互換BASIC0.31での動作も確認しています。
ゲームのロードおよび実行
PC-6001VWやPC-6001VXのようなエミュレータの場合 ff.p6をロード用のテープイメージとしてセットして、モード5 (N60m-BASIC/N66-BASIC)、ページ数3で起動してください。 実機の場合は、loader.wavとff6001.wavをテープに順に保存するか、或いは、PCで順次再生されるようにしてくださいい。 BASICが起動したら、
cload
と入力します。
Found:loader
と出て、OKと表示されたら、続けて
run
と入力してください。
Program Loading...
と表示されたら、PCから実機へロードする場合はff6001.wavを再生してください。 テープに連続して録音してある場合、およびエミュレータでff.p6を使用している場合は、自動的に続きをロードし、タイトル画面が表示されます。
操作
REMくんはカーソルキーで上下左右に操作できます。 スペースキーを押すと、向いている方向で隣接しているブロックを押します。 シフトキーを押すとギブアップします。
押すことができます。 ブロックを壊せます。 ブロックは壊せません。
STOP
キーでBGMをON/OFFします。
BGMはデフォルトではOFFで、メニューに戻るとOFFに戻されます。
コンストラクションモード
メインメニューで、1を選ぶとコンストラクションモードになります。 左上でカーソルが点滅しているので、カーソルキーで移動してください。 0-7を押すとカーソルの位置に対応するオブジェクトを置きます。
0. 空白 1. 壁 2. REMくん 3. フルーツ 4. 左 5. 右 6. 上 7. 下
REMくんを同時に二か所以上に配置はできません。 HOME/CLRキーを押すとステージを空白でクリアします。 DELキーを押すとカーソルが一つ戻ります。 オブジェクトを置くとカーソルが右へ進みますがこれがいやな場合は、スペースキーを押すと進まなくなります。 もう一度スペースを押すと再び進むようになります。 9を押すとテストプレイすることができます。 リターンキーを押すとステージを保存してタイトル画面に戻ります。
面データのセーブとロード
メインメニューから2を選ぶと作成した面データをテープに保存できます。 保存したいステージの範囲を指定できます。
メニューから3を選ぶとロードできます。 保存されたデータにはどのステージであるのかが記録されています2)ので、ステージ番号の指定などの必要はありません。
ゲームの終了
メインメニューから4を選ぶとゲームを終了してBASICに戻ります。 再度起動したい場合はロードからやり直すか、
EXEC &H9000
と入力してください。
ダウンロード
エミュレータ用のp6ファイルと、実機用のwavファイル(逆位相版も同梱)をまとめたものをこちらに置きます。
改版履歴
- 2020/11 初版
- 2020/12/09 BGMを鳴らすようにした。
技術資料
メモリーマップ
アドレス | |
---|---|
プログラムエリア | 9000H-9EFFH |
ワークエリア(変数) | 9F00H-9FFFH |
キャラクタデータ | A000H-A45FH |
タイトルデータ | A460H-A77FH |
フォントデータ | A780H-A87FH |
デモ画面用マップ | A880H-A8DFH |
デモ操作データ | A8E0H-A96FH |
プレイ中マップ | A970H-AA0FH |
メッセージ文字列 | AA10H-AEC8H |
BGMデータ | AEC9H-AF08H |
マップデータ(80Hx100) | B000H-CF3FH |
コンストラクション用ワーク | CF40H-CF8FH |
PC-6001mkIIのBIOS/ワークエリア
アドレス | 備考 | |
---|---|---|
アトリビュートエリア | 4000H-5FFFH | |
グラフィックエリア | 6000H-7FFFH | |
キー入力 | 1061H | 出力:A(ビットマップ) |
キー入力 | 0FBCH | 出力:A=文字コード |
キーバッファクリア | 1058H | |
ダイレクトモード | 0442H | BASICへ戻る |
モード設定 | 1390H | 入力:A=モード-1 |
表示ページ | 13EDH | 入力:A=ページ-1 |
使用ページ | 140CH | 入力:A=ページ-1 |
セーブ開始 | 25B7H | CMTをONにし、ヘッダD3を10byte出力、ファイル名6byteを出力 |
1byteセーブ | 1ACCH | 入力:A |
セーブ終了 | 1B06H | テープを閉じる |
ロード開始 | 1A61H | CMTをONにする |
ヘッダー読み込み | 2533H | ヘッダ及びファイル名を読み込む |
1byteロード | 1A70H | 出力:A |
ロード終了 | 1AAAH | テープを閉じる |
セーブ用ファイル名エリア | FECBH-FED0H | 6bytes |
ロード用ファイル名エリア | FED1H-FED6H | 6bytes |
キークリック音 | FA2DH | 0:OFF/1:ON |
ローダー
- ヘッダー構造
ヘッダー(16bytes) | |||||||
---|---|---|---|---|---|---|---|
D3 | D3 | D3 | D3 | D3 | D3 | D3 | D3 |
D3 | D3 | 開始 | 終了 | 実行 |
PC-6001にはマシン語モニターがありません。 PC-6001mkII以降では、N60m/N66-BASICでは実装されていますが、シリアル通信機能と共有されているからか、1byteをを6進数0-9A-Fの2文字として保存するため、効率が悪く、あまり使いでがいいとはいえません。
そこで、cload/csave用のヘッダを流用し、ファイル名用の6byteを2byteずつ三つに分け、開始アドレス、終了アドレス、実行開始アドレスを保持し、1byteのデータをそのままの1byteで保持します。
ローダープログラムは読み込んだデータを開始アドレスから順に書き込み、終了アドレスになったら、実行開始アドレスにジャンプします。
ゲーム内のマップデーターの場合は実行開始アドレスは使いませんし読み込んでも分岐しません。
BASICから呼び出されるローダ本体は以下のようなコードになっています。 RST 20HはHLとDEを比較してくれるシステムコールで、結果がゼロフラグとキャリーフラグに反映されます。
org 0f900h call 1a61h call 2539h ld hl, (0fed1h) ld de, (0fed3h) read_next: call 1a70h ld (hl),a inc hl rst 20h jr nz, read_next read_done: call 1aaah ld hl, (0fed5h) ; get start addr jp (hl) ret end
10進数変換(0~187まで)
ゲーム中、面数やフルーツの残数を表示するために、16進数のデータを10進数に変換する必要があります。
結構頻度高く呼ばれるので高速である必要があります。
正攻法、というか最もよく、かつ汎用性高く使われる方法は、10で除算して剰余を得る方法でしょう。
ただ、計算量が多く、ループもあるので、決して高速とはいいがたいです。
面数は00-99、フルーツ数は、フィールドをすべてフルーツで埋め尽くしても160なので、この範囲の数値が変換できれば問題ありません。
Z80にはDAAという命令があるので、これを利用すると0-187の数値を16進数から10進数に変換できます。
DAAは直前の演算によってAレジスタに得られた値を4bitx2けたの十進数に変換します。
16進数2けたの数値(ただし0x00-0xbb)を変換するにあたって、まず値を4bitずつ二つに分けます。 下位1ニブルをDAAで10進化して、保存しておきます。 上位ニブルはそのままでは桁あふれするので、右に4ビットシフトして、DAAで10進化し、これを add a,aで二倍してDAA。これを4回繰り返し、保存しておいた下位ニブルを足してDAAします。
ただし、これでは、100以上になるときにCFがうまく立ちません。 なので、ちょっと演算順序をかえてやります。 これでも188の時にはうまく処理できなくなるのですが。 (だから187以下のみサポート)
push hl ld h,a and 0fh or a ; clear HF daa ld l,a ld a,h and 0f0h rrca rrca rrca rrca daa add a,a daa add a,a daa add a,a daa ld h,a add a,l daa add a,h daa ; set CF if over 100 pop hl ret
CLS
BIOSのCLSルーチンはPC-8801/mkIIのそれ同様に遅い上に、呼び出すとSCREENモードを戻してしまうようで具合が悪いです。
なので、自前の画面消去ルーチンを組みます。
最も簡便な方法は、ldirを使って1byteずつクリアする方法でしょう。
ld hl,04000H ld de,04001H ld (hl),0 ld bc,40*200*2-1 ldir
ただ、これだとイマイチ速くありません。 そこで、16bitずつデータをストアできるpushを使って消去する方法を実装します。
ld hl,0 ld (stack_ptr),sp di ld sp,04000H + 40 * 200 ld b, 100 next1: push hl ... push hl ; 40回繰り返す(80bytes == 2lines) djnz next1 ld sp,06000H + 40 * 200 ld b, 100 next2: push hl ... push hl ; 40回繰り返す(80bytes == 2lines) djnz next2 ld sp,(stack_ptr) ei
spをいじるので実行期間中は割り込みを禁止します。 画面が下から消えますがよしとします。
モードとページ数
PC-6001mkIIはN60-BASICモードとN60m-BASICモードの二つがあります。 起動メニューの1~4はN60で5がN60mになります。
移植もとにしたPC-8001版は160x100x8色の解像度を持つので、PC-6001mkIIではN60m-BASICでSCREEN 3(160x200x15色)がちょうどいい解像度になります。
このため、フルーツフィールドはモード5でページ数3で起動することになっています。
文字フォント
モード3での文字表示は、8×16ドットになるので、文字がばかでかく、ゲーム中の表示には使いにくいため、最低限のフォントを4×8でデザインし利用しています。
0x20-0x5F の64文字のみを実装してあります。 英小文字はサポートしていません。
フォントは一文字4byteで構成されます。 上位ニブルが奇数ライン、下位ニブルが偶数ラインをそれぞれ表現しています。
表示するときは、指定された色にあわせて、VRAMに直接書き込みます。
フォント | バイト位置 | ||||
---|---|---|---|---|---|
上位ニブル | +0 | ||||
x | 下位ニブル | ||||
x | x | 上位ニブル | +1 | ||
x | 下位ニブル | ||||
x | 上位ニブル | +2 | |||
x | 下位ニブル | ||||
x | x | x | 上位ニブル | +3 | |
下位ニブル |
開発環境について
コードはアセンブラで記述しています。 アセンブルは、WSL2上にインストールした Ubuntu 20.4LTS上の zasm を利用しています。
$ zasm -uwy --z80 ff6001.asm ff6001.lst ff6001.bin
のようにしています。
エミュレータにロードするために、ヘッダをつけてP6形式にするツールをrubyで書いて使っています。
buf = ARGF.read head = 0xd3.chr * 16 saddr = 0x9000 eaddr = saddr + buf.bytes.size head[10] = (saddr & 0xff).chr head[11] = (saddr >> 8).chr head[12] = (eaddr & 0xff).chr head[13] = (eaddr >> 8).chr head[14] = (saddr & 0xff).chr head[15] = (saddr >> 8).chr print "#{saddr.to_s(16)} - #{eaddr.to_s(16)}\n" open('ff6001.p6', 'w') do |f| f.write head f.write buf end
更に、ローダのp6ファイルに、ゲーム本体の p6ファイルを、10byteほどの空白をあけて連結するだけのツールもrubyで書いてあります。
loader = nil open('loader.p6') do |f| loader = f.read end pad = "\000"*10 prog = ARGF.read open('ff.p6','w') do |f| f.write loader f.write pad f.write prog end
これらが make から呼び出されて一発でロード可能な p6ファイルができるようになっています。
デバッグを迅速に行うために、何度もビルドしてはロードを行うわけですが、WAVファイルにすると3分弱の長さになりますので、短時間でロードとテストができるこの環境でなかったら、移植を完遂はできなかったかもしれません。
マップデータ
マップデータの構造は極めて単純です。 ステージは16×10のサイズで、配置されるオブジェクトは0~7の8種類、3bitでマッピング可能なので、1byteで4bitずつ2マス分のデータを保持します。 従って、1ステージ分のデータは16×10/2 = 80バイトになります。
下はステージ00のデータです。
0000b000 11 11 11 11 11 11 11 11 |........| 0000b008 11 10 11 11 11 11 11 11 |........| 0000b010 11 06 00 76 01 11 00 00 |...v....| 0000b018 11 01 10 11 01 11 01 10 |........| 0000b020 23 50 10 55 35 00 00 43 |#P.U5..C| 0000b028 11 11 10 11 01 11 01 10 |........| 0000b030 11 37 00 45 01 11 00 01 |.7.E....| 0000b038 11 10 11 11 11 11 11 11 |........| 0000b040 11 11 11 11 11 11 11 11 |........| 0000b048 11 11 11 11 11 11 11 11 |........|
プレイ中のマップはAA70H~の160バイトのエリアに1byteに一マスのデータとして展開され利用されます。
また、コンストラクション中のマップはCF40H~の160byteに作成され、RETキーを押すことで、2マスを1バイトにパッキングして、マップデータの当該ステージのエリアに上書きされます。
スクロール
VRAMをスクロールするには、現在のVRAMデータを読み出す必要があります。 しかし、VRAMのある4000H~7FFFHは、読み出す際にはROMへのアクセスになり、VRAMを読み出すにはバンクを切り替えてやらねばなりません。
ポート0F0Hに上位ニブルを 0D0Hにして出力することで、読み出しバンクをVRAMに変更できます。
スクロールはアトリビュートエリア(4000H-5FFFH)を動かした後、グラフィックエリア(6000H-7FFFH)を動かすことになるので、全画面分を一気にやると派手に色ずれを起こしてしまう。
なので、上から8line分(フォント一つ分の高さ)をアトリビュート、グラフィックスともにスクロールして、次の8line分を処理し……と小分けにして、色ずれを目立たないように処理しています。
push hl push de push bc ld de,vram0 ld (vram0_scr_ptr),de ld de,vram1 ld (vram1_scr_ptr),de in a,(0F0H) ; read bank setting push af and 0fh ; set 04000h-07fffh to RAM (VRAM) or 0d0h di out (0F0H),a ld b,24 scroll_up_loop: push bc ld de,(vram0_scr_ptr) ld hl,320 add hl,de ld bc,320 ldir ld (vram0_scr_ptr),de ld de,(vram1_scr_ptr) ld hl,320 add hl,de ld bc,320 ldir ld (vram1_scr_ptr),de pop bc djnz scroll_up_loop pop af out (0F0H),a ; restore bank ei ld c,4 call _cls ; clear botton 1 line pop bc pop de pop hl ret
BGM
BGMはゲームのループ中にPSGのレジスタを叩いて出力しています。
チャンネルAで効果音を、チャンネルBでBGMを鳴らしています。
ブロックを動かしたり消したりするとBGMが飛んだようになりますが、PC-6001mkIIの全力です。3)
BGMはX1用のデータを、X1用の演奏ルーチンを移植したもので鳴らしています。 X1とPC-6001は同じPSGチップAY-3-8910を搭載しているので、基本的な操作は同じです。
またPC-6001とX1はAY-3-8910のレジスタのマッピングが全く同じになっているため、基本的に、I/Oアドレスだけ調整してやれば、そのまま音が鳴ります。
PC-6001がA0H
でレジスタの選択を、A1H
にデータの出力を行うのに対して、X1は1CxxH
でレジスタの選択を、1BxxH
にデータの出力を行っています。
X1はZ80のOUT (C),r
がBCレジスタペアで16bit幅のアドレス空間を持てることを積極的に利用していて、PSGへのアクセスも16bit幅のアドレスを使っていますが、下位8bitは Don't Careなので、気にする必要はありません。
play_bgm: ld a,12 ; set envelope 6911 ... 0.9s out (0a0h),a ; --> 1affh ld a,1ah out (0a1h),a ld a,11 out (0a0h),a ld a,0ffh out (0a1h),a ld a,9 ; set channel B to envelope mode out (0a0h),a ; (10H) ld a,10h out (0a1h),a ld a,3 ; Clear Channel B CT out (0a0h),a xor a out (0a1h),a push hl ld hl, (bgm_ptr) ld a,(hl) ; Read next data add a,80h jr nc,skip01 ; MSB to set CT B == 1 push bc ld c,a ld a,3 out (0a0h),a ld a,1 out (0a1h),a ld a,c pop bc skip01: cp 7fh ; rewind if 0xff jr z, rewind cp 80h jr z, noplay ; rest if 00 push bc ld c,a ld a,2 out (0a0h),a ; set FT B ld a,c out (0a1h),a pop bc ld a,7 ; channel TONE A/B only out (0a0h),a ld a,0fch out (0a1h),a ld a,13 ; Envelope mode to 00xx out (0a0h),a xor a out (0a1h),a noplay: inc hl ld (bgm_ptr),hl pop hl ret rewind: ld hl, bgm_dat ld (bgm_ptr), hl pop hl ret
タイマー割込み
BGM対応の最初の版では、ゲームルーチンの中からBGMの再生ルーチンをcallしていましたが、当然、再生が安定しませんでした。 そこで、タイマー割込みを使って、BGMを再生する方法に切り替えました。
タイマー割込みは1/512sごとに発生するので、ここで、適当な間隔でPSGをたたいてやれば安定して再生が行えるはずです。
タイマー割込みに関係するのは以下のリソースになります。
リソース | 内容 |
---|---|
B0H | I/Oポート 最下位ビットがタイマー割込みの enable/disable。0でenable |
FA27H | ポートB0Hに出力されている値 |
FA06H~FA07H | タイマー割込みベクタ。デフォルトは0F74H |
割込みベクタは、標準ではPLAY
文やカーソルの点滅などの処理をしているようです。
最初、ベクタを保存して、BASICに戻るときに保存した値を戻していたのですが、リセットなど予期せぬ処理の後だと、ベクタが初期化されないでゲーム内のハンドラーをポイントしたままになったりしていたので、構わず 0F74H
を書き戻すようにしました。
タイマー割込みをフックするような何かが先にいると具合が悪いですが、まあ、そこまで考える必要もないでしょう、多分。
また、タイマー割込みもBASICが動いている状態では0F74H
を使った割込みが有効な状態なので、ゲームから復帰するときもタイマー割込みを有効にして戻っています。
BGMはエンベロープが0.886秒ほどで0になるような形状なので、450回に一回くらいPSGをたたけばいいかと思ったのですが、実際に動かしたら64回ほどでたたく必要がありました。どこかで何かの計算を間違えたのかな?
とりあえず、BGMの再生が安定しました。 というか、ゲームでBGM鳴らす場合はみんなこんな風な処理をしているものなんですよね?
16bit演算
Z80は8bitのCPUなので、基本的な演算は8bitのレジスタに対して行い4)、16bitの演算は適宜、Cフラグなどと組み合わせて複数回にわけて行う必要があります。
しかし、いくつかの演算については16bitレジスタペアに対しても使えます。
ところが、8bitの演算と全く等価ではない場合があるので注意が必要です。 と、いうか、失念してて、罠にはまりかけました。
16bitの減算処理、DEC
命令においては、16bitのそれはフラグを変更してくれません。
なので、
DEC HL JR Z, END ...
のようなコードは動かないのです。 減算した後、別途比較を組む必要があります。
DEC HL LD A,H OR L JR Z, END ...
これなら動きます。 が、Aレジスタを使うし、比較のためにサイクルを食うし、忘れると動かないし、気を付けないといけません。
技術的ではないボヤキのようなもの
ウェイトループ
移植にあたって参考にしたPC-8001版は、随所にウェイトが入っており、それでもエミュレータ上で速すぎるくらいに速く動作しています。
PC-8001もPC-6001もZ80 4MHzであり、メモリアクセスにウェイトが入っているのも同じです。
であれば、同量のウェイトを仕込んでしかるべきだろうと思ったのですが、驚くほど遅かったので、最終的には、ステージセレクトと音を鳴らす時間を調整する部分以外のウェイトは全廃しました。
それどころか、ループ内で呼び出しているルーチンの徹底した高速化まで余儀なくされたのです。
恐るべし、PC-6001……。
なお、エミュレータを作成してくださっている方々の名誉のために申し上げますが、フルーツフィールドに限っていえば実機と同等の速度でエミュレータ上でも動作しています。
サウンド?
実は、移植中に、サウンド機能を実装してあるっぽい形跡を見つけたのです。 ステージ開始のとき、ステージクリアしたとき、あとギブアップしたときの三か所から呼ばれているサブルーチンがあったのですが、中では、HLレジスタのさしているアドレスから3byte読んでは、ブザーをパラメータに従って鳴らしに行くという動作を繰り返すようになっていて、先頭の1byteがFFHになるまでこれを繰り返すというものでした。
ただ、三か所とも、いきなり FFHをポイントして呼び出すので結局何もしないで戻ってきてしまいます。
おそらくはそこに音階データとなるものが仕込まれていたんでしょうが、何らかの事情で削除されてしまったようです。 FM-7やX1では音楽も流れるようなので、おそらくはそれと似たようなのが仕込まれていたのでしょうが、今となっては知る由もありません。 移植の際には、呼び出しも含めて綺麗に取っ払ってあります。
なお、現在、X1版のBGMを移植して、鳴らせるようにしてあります。