GB ゲーム開発覚え書き: ウィンドウと STAT 割り込み

Game, C, GameBoy

前回はバックグラウンドを設定したので、次はウィンドウを使ってゲーム内に HUD を表示する。

今回はゲームではなく、スプライトを置いただけのゲームもどきで試した。
そのサンプルコードはこちら。
ウィンドウのクセを知るために、バックグラウンドをパレット 1 番の色で埋めている。

いわゆる縦シューティングゲームと呼ばれるジャンルの画面。自分の機体が画面下部にいて、蛾のような敵が上部にいる。背景はパレット 1 番の色で埋めている。

ウィンドウについて

ウィンドウは「バックグラウンドの上に置けるバックグラウンド」の立ち位置。
ゲームではメッセージウィンドウや、HUD の表示に使われることがある。

ウィンドウは画面を覆い尽くすぐらいデカくて、サイズ変更できず、不透明なレイヤー。
下や右に移動させる分には問題なく、上や左に移動させる場合、バックグラウンドを覆ってしまうので、ちょっとした工夫が必要。

ウィンドウとバックグラウンドの関係

ウィンドウのタイルデータやタイルマップは、バックグラウンドと同じものが使える。
そして、ウィンドウを表示するには、バックグラウンドの表示も有効になっていないといけない。

GBTD, GBMB でウィンドウ作成

先述の通り、ウィンドウのタイルデータとタイルマップはバックグラウンドと同じなので、作り方は前回の内容そのままで OK。 GBTD で 0〜9 の数字と "STAGE" の 5 文字と自機アイコンを書いて、以下のような HUDMaterials というタイルデータとして Export した。

0から9までの数字と、STAGEの文字と、自機アイコンを格納したタイルデータ

src/HUDMaterials.c

const unsigned char HUDMaterials[] =
{
  0x18,0x18,0x24,0x24,0x66,0x66,0x66,0x66,
  0x66,0x66,0x66,0x66,0x24,0x24,0x18,0x18,
  0x18,0x18,0x18,0x18,0x38,0x38,0x18,0x18,
  0x18,0x18,0x18,0x18,0x18,0x18,0x3C,0x3C,
  0x3C,0x3C,0x3C,0x3C,0x46,0x46,0x06,0x06,
  0x3C,0x3C,0x60,0x60,0x60,0x60,0x7E,0x7E,
  0x7C,0x7C,0x06,0x06,0x06,0x06,0x3C,0x3C,
  0x3C,0x3C,0x06,0x06,0x06,0x06,0x7C,0x7C,
  0x1C,0x1C,0x2C,0x2C,0x6C,0x6C,0x4C,0x4C,
  0x4E,0x4E,0x7E,0x7E,0x0C,0x0C,0x0C,0x0C,
  0x7E,0x7E,0x60,0x60,0x60,0x60,0x7C,0x7C,
  0x06,0x06,0x06,0x06,0x46,0x46,0x3C,0x3C,
  0x3E,0x3E,0x60,0x60,0x60,0x60,0x7C,0x7C,
  0x63,0x63,0x63,0x63,0x63,0x63,0x3C,0x3C,
  0x7E,0x7E,0x7E,0x7E,0x66,0x66,0x06,0x06,
  0x06,0x06,0x06,0x06,0x06,0x06,0x06,0x06,
  0x3C,0x3C,0x66,0x66,0x66,0x66,0x3C,0x3C,
  0x66,0x66,0x66,0x66,0x66,0x66,0x3C,0x3C,
  0x3C,0x3C,0x7E,0x7E,0x46,0x46,0x46,0x46,
  0x3E,0x3E,0x06,0x06,0x46,0x46,0x3C,0x3C,
  0x3C,0x3C,0x66,0x66,0x60,0x60,0x7C,0x7C,
  0x06,0x06,0x06,0x06,0x66,0x66,0x3C,0x3C,
  0x7E,0x7E,0x7E,0x7E,0x18,0x18,0x18,0x18,
  0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x18,
  0x18,0x18,0x3C,0x3C,0x24,0x24,0x66,0x66,
  0x66,0x66,0x7E,0x7E,0x66,0x66,0x66,0x66,
  0x3C,0x3C,0x66,0x66,0x66,0x66,0x60,0x60,
  0x6E,0x6E,0x66,0x66,0x7E,0x7E,0x3C,0x3C,
  0x7E,0x7E,0x7E,0x7E,0x60,0x60,0x7E,0x7E,
  0x7E,0x7E,0x60,0x60,0x7E,0x7E,0x7E,0x7E,
  0x00,0x18,0x00,0x18,0x3C,0x24,0x3C,0x24,
  0x66,0x42,0xE7,0x99,0xE7,0xBD,0xDB,0xDB
};

そこから GBMB でタイルマップを作成。
Window と、今回は縦型のウィンドウも試すために WindowVertical を作成。

今回のタイルマップは 0〜1 番をバックグラウンドが使い、HUDMaterials のタイルを 2 番以降に割り当てる。
そのため GBMB のエクスポート時に "Location Format" で "Tile Offset" を 2 にする。
そうすれば、参照タイル番号が +2 されて書き出されるので、バックグラウンドとのタイル番号の衝突を防げる。

src/Window.c

const unsigned char Window[] =
{
  /* 
   *  HUDMaterials だけだと 0x00 だが、バックグラウンドが 0x00〜0x01 まで使うので 0x02 スタートでないといけない
   *  だから GBMB のエクスポートで "Tile Offset" を 2 にする
   */
  0x0C,0x0D,0x0E,0x0F,0x10,0x03,0x12,0x12,0x11,0x02,
  0x04,0x12,0x12,0x02,0x02,0x02,0x02,0x02,0x02,0x02
};

const unsigned char WindowVertical[] =
{
  0x0C,0x0D,0x0E,0x0F,0x10,0x12,0x12,0x12,0x12,0x12,
  0x12,0x12,0x03,0x12,0x12,0x12,0x12,0x12,0x12,0x12,
  0x12,0x12,0x12,0x12,0x12,0x02,0x02,0x02,0x02,0x02,
  0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,
  0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,
  0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,
  0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,
  0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,
  0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,
  0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11,0x11
};

ウィンドウのタイルデータを設定

そして main.c を書く。
今回はタイルデータ 0〜1 番目を Backgrounds というタイルデータに使用しているので、2 番目以降に先ほどエクスポートした HUDMaterials を割り当てる。
(※ 今回スプライトの設置を別関数に切り出してますが、中でやっていることはスプライトを設定する回と全く同じなので詳細は省略します)

src/main.c

#include <gb/gb.h>
#include <stdio.h>
#include "structs/Player.h"
#include "structs/Enemy.h"
#include "Sprites.c"
#include "Backgrounds.c"
#include "StageMap.c"

// ウィンドウのタイルデータ、タイルマップの読み込み
#include "HUDMaterials.c"
#include "Window.c"

void main()
{
  set_sprite_data(0, 11, Sprites);
  set_bkg_data(0, 2, Backgrounds); // バックグラウンドの 0〜1 番目は背景向けにエクスポートしたタイルデータを読み込む
  set_bkg_data(2, 16, HUDMaterials); // バックグラウンドの2〜番目から今回エクスポートした  HUDMaterials  を追加
  set_bkg_tiles(0, 0, 20, 18, StageMap);
  
  // スプライトの設置
  Player *player = new_player(64, 128);
  Enemy *enemy = new_enemy(0, 8, 56, 56);

  SHOW_BKG;
  SHOW_SPRITES;
}

ウィンドウを置く

ウィンドウ(バックグラウンド)のタイルデータを設定したので、set_win_tiles でタイルマップを設定する。
この設定もバックグラウンドの set_bkg_tiles と全く同じなので省略。

次に、move_win(uint8_t x, uint8_t y) で x 座標と y 座標を指定する。
ウィンドウの注意点として、x 座標の値は -7 される。つまり左隅に置く場合、0 ではなくて 7 が正解。

ちなみに x 座標は 0〜166(実際は-7〜159) y 座標は 0〜143 の範囲で指定できる。数値を(-7 補正前で)0 と 166 にするのは推奨されてない。 166 だと次のスキャンに移るらしく、0 にした場合と同じ位置に、上部が欠けた状態で描画される。とにかく望ましい挙動じゃない。

話を戻して、ウィンドウを移動できたので、フラグを立てて表示させる。
LCDC の Bit 0(バックグラウンドの表示)と Bit 5 を 1 にする必要がある。
GBDK では SHOW_BKGSHOW_WIN マクロを使って有効にできる。

src/main.c

#include <gb/gb.h>
#include <stdio.h>
#include "structs/Player.h"
#include "structs/Enemy.h"
#include "Sprites.c"
#include "Backgrounds.c"
#include "StageMap.c"

// ウィンドウのタイルデータ、タイルマップの読み込み
#include "HUDMaterials.c"
#include "Window.c"

void main()
{
  set_sprite_data(0, 11, Sprites);
  set_bkg_data(0, 2, Backgrounds);
  set_bkg_data(2, 16, HUDMaterials);
  set_bkg_tiles(0, 0, 20, 18, StageMap);
  
  Player *player = new_player(64, 128);
  Enemy *enemy = new_enemy(0, 8, 56, 56);

  set_win_tiles(0, 0, 20, 1, Window); // ウィンドウにタイルマップを設定
  move_win(7, 136); // ウィンドウを x7(-7 して 0), y136 に移動

  SHOW_BKG; // バックグラウンドも有効にする必要あり
  SHOW_WIN; // ウィンドウの表示を有効にする
  SHOW_SPRITES;
}

ウィンドウの配置パターン

先ほどのコードをコンパイルし、ゲームを起動すると、ゲーム画面の下にステータスが表示されているはず。
下配置は結構よく見るパターン。 ウィンドウを画面最下部に配置したゲーム画面。ウィンドウにはダミーのステータスとして、ステージ1、自機の数が2、7桁のスコアが表示されている。

もう1つは、右に置くパターン。
縦 STG とか、落ちものパズルゲーとか。 ウィンドウを画面右端に配置したゲーム画面。ウィンドウにはダミーのステータスとして、ステージ1、5桁のスコアがあり、自機アイコンで残りのスペースを埋めている。

さらにポピュラーなものとしては、上配置がある。
しかし、上にウィンドウを置くと、画面が真っ白になってしまう。
これは不透明なウィンドウに、バックグラウンドが隠されてしまっているため。 ウィンドウを画面最上部に配置したゲーム画面。ウィンドウの背景色であるパレット 0 番の色が、バックグラウンドである 1 番の色を覆ってしまっている。

左も同様に、バックグラウンドが隠れる。 ウィンドウを画面左端に配置したゲーム画面。ウィンドウの背景色であるパレット 0 番の色が、バックグラウンドである 1 番の色を覆ってしまっている。

このように、ウィンドウは上と左にそのまま置けないので、下か右に置いた方がラク。
でも上表示は、STAT の割り込みを使って擬似的に表示させる方法がある。

割り込みを使った上ウィンドウの設置

Gameboy Development Forum で紹介されている方法を使えば良いものの、コードだけでは良くわからないと思うので補足する。

まず、 STAT について知る。
STAT は、現在の PPU の状態を管理するレジスタ。ここの Bit 6 を 1 にすることで、LYC を割り込みの対象にする。

LYC とはなんぞや…と言えば、LY Compare を指す。
Pan docs の Scrolling によれば、まず現在の描画行を LY というレジスタで管理していて、その LY と常に比較されている値が LYC。
LYC に値を入れておくと、LY の値が同じ時(その行を描写する時)に割り込みを発生させる、という仕組みらしい。 この STAT 割り込み(LYC 割り込み)によって、特定の行以降はウィンドウを出さないとか、逆にスプライトを引っ込めるような実装が可能になる。

これらをおさえておくと、 GBDK のコードでも何をやっているのかわかる。
まず、STAT_REG で STAT レジスタに 0x45 を指定して、 LYC 割り込みフラグの Bit 6 を 1 にしている。
というか Bit 6 だけなら 0x40(01000000) では…?とにかく 0x45 で問題ない様子。
続く LYC_REG は LYC レジスタのことで、8 行目に割り込みを入れる(0〜7 行目にはウィンドウがある)。

src/main.c

void main() {
  STAT_REG = 0x45; // 0x40 でいい?
  LYC_REG = 0x08;
  /* 省略 */
}

後は interruptLCD という割り込み向けの HIDE_WIN する関数を実装して、BDK の add_LCD をハンドラとして設定することで、STAT 割り込み時に HIDE_WIN が実行される。
最後に set_interrupts で引数に VBlank or LCD での割り込みを設定する。少なくともこの 2 つがないといけない。

src/main.c

void main() {
  /* 省略 */
  add_LCD(interruptLCD);
  enable_interrupts();
  set_interrupts(VBL_IFLAG | LCD_IFLAG);
  /* 省略 */
}

// STAT 割り込みで発火する。
void interruptLCD() {
  HIDE_WIN;
}

そんでもって、VBlank 後に再びウィンドウを出すよう、メインループ内で SHOW_WIN する。
…けども、wait_vbl_done があるとは言え、メインループ内で SHOW_WIN し続けるのはお行儀が良くないので、VBlank のハンドラを追加した。

src/main.c

void main() {
  /* 省略 */
  add_LCD(interruptLCD);
  add_VBL(interruptVBL); // VBL ハンドラ
  enable_interrupts();
  set_interrupts(VBL_IFLAG | LCD_IFLAG);
  /* 省略 */
}
while (1)
{
  wait_vbl_done();
}

void interruptLCD() {
  HIDE_WIN;
}

// ここでウィンドウを表示
void interruptVBL() {
  SHOW_WIN;
}

この状態でコンパイルして起動すると、ウィンドウとバックグラウンドが両方表示される。
でも、まだ何かがおかしい。ウィンドウが横 40 ドット分欠けている。
欠けているというか、そもそも 8 行を越えてウィンドウが表示されている。
ウィンドウが上に表示され、バックグラウンドも表示されているが、ウィンドウの下に縦1ドット分の高さの線が混ざり、それが横残り40ドット分の位置で消えている。

HBlank を待つ STAT 割り込み

LY8 に入ったタイミングで HIDE_WIN してほしいけど、実は遅延がある?

GB DEV FAQs の質問 によると、LYC 自体が他の割り込みによって遅延することがあるらしい。
実際には 1 行前で割り込みを発生させ、次の LY までループして待つ、という手法が必要だそうな。
今回は 1 行前(7)で割り込みを発生させつつ、その行の描画が終わる(HBlank)まで待ってあげる作戦にする。

HBlank を待つための方法が GBDev フォーラムの書き込みにある。STAT_REG & 3 で mode のビットを見て、 0 になるまで空ループを繰り返す、というアプローチ(mode については後の記事で書くかもしれない。とりあえず 0 の時は HBlank)。
また、LYC_REG の値は 7 にしておく。

src/main.c

void interruptLCD()
{
  while(STAT_REG & 3);
  HIDE_WIN;
}

最終的なコードはこうなった。

src/main.c

#include <gb/gb.h>
#include <stdio.h>
#include "structs/Player.h"
#include "structs/Enemy.h"
#include "Sprites.c"
#include "Backgrounds.c"
#include "StageMap.c"
#include "HUDMaterials.c"
#include "Window.c"

// STAT 割り込み時にウィンドウを隠す
void interruptLCD()
{
  while(STAT_REG & 3); // HBlank まで待つ
  HIDE_WIN;
}

// VBlank 時にウィンドウを表示する
void interruptVBL() {
  SHOW_WIN;
}

void main()
{
  STAT_REG = 0x40; // LYC を有効にする
  LYC_REG = 0x07;  // 8 番目の直前の行

  disable_interrupts();
  
  set_sprite_data(0, 11, Sprites);
  set_bkg_data(0, 2, Backgrounds);
  set_bkg_data(2, 16, HUDMaterials);
  set_bkg_tiles(0, 0, 20, 18, StageMap);
  Player *player = new_player(64, 128);
  Enemy *enemy = new_enemy(0, 8, 56, 56);

  // 上にウィンドウを表示
  set_win_tiles(0, 0, 20, 1, Window);
  move_win(7, 0);

  SHOW_BKG;
  SHOW_SPRITES;

  // ハンドラと、割り込みの設定
  add_LCD(interruptLCD);
  add_VBL(interruptVBL);
  set_interrupts(VBL_IFLAG | LCD_IFLAG);
  enable_interrupts();

  while (1)
  {
    wait_vbl_done();
  }
}

画面上にウィンドウが表示され、8 行目以降は隠されるようになった。

上にウィンドウが表示され、正しい行でウィンドウが消え、バックグラウンドが表示されるようになった。

要するにつらいので、ウィンドウを使う時は以下を心がけた方がいいかもしれない。

  1. 基本的にウィンドウを置く時は下か右
  2. 上に置きたい時は LYC で STAT 割り込みを入れる
  3. ラグは、ウィンドウ周辺を白背景にしてごまかす。もしくは HBlank を待つ
  4. 最悪スプライトで頑張る、ただし最大 40 個まで、1 行に 10 個まで

終わり

無事にウィンドウを置けた。 サウンド編 に続く。