GB ゲーム開発覚え書き: サウンド

Game, C, GameBoy

約 2 年半ぶりの更新になってしまった。今回はゲームに音を追加してみる。
用意したサンプルゲームは以下。

src/main.c

#include <gb/gb.h>
#include <stdio.h>


// キャラクターのタイルセット
const unsigned char characters[] =
{
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0xA5,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0xA5,0xFF,0x81,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0x81,
  0xFF,0x81,0xFF,0xA5,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0x8B,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0xD1,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x00,0x24,0x00,0x18,0x7E,0x7E,0xFF,0x81,
  0xFF,0xA5,0x99,0xE7,0xFF,0x81,0x7E,0x7E
};

// 背景のタイルセット
const unsigned char backgrounds[] =
{
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
  0xFF,0xFF,0xFD,0x83,0xFD,0x83,0xFD,0x83,
  0xFD,0x83,0xFD,0x83,0x81,0xFF,0xFF,0xFF
};

BOOLEAN is_end = FALSE; // ゲームが終わるかの判定
UINT8 player_pos[2] = {0};
UINT8 friend_pos[2] = {0};

// 衝突判定
BOOLEAN is_colliding_friend()
{
  return ((player_pos[0] >= friend_pos[0] && player_pos[0] <= friend_pos[0] + 8) && (player_pos[1] >= friend_pos[1] && player_pos[1] <= friend_pos[1] + 8)) || ((friend_pos[0] >= player_pos[0] && friend_pos[0] <= player_pos[0] + 8) && (friend_pos[1] >= player_pos[1] && friend_pos[1] <= player_pos[1] + 8));
}

BOOLEAN is_colliding_x(BYTE delta)
{
  if (delta > 0)
  {
    return player_pos[0] >= 160;
  }
  else
  {
    return player_pos[0] <= 8;
  }
}

void main()
{
  set_sprite_data(0, 6, characters);

  // 主人公
  set_sprite_tile(0, 0);
  player_pos[0] = 8;
  player_pos[1] = 152;
  move_sprite(0, player_pos[0], player_pos[1]);

  // フレンド
  set_sprite_tile(1, 5);
  friend_pos[0] = 160;
  friend_pos[1] = 152;
  move_sprite(1, friend_pos[0], friend_pos[1]);

  SHOW_SPRITES;

  while (!is_end)
  {
    UINT8 joypad_control = joypad();

    // 操作
    if (joypad_control & J_RIGHT)
    {
      if (!is_colliding_x(1))
      {
        player_pos[0]++;
        move_sprite(0, player_pos[0], player_pos[1]);
      }
    }
    else if (joypad_control & J_LEFT)
    {
      if (!is_colliding_x(-1))
      {
        player_pos[0]--;
        move_sprite(0, player_pos[0], player_pos[1]);
      }
    }

    is_end = is_colliding_friend();

    wait_vbl_done();
  }
  printf("GAME CLEAR");
}

サンプルコードでビルドされたゲームの画面。画面左端に主人公がいて、右端にフレンドがいる

今回は主人公を左右に動かし、画面右端のフレンドに触れればゲームクリアという単純なもの。
このキャラクターが歩く時に、音を鳴らしたいと思う。

ゲームボーイの音

メモリマップで言うと $FF10 から $FF26 がオーディオ、$FF30 から $FF3F までが波形。
サウンドは 4 チャンネルあり、それぞれ異なる波形を持つ。

  • CH1: 矩形波+スイープ
  • CH2: 矩形波
  • CH3: 波形メモリ音源
  • CH4: ノイズ波

また外部入力(VIN) がある。これはカートリッジの pin 31 からの音をルーティングするもの。
実際に使われた商用ゲームはなく GBA では削除されている。

ゲームボーイのサウンドチップは APU と呼ばれる。
CPU の速度と同期するので、クロック数が 2.4 倍速いスーパーゲームボーイ1ではピッチも変わる。

音を鳴らす準備

サウンドに関するレジスタは NR から始まり、NR5 はサウンドの制御に関するレジスタを指す。 NR52 は APU のオンオフを制御する。7 ビットが 1 で APU がオンになる。

src/main.c

void main()
{
  NR52_REG = 0x80; // APU をオンにする(10000000)

  // 処理
}

次に、音量とパンを設定する。
左右チャンネルの音量は NR50 で、6〜4 ビットが左、2〜0 ビットが右の音量。
7 と 3 は VIN に関わるものなので、使わない場合は(というか、まず使わないので) 0 にする。
ちなみにゲームボーイのスピーカーはモノラルなので、パンはヘッドホンで聞かないと確認できない。

src/main.c

void main()
{
  NR50_REG = 0x77; // 左右チャンネルの音量を MAX にする (01110111)
  NR51_REG = 0xFF; // 全てのチャンネルのパンを振る (11111111)
  // 処理
}

音を鳴らす

今回は CH1(矩形波)を使う。
そのレジスタは NR10 から NR14 まで。

設定は覚えることが多い。
例えば NR10 はスイープで、NR11 はデューティ比+長さで、NR12 はボリューム+エンベロープ、NR13NR14 は Hi,Lo の周波数…など、レジスタごとにそれぞれ異なる役割を持っている。
また、NR10 であれば、6〜4 ビットは 7.8 ms 単位のスイープ時間、3 ビットは増加させるか減少させるか、2〜0 ビットは周波数を変化させる際のシフト回数…という感じで設定される。
これを覚えたところで、直感的に作れるものでもない。

そこで今回は GBDK の examples にある sound というプログラムを使う。
ここで実際の音を確認しながら、望み通りの値に出力できる。
十字キーで各項目の値を操作し、スタートでプレビュー、セレクトで CH の変更。

GBDK の example である sound の画面。Swp Time など、ゲームボーイの波形に関する項目があり、これらの値を調整していく

sound プログラムで色々いじって、出したい音ができたら画面下の NR10-14: から続く 2 桁ずつの数値をレジスタに割り当てていく。
例えば NR10-14: 15, 80, 94, 67, 86 と表示されていれば、以下の通り。

src/main.c

NR10_REG = 0x15; // 00010101
NR11_REG = 0x80; // 10000000
NR12_REG = 0x94; // 10010100
NR13_REG = 0x67; // 01100111
NR14_REG = 0x86; // 10000110

これを、主人公が歩いている時に一定間隔で鳴らすようにする。

src/main.c

int8 frame_counter = 0;
void walk_sound() {
  frame_counter++;
  if(frame_counter >= 18) {
    NR10_REG = 0x15;
    NR11_REG = 0x80;
    NR12_REG = 0x94;
    NR13_REG = 0x67;
    NR14_REG = 0x86;
    frame_counter = 0;
  }
}

walk_sound をループの中に加え、最終的なコードは以下となる。

src/main.c

#include <gb/gb.h>
#include <stdio.h>

// キャラクターのタイルセット
const unsigned char characters[] =
{
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0xA5,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0xA5,0xFF,0x81,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0x81,
  0xFF,0x81,0xFF,0xA5,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0x8B,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x7E,0x7E,0xFF,0x81,0xFF,0x81,0xFF,0xD1,
  0xFF,0x81,0xFF,0x81,0xFF,0x81,0x7E,0x7E,
  0x00,0x24,0x00,0x18,0x7E,0x7E,0xFF,0x81,
  0xFF,0xA5,0x99,0xE7,0xFF,0x81,0x7E,0x7E
};

BOOLEAN is_end = FALSE; // ゲームが終わるかの判定
UINT8 player_pos[2] = {0};
UINT8 friend_pos[2] = {0};

// 衝突判定
BOOLEAN is_colliding_friend()
{
  return ((player_pos[0] >= friend_pos[0] && player_pos[0] <= friend_pos[0] + 8) && (player_pos[1] >= friend_pos[1] && player_pos[1] <= friend_pos[1] + 8)) || ((friend_pos[0] >= player_pos[0] && friend_pos[0] <= player_pos[0] + 8) && (friend_pos[1] >= player_pos[1] && friend_pos[1] <= player_pos[1] + 8));
}

BOOLEAN is_colliding_x(BYTE delta)
{
  if (delta > 0)
  {
    return player_pos[0] >= 160;
  }
  else
  {
    return player_pos[0] <= 8;
  }
}

// 歩く時の音
int frame_counter = 0;
void walk_sound() {
  frame_counter++;
  if(frame_counter >= 18) {
    NR10_REG = 0x15; // 00010101
    NR11_REG = 0x80; //
    NR12_REG = 0x94;
    NR13_REG = 0x67;
    NR14_REG = 0x86;
    frame_counter = 0;
  }
}

void main()
{
  NR52_REG = 0x80; // サウンドを有効化
  NR50_REG = 0x77; // 左右チャンネルの音量を MAX にする
  NR51_REG = 0xFF; // 全てのチャンネルのパンを振る

  set_sprite_data(0, 6, characters);

  // 主人公
  set_sprite_tile(0, 0);
  player_pos[0] = 8;
  player_pos[1] = 152;
  move_sprite(0, player_pos[0], player_pos[1]);

  // フレンド
  set_sprite_tile(1, 5);
  friend_pos[0] = 160;
  friend_pos[1] = 152;
  move_sprite(1, friend_pos[0], friend_pos[1]);

  SHOW_SPRITES;

  while (!is_end)
  {
    UINT8 joypad_control = joypad();

    // 操作
    if (joypad_control & J_RIGHT)
    {
      if (!is_colliding_x(1))
      {
        player_pos[0]++;
        // 定期的に歩く音を鳴らす
        walk_sound();
        move_sprite(0, player_pos[0], player_pos[1]);
      }
    }
    else if (joypad_control & J_LEFT)
    {
      if (!is_colliding_x(-1))
      {
        player_pos[0]--;
        // 定期的に歩く音を鳴らす
        walk_sound();
        move_sprite(0, player_pos[0], player_pos[1]);
      }
    } else {
      // 次歩いた時に鳴るようにリセット
      frame_counter = 18;
    }

    is_end = is_colliding_friend();

    wait_vbl_done();
  }

  printf("GAME CLEAR");
}

これをビルドして歩くと、音が鳴ると思う。
次はスクロールを実装する。