Raspberry Pi Picoを使った携帯ゲーム機

この記事ははJLCPCBの提供でお送りします。

JLCPCBとは

jlcpcb.com (↑こちらは日本語版のログインページで、お得なクーポンも配布されています。)

JLCPCBとは、プリント基板製造などで有名な香港の企業です。

日本からでもWebページでポチポチするだけでKiCADなどで作成した基板データの製造を依頼できます。

値段もかなりお手頃で、ホビー電子工作ユーザーの間では広く利用されています。

この記事の作例もJLCPCBに基板を発注して実現しました。

Raspberry Pi Picoを使った携帯ゲーム機とは?

文字通り、Raspberry Pi Picoを搭載した携帯ゲーム機です。 1枚の基板で色々なことが試せるように工夫しました。

  • ディプレイは以下から1つ
    • I2C接続OLED
    • SPI接続OLED
    • SPI接続カラーTFT(128px*128px)
  • スピーカー
  • 3.5mmオーディオジャック端子
  • ボリューム
  • 電源は以下から1つ
    • 単4電池2つ
    • LiPOバッテリー(充電モジュールは既製品を想定)
  • MicroSDカードソケット
  • 十字ボタン、A,Bボタン
  • 電源用スライドスイッチ
  • Raspberry Pi Picoのすべての端子を引き出した拡張端子

タクトスイッチは6mm角の表面実装の物、コンデンサは0603(1608M)を使っています

複数のディスプレイ、複数の電源への対応

まぁ単純にフットプリントを重ねて配置しただけです。どれか1つしか使わない想定なので、フットプリント自体が重なることは問題がないため、スルーホール穴が重ならないように配置しました。

電源も、乾電池ホルダーに重ねてLiPoバッテリーの充電モジュールを配置し、どちらか一方を利用できるようにしました。

発注

今回は基板の色は黒で発注しました。JLCPCBの黒の基板はツヤ消しマットのような質感で、高級感があり良いものだと思いました。

基板サイズは10cm*10cm以内なので、JLCPCBの最安料金で発注できました。 (1注文当たり5枚の基板が1つだけ割引になるため、1注文なら5枚で$2+送料)

今回の失敗

今回も凡ミスをしてしまいました・・

Raspberry Pi PicoのGNDが他のGNDと繋がっておらず、電源を入れても、Raspberry Pi Picoに電源が供給されない状態でした。

まぁ、修正は簡単で、適当なGNDの来ているピンからRaspberry Pi PicoのGNDピンにジャンパ線を取り付けて修正しました。

毎回ミスはありますね・・

単4電池2つでRaspberry Pi Pico?!

Raspberry Pi Picoは3.3V駆動なので単4電池2つでは少し電圧が足りないと思って、DC-DCコンバータを別途取り付けようと思っていたのですが、よく仕様を見るとRaspberry Pi PicoにはDC-DCコンバータが搭載されており、1.8~5.5Vを印加すると、いい感じに3.3Vにしてくれるようで、外部部品が不要であることに気づきました。

https://datasheets.raspberrypi.com/pico/pico-datasheet.pdf より(CC BY-ND)

MicroPythonでのプログラミング

とりあえず I2C接続OLED、単4電池2つの構成で組み立てました。

動作確認がてらMicroPythonでプログラミングしてみました。 以下のソースコードで、キー入力を取得し、画面に文字を表示し、音を鳴らすことができます。

"""
Raspberry Pi Pico Sequencer test

Copyright (c) 2023 inajob

This software is released under the MIT License.
http://opensource.org/licenses/mit-license.php
"""

from machine import Pin, I2C, PWM, Timer
import ssd1306
import time
buzzer = PWM(Pin(9))
freqLis = [262, 294, 330, 349, 392, 440, 494, 523]
idx = 0
i2c = I2C(0, sda=Pin(4), scl=Pin(5) , freq=400000)
BTNA = Pin(15, Pin.IN, Pin.PULL_UP)
BTNB = Pin(14, Pin.IN, Pin.PULL_UP)
BTNU = Pin(19, Pin.IN, Pin.PULL_UP)
BTND = Pin(17, Pin.IN, Pin.PULL_UP)
BTNL = Pin(18, Pin.IN, Pin.PULL_UP)
BTNR = Pin(16, Pin.IN, Pin.PULL_UP)

display = ssd1306.SSD1306_I2C(128, 64, i2c)
count = 0
pos = [0,0]
MAX_STEP = int(128/4)
MAX_TONE = int(64/4)
data = [[False] * MAX_TONE for _ in range(MAX_STEP)]
playPos = 0;

def draw():
    display.fill_rect(0,0,128,64,0)
    for step in range(128/4):
        for tone in range(64/4):
            if data[step][tone]:
                display.fill_rect(step*4, tone*4, 4, 4, 1)
    display.fill_rect(pos[0]*4, pos[1]*4, 4, 4, 1)
    display.fill_rect(playPos*4, 0, 1, 64, 1)

def tick(timer):
    global idx
    global count
    global pos
    global playPos
    draw()
    count = count + 1
    if count > 2:
        count = 0
        playPos = playPos + 1
        if playPos >= 8:
            playPos = 0
    display.show()
    
    if(BTNA.value() == 0):
        data[pos[0]][pos[1]] = True
    if(BTNB.value() == 0):
        data[pos[0]][pos[1]] = False
    if(BTNU.value() == 0):
        pos[1] = pos[1] - 1
        if pos[1] < 0:
            pos[1] = 0
    if(BTND.value() == 0):
        pos[1] = pos[1] + 1
    if(BTNL.value() == 0):
        pos[0] = pos[0] - 1
        if pos[0] < 0:
            pos[0] = 0
    if(BTNR.value() == 0):
        pos[0] = pos[0] + 1
    
    f = False
    for i in range(MAX_TONE):
        if data[playPos][i]:
            buzzer.freq(freqLis[i])
            buzzer.duty_u16(256*16)
            f = True
    if not(f):
        buzzer.duty_u16(0)
tim = Timer()
tim.init(period=100, mode=Timer.PERIODIC, callback=tick)

MicroPythonの開発にはThony( https://thonny.org/ )を利用しました。

MicroPythonはコンパイルすることなく、パソコン上のIDEからプログラムを転送し、実行できるので、Arduinoなどに比べるとトライ&エラーがやりやすいと感じました。

Arduinoでのプログラミング

MicroPythonで作曲ソフトを作りましたが、これでは和音に対応できません。 ということでArduinoを使って和音も利用できる作曲ソフトを作ってみました。

OLEDの制御はArduino UNOでよく利用しているu8g2というライブラリがそのまま動いたので、簡単でした。

github.com

ソースコードは雑ですが、、こんな感じです。

/**
Raspberry Pi Pico Sequencer test

Copyright (c) 2023 inajob

This software is released under the MIT License.
http://opensource.org/licenses/mit-license.php
*/

#define NOTE_B0 31
#define NOTE_C1 33
#define NOTE_CS1 35
#define NOTE_D1 37
#define NOTE_DS1 39
#define NOTE_E1 41
#define NOTE_F1 44
#define NOTE_FS1 46
#define NOTE_G1 49
#define NOTE_GS1 52
#define NOTE_A1 55
#define NOTE_AS1 58
#define NOTE_B1 62
#define NOTE_C2 65
#define NOTE_CS2 69
#define NOTE_D2 73
#define NOTE_DS2 78
#define NOTE_E2 82
#define NOTE_F2 87
#define NOTE_FS2 93
#define NOTE_G2 98
#define NOTE_GS2 104
#define NOTE_A2 110
#define NOTE_AS2 117
#define NOTE_B2 123
#define NOTE_C3 131
#define NOTE_CS3 139
#define NOTE_D3 147
#define NOTE_DS3 156
#define NOTE_E3 165
#define NOTE_F3 175
#define NOTE_FS3 185
#define NOTE_G3 196
#define NOTE_GS3 208
#define NOTE_A3 220
#define NOTE_AS3 233
#define NOTE_B3 247
#define NOTE_C4 262
#define NOTE_CS4 277
#define NOTE_D4 294
#define NOTE_DS4 311
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_FS4 370
#define NOTE_G4 392
#define NOTE_GS4 415
#define NOTE_A4 440
#define NOTE_AS4 466
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_CS5 554
#define NOTE_D5 587
#define NOTE_DS5 622
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_FS5 740
#define NOTE_G5 784
#define NOTE_GS5 831
#define NOTE_A5 880
#define NOTE_AS5 932
#define NOTE_B5 988
#define NOTE_C6 1047
#define NOTE_CS6 1109
#define NOTE_D6 1175
#define NOTE_DS6 1245
#define NOTE_E6 1319
#define NOTE_F6 1397
#define NOTE_FS6 1480
#define NOTE_G6 1568
#define NOTE_GS6 1661
#define NOTE_A6 1760
#define NOTE_AS6 1865
#define NOTE_B6 1976
#define NOTE_C7 2093
#define NOTE_CS7 2217
#define NOTE_D7 2349
#define NOTE_DS7 2489
#define NOTE_E7 2637
#define NOTE_F7 2794
#define NOTE_FS7 2960
#define NOTE_G7 3136
#define NOTE_GS7 3322
#define NOTE_A7 3520
#define NOTE_AS7 3729
#define NOTE_B7 3951
#define NOTE_C8 4186
#define NOTE_CS8 4435
#define NOTE_D8 4699
#define NOTE_DS8 4978

int tones[] = {
  NOTE_C4, NOTE_D4, NOTE_E4, NOTE_F4, NOTE_G4, NOTE_A4, NOTE_B4,
  NOTE_C5, NOTE_D5, NOTE_E5, NOTE_F5, NOTE_G5, NOTE_A5, NOTE_B5,
  NOTE_C6, NOTE_D6, NOTE_E6, NOTE_F6, NOTE_G6, NOTE_A6, NOTE_B6
};

#include "hardware/pwm.h"
#include <U8g2lib.h>
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);

#define PWM_AUDIO_L    (9)
//#define PWM_AUDIO_R    (28)
#define RANDOMBIT      (*((uint *)(ROSC_BASE + ROSC_RANDOMBIT_OFFSET)) & 1)

#define PWM_RANGE_BITS (10)
#define PWM_RANGE      (1<<PWM_RANGE_BITS)
#define NUM_PSG        (4)
#define PSG_VOL_MAX    (PWM_RANGE / NUM_PSG - 1)
#define SAMPLE_RATE    (125000000 / PWM_RANGE)
#define OMEGA_UNIT     (FIXED_1_0 / SAMPLE_RATE)

#define FIXED_0_5      (0x40000000)
#define FIXED_1_0      (0x7fffffff)
typedef uint32_t fixed;       // 1.0=0x7fffffff, 0.0=0x0

enum psg_type {OSC_SQUARE, OSC_SAW, OSC_TRI, OSC_NOISE};

struct psg_t {
    volatile fixed phi;       // 0..FIXED_1_0
    fixed step;               // 0..FIXED_1_0
    volatile int sound_vol;   // 0..PSG_VOL_MAX
    enum psg_type type;
};

static struct psg_t psg[NUM_PSG];

void psg_freq(int i, float freq) {
    assert(i < NUM_PSG);
    psg[i].step = freq * OMEGA_UNIT;
}

void psg_vol(int i, int value) {
    assert(i < NUM_PSG);
    if (value < 0) {
        value = 0;
    }
    psg[i].sound_vol = value % (PSG_VOL_MAX + 1);
}

void psg_type(int i, enum psg_type type) {
    assert(i < NUM_PSG);
    psg[i].type = type;
}

static inline uint psg_value(int i) {
    assert(i < NUM_PSG);
    uint result;
    if (psg[i].type == OSC_SQUARE) {
        result = (psg[i].phi > FIXED_0_5) ? psg[i].sound_vol : 0;
    } else if (psg[i].type == OSC_SAW) {
        result = ((psg[i].phi >> (31 - PWM_RANGE_BITS)) * psg[i].sound_vol) >> PWM_RANGE_BITS;
    } else if (psg[i].type == OSC_TRI) {
        result = ((psg[i].phi >> (30 - PWM_RANGE_BITS)) * psg[i].sound_vol) >> PWM_RANGE_BITS;
        result = (result < psg[i].sound_vol) ? result : psg[i].sound_vol * 2 - result;
    } else { // OSC_NOISE
        result = RANDOMBIT * psg[i].sound_vol;
    }
    return result;
}

static inline void psg_next() {
    for(int i = 0; i < NUM_PSG; i++) {
        psg[i].phi += psg[i].step;
        if (psg[i].phi > FIXED_1_0) {
            psg[i].phi -= FIXED_1_0;
        }
    }
}

void on_pwm_wrap() {
    uint sum = 0;

    pwm_clear_irq(pwm_gpio_to_slice_num(PWM_AUDIO_L));
#ifdef PWM_AUDIO_R
    pwm_clear_irq(pwm_gpio_to_slice_num(PWM_AUDIO_R));
#endif
    psg_next();
    for(int i = 0; i < NUM_PSG; i++) {
        sum += psg_value(i);
    }
    pwm_set_gpio_level(PWM_AUDIO_L, sum);
#ifdef PWM_AUDIO_R
    pwm_set_gpio_level(PWM_AUDIO_R, sum);
#endif
}

void psg_pwm_config() {
    gpio_set_function(PWM_AUDIO_L, GPIO_FUNC_PWM);
#ifdef PWM_AUDIO_R
    gpio_set_function(PWM_AUDIO_R, GPIO_FUNC_PWM);
#endif
    uint slice_num = pwm_gpio_to_slice_num(PWM_AUDIO_L);
    pwm_clear_irq(slice_num);
    pwm_set_irq_enabled(slice_num, true);
    irq_set_exclusive_handler(PWM_IRQ_WRAP, on_pwm_wrap);
    irq_set_enabled(PWM_IRQ_WRAP, true);

    pwm_config config = pwm_get_default_config();
    pwm_config_set_clkdiv_int(&config, 1);
    pwm_config_set_wrap(&config, PWM_RANGE);
    pwm_init(slice_num, &config, true);
#ifdef PWM_AUDIO_R
    slice_num = pwm_gpio_to_slice_num(PWM_AUDIO_R);
    pwm_init(slice_num, &config, true);
#endif
}

void psg_init() {
    for(int i = 0; i < NUM_PSG; i++) {
        psg[i].phi = 0;
        psg[i].step = 0;
        psg[i].sound_vol = PSG_VOL_MAX / 4;
        psg[i].type = OSC_SQUARE;
    }
    psg_pwm_config();
}

void psg_all_vol(int value) {
    for(int i = 0 ; i < NUM_PSG; i++) {
        psg_vol(i, value);
    }
}

int channel = 0;
int cursorX = 0;
int cursorY = 0;
int pos = 0;
#define SIZE 16
bool scoreData[SIZE][32];
int trigger[6];

void setup() {
  psg_init();

  psg_type(0, OSC_SQUARE);
  psg_type(1, OSC_SQUARE);
  psg_all_vol(0);
  psg_vol(channel, PSG_VOL_MAX / 4);
  psg_vol(1, PSG_VOL_MAX / 4);
  psg_freq(channel, 220);
  psg_freq(1, 220);

  pinMode(14, INPUT_PULLUP);
  pinMode(15, INPUT_PULLUP);
  pinMode(16, INPUT_PULLUP);
  pinMode(17, INPUT_PULLUP);
  pinMode(18, INPUT_PULLUP);
  pinMode(19, INPUT_PULLUP);
  
  u8g2.begin();
  u8g2.setFont(u8g2_font_ncenB08_tr);

  for(int i = 0; i < 128/SIZE; i ++){
    for(int j = 0; j < 32; j ++){
      scoreData[i][j] = false;
    }
  }
  for(int i = 0; i < 6; i ++){
    trigger[i] = 0;
  }
}

void draw(){
  u8g2.drawLine(pos*128/SIZE, 0, pos*128/SIZE, 63);
  for(int i = 0; i < SIZE; i ++){
    for(int j = 0; j < 32; j ++){
      if(scoreData[i][j]){
        u8g2.drawBox(i*128/SIZE, j*2, 128/SIZE, 2);
      }
    }
  }
  u8g2.drawBox(cursorX * 128/SIZE, cursorY*2, 128/SIZE, 2);
}

int count = 0;

void loop() {
  count ++;
  if(count > 3){
    count = 0;
    pos ++;
    if(pos > SIZE - 1){pos = 0;}
    for(int i = 0; i < NUM_PSG; i ++){
      psg_freq(i, 0);
      psg_vol(i, 0);
    }
    int freeChan = 0;
    for(int j = 0; j < 32; j ++){
      if(scoreData[pos][j]){
        psg_vol(freeChan, PSG_VOL_MAX);
        psg_freq(freeChan, tones[j]);
        freeChan ++;
        if(freeChan >= NUM_PSG){
          freeChan = 0;
        }
      }
    }
  }

  if(!digitalRead(16)){trigger[0] ++;}else{trigger[0] = -1;}
  if(!digitalRead(17)){trigger[1] ++;}else{trigger[1] = -1;}
  if(!digitalRead(18)){trigger[2] ++;}else{trigger[2] = -1;}
  if(!digitalRead(19)){trigger[3] ++;}else{trigger[3] = -1;}
  if(!digitalRead(15)){trigger[4] ++;}else{trigger[4] = -1;}
  if(!digitalRead(14)){trigger[5] ++;}else{trigger[5] = -1;}


  if(trigger[0] == 3){cursorX ++;}
  if(trigger[1] == 3){cursorY ++;}
  if(trigger[2] == 3){cursorX --;}
  if(trigger[3] == 3){cursorY --;}
  if(trigger[4] == 3){scoreData[cursorX][cursorY] = true;}
  if(trigger[5] == 3){scoreData[cursorX][cursorY] = false;}

  for(int i = 0; i < 6; i ++){
    if(trigger[i] == -1)trigger[i] = 0;
  }

  if(cursorX < 0)cursorX = 0;
  if(cursorY < 0)cursorY = 0;
  if(cursorX > SIZE - 1)cursorX = SIZE - 1;
  if(cursorY > 64/2 - 1)cursorY = 64/2 - 1;

  sleep_ms(3);
  
  u8g2.clearBuffer();
  draw();
  u8g2.sendBuffer();
}

参考にしたもの

以下のRP2040を使ったゲーム機の回路を参考にしており、SPIのOLEDを搭載した場合は、同じように動作するように設計しました(試していないですが・・)

github.com

和音を使った演奏については以下の記事を参考にしました

blog.boochow.com

まとめ

この手のゲーム機といえば、Arduboy( https://www.arduboy.com/ )が有名で、自分も本物を持っていますし、互換機を作ったこともあります、それに比べるとこのRaspberry Pi Picoを使ったゲーム機は、性能がケタ違いに高いものとなっており、様々なゲームを動かすポテンシャルがあります。

また、半導体不足の影響もあり、おそらくRaspberry Pi Picoを使ったこのゲーム機の方が安上がりで作れそうです。

まぁ、Arduboyほどのスタイリッシュなデザインにするのは、かなり大変で、Arduboyの価値はそちらの方にあると思うのですが、自作ゲーム機界隈も日進月歩だなと感じています。

最後に、この基板が数枚余っていますので、もしほしい人がいたら連絡ください。 (可能なら、ボツ基板交換的なノリで、何か自作の面白い基板と交換してもらえると嬉しいです。 私が提供できるボツ基板はこちらにまとめています。