前回記事「Arduinoでノンプリエンプティブな周期的タスクを起動するちょっと変わったマクロ」の応用として、ブロック崩しを作ってみました。

まずはデモから

Arduino UNO R4 と 1.3” カラー LCD(解像度 240X240、ドライバーIC ST7789VW、Amazon でカラー LCD を検索するとよく目にする「🍒」が特徴のヤツ)で作成しました。電源を ON すると、ポテンショメータを動かすまでは自動で打ち返すデモが続きます。

ソースコードやワイヤリングは、GitHub の Arduino-UNO-R4/breakout を覗いてみて下さい。また R4 以前での動作を確認するため Wokwi を試してみました。5V → 3.3V のロジックレベル変換を省いてますし、現実に動作するかどうかも保証の限りではないのでご注意を。

ブロック崩しのワイヤリング
ブロック崩しのワイヤリング
Wokwiでブロック崩しをシミュレーション
Wokwiでブロック崩しをシミュレーション

ノンプリエンプティブなマルチタスクのテンプレート

前回の記事 では、複数のタスクを周期的に起動するマクロ DO_EVERY を紹介しました。今回はそれに状態コントローラ(ステートマシン)を加えた、次のようなコードを考えます。Arduino IDE にコピペすれば、コンパイルは通りますが、もちろん何も起きません。

// Non-preemptive multitasking
#define DO_EVERY(period, prev)  static uint32_t prev = 0; for (uint32_t now = millis(); now - prev >= period; prev = now)

#define INTERVAL1 110 // [msec]
#define INTERVAL2 120 // [msec]
#define INTERVAL3 130 // [msec]

void StateController(void) {
  ;
}

void setup() {
  ;
}

void loop() {
  // 状態コントローラ
  StateController();

  // 1つ目のタスク
  DO_EVERY(INTERVAL1, Timer1) {
    ; // 何らかの処理
  }

  // 2つ目のタスク
  DO_EVERY(INTERVAL2, Timer2) {
    ; // 何らかの処理
  }

  // 3つ目のタスク
  DO_EVERY(INTERVAL3, Timer3) {
    ; // 何らかの処理
  }
}

このコードの意図は、以下の繰り返しになります。

  • 状態コントローラは、各タスクの状態を常に監視し、システム状態を決定する
  • 各タスクは、状態コントローラが決定したシステム状態に応じた処理を実行する
  • 各タスクは、自身の状態が変化したら、その旨を状態コントローラに通知する

状態コントローラと各タスク間でどのように状態をやり取りをするか、ここでは具体的な方法を示していませんが、Arduino であればグローバル変数を介したやり取りで十分でしょう。なぜなら、リアルタイム OS で制御される「プリエンプティブなマルチタスク」ではなく、あくまで「ノンプリエンプティブなマルチタスク」だからです 1 。

つまりは(割り込みを使わない限り 2 )グローバル変数を書き換える際の排他制御も不要だし、排他制御が原因で起きる デッドロック も心配しなくてイイという事です。

ただし、どのタスクも所定の時間内には終わるように作りましょうネ。

ブロック崩しゲームの分析

以下、何となくオブジェクト指向な書きっぷりで説明しますが、実際のコードは C++ ではなく C で書います :stuck_out_tongue_winking_eye:

まず最初の オブジェクト として、「状態コントローラ」にゲームの進行を司る役割を持たせることを考えます。このオブジェクトが持つ状態遷移を図で表すと 3 、ザックリですが次のように想定できます。

  stateDiagram-v2
    [*] --> ゲーム起動中:電源ON
    ゲーム起動中 --> [*]:電源OFF
    state ゲーム起動中 {
      [*] --> オープニング
      オープニング --> プレイ開始:プレイ開始のトリガ
      プレイ開始 --> プレイ中:プレイの準備完了
      プレイ中 --> ステージクリア:ブロックが全て消去された
      ステージクリア --> プレイ開始:次のステージの準備完了
      プレイ中 --> ゲームオーバー:ボール残数がゼロ
      ゲームオーバー --> オープニング:オープニング開始のトリガ
    }
  

「オープニング」ではゲームタイトルが表示され、カッコいい音楽が流れるイメージですかネ。

続いてゲーム中の主要なオブジェクトとして、ブロック、ボール、ラケットの3つを考えます(「壁」は能動的な役割や状態を持たないので分析の対象外)。これらオブジェクトの振る舞いは、およそ次のようになるでしょう。

  1. ブロック
    • プレイ開始時に、全てのブロックが消されていない状態に準備する
    • 消されていないブロックを描画する
    • 消されるべきブロックを画面から消去する
    • 全てのブロックが消去されたら、その旨を状態コントローラに通知する
  2. ボール
    • プレイ開始時に、新しいボールの打ち出しを準備する
    • プレイ中は、自身の移動方向に応じてボールを動かす
    • 壁、ブロック、ラケットに当たったら移動方向を変える
    • ブロックに当たったら、スコアアップを状態コントローラに通知する
    • ラケットより下に移動したら、ボール残数の減少を状態コントローラに通知する
  3. ラケット
    • ユーザ入力を監視する
    • オープニング中にユーザ入力があれば、プレイ開始を状態コントローラに通知する
    • プレイ中は、ユーザ入力に応じてラケットを動かす

これらのオブジェクトも自身の状態と状態遷移を持ち得ますが、必ずしもゲーム進行の詳細を知らずとも良いようにすることがポイントです。即ち、状態コントローラが指示するシステム状態に従い「せっせと自分の仕事に専念する」ことができるようにしてあげます。

設計の概要

続いて「状態遷移の仕組み」と「状態コントローラと各オブジェクトの役割分担」をより具体的に落とし込んでいきます。

状態遷移の仕組み

先の状態遷移図に則り、ゲーム進行を司る「システム状態」を、次のような emum(列挙型)として定義します。

typedef enum {
  OPENING,  // オープニング
  START,    // プレイ開始
  PLAYING,  // プレイ中
  CLEAR,    // ステージクリア
  GAMEOVER, // ゲームオーバー
} Status_t;

この Status_t をグローバル変数に割り当て、状態コントローラを switch - case文 で構成すると、次のようなコードになります。

Status_t status = OPENING;

void StateController(void) {
  switch (status) {
    case OPENING:
      ; // 何らかの処理
      break;
    case START:
      ; // 何らかの処理
      break;
    case PLAYING:
      ; // 何らかの処理
      break;
    case CLEAR:
      ; // 何らかの処理
      break;
    case GAMEOVER:
    default:
      ; // 何らかの処理
      break;
  }
}

続いて先の「状態遷移図」を眺めつつ、状態コントローラの役割をもう少し具体化することで「何らかの処理」の内容を決めていきます。

状態コントローラと各オブジェクトの役割分担

各オブジェクトも独自の状態と状態遷移を持ち得ますが、今回はそこまで複雑にせず、遷移時に必要な各オブジェクトのメソッドを状態コントローラが呼び出す実装とします。

例えば「ボール」オブジェクトが持つメソッド「プレイ開始時に、新しいボールの打ち出しを準備する」を「ボール」自身の状態遷移中で実行するのではなく、状態コントローラが「case START:break;」で同メソッドを呼び出すと言うワケです。

このような実装のメリットとデメリットは以下となります。

  • メリット
    各オブジェクトは、必要とされるメソッドを備えるだけで良い(ゲーム進行に伴う状態遷移時の詳細を知る必要がない、 即ち状態コントローラへの依存度が低くなる

  • デメリット
    状態コントローラは、状態遷移時に必要な処理を行う責任がある(各オブジェクトのメソッドを知っている必要がある、即ち各オブジェクトへの依存度が高くなる

オブジェクト指向のキーワード(というかソフトウェア品質の指標)に「高凝集・疎結合」があり、将来の拡張性を考えればメリデメを反転させる考え方もあり得ますが、今回はシンプルさを優先させることとします。

さらにゲームの進行上、スコアとボール残数の管理や表示、「ゲームオーバー」等のメッセージ表示機能の割り当てを考えなければなりません。新たなオブジェクトを定義する手もありますが、前述同様にシンプルさを優先させ、それらも状態コントローラに割り振る事にします。

ボール残数とスコアの管理からステージクリア時の処理やメッセージの表示まで、ゲーム進行に関する全段取りを整える役割を状態コントローラに負ってもらうワケで、その責任は重大ですネ。

実装の概要

前章の役割分担に基づき、各オブジェクトのメンバー変数やメソッドを定義し、loop() 内の処理を書いていきます。実際のプログラムを C で書いている都合上(publicprivate を区別せず全てグローバルにしちゃっていて、カッコ悪いので…)、ここでは loop() 内の処理についてだけ示したいと思います。

ちなみに、「ブロック」オブジェクトには能動的な動きがないので、タスクは割り当てなくても良いと思います。

「状態コントローラ」オブジェクト

およそ次のような感じになると思います。括弧(「オブジェクト」)は、それぞれのメソッドを呼び出すことを意図しています。また「メッセージを表示する」は、数秒間ポーズをかける仕組みが必要になりそうです。

void StateController(void) {
  switch (status) {
    case OPENING:
      // ゲームを初期化する;
      // オープニング画面を描画する;
      // プレイ開始のトリガで status = START;
      break;
    case START:
      // 「ボール」の位置と方向を設定する;
      // 「ブロック」を初期化して描画する;
      // status = PLAYING;
      break;
    case PLAYING:
      // 「ブロック」が全て消去されたら status = CLEAR;
      // 「ラケット」に当たらず空振したら status = START;
      // 「ボール」残数がゼロになったら status = GAMEOVER;
      // ステージやスコア、ボール残数を表示する;
      break;
    case CLEAR:
      // ステージクリア時のメッセージを表示する;
      // 次ステージの準備をする;
      // status = START;
      break;
    case GAMEOVER:
      // ゲーム終了のメッセージを表示する;
      // オープニング開始のトリガで status = OPENING;
      break;
  }
}

「ボール」オブジェクトのタスク

「プレイ中」は、ひたすらボールを移動させると同時に、壁やブロック、ラケットへの衝突を判定し、衝突時には移動方向を変えたりする処理を書く事になります。

  // ボールオブジェクトのタスク
  DO_EVERY(INTERVAL1, Timer1) {
    if (status == PLAYING) {
      // ボールの位置を更新する;
      // 壁、ブロック、ラケットへの衝突を判定し、衝突時は移動方向を変える;
      // 更新された位置に基づきボールを描画する;
    }
  }

「ラケット」オブジェクトのタスク

ひたすらユーザ入力を監視し、ラケットを動かすだけですネ。

  // ラケットオブジェクトのタスク
  DO_EVERY(INTERVAL2, Timer2) {
    // ユーザ操作を読み取る
    // 読み取った位置に応じてラケットを描画する
  }

ブロック崩しのコア部分は…

さて、肝心のブロック崩しですが、以下の記事から流用させてもらうこととしました(オィ)。

作者の boochow さんには、流用したコードの公開を快諾していただきました。それぞれの記事と添付されたプログラムには、以下の優れた特徴があります。

  • 小さなプログラムから始まり、少しづつ機能を追加していくプロセスがとてもわかり易い
  • 仮想のスクリーン解像度を設定し、異なる解像度を持つ LCD への移植を容易にしている
  • 浮動小数点数を用いず、整数計算だけで実現しているため、処理負荷がとても軽い
  • 意図的に各オブジェクトの役割が整理され、コーディングに反映されている

ゲームなど作ったことのない僕にとって正に救いの神であり、また今回のお題にピッタリというワケです。

さらにちょっと欲張って、次の機能を追加してみました。

  • 電源投入直後は、自動でボールを打ち返すデモモードが動作する
  • ラケットを操作するポテンショメータを動かすと、ゲームが始まる
  • 連続してブロックに当たると、どんどん得点が高くなるコンボ機能
  • (入射角と反射角の法則に反し)打ち返したラケットの位置に応じて反射角が変わる
  • ステージをクリアする度に少しづつブロックが下がり、ボールの移動速度が早くなる

ということで、スコアやボールの管理、状態の通知方法やメッセージの表示処理等、説明していない部分もあり、また説明とのズレも多少ありますが、作成したプログラムを載せておきます。オブジェクト指向的な説明をしながらも 手抜きして オリジナルの良さを生かし、C で実装しているところもご勘弁ください :sweat_smile:

作成したプログラム

(ご参考)
/*
 * Copyright (c) 2024 embedded-kiddie
 * Copyright (c) 2015 boochow
 * Released under the MIT license
 * https://opensource.org/license/mit
 */
#include <Adafruit_GFX.h>    // Core graphics library
#include <Adafruit_ST7789.h> // Hardware-specific library for ST7789
#include <SPI.h>

/* SPI pin definition for Arduino UNO R3 and R4
  | ST7789 | PIN  |  R3  |   R4   |     Description      |
  |--------|------|------|--------|----------------------|
  | SCL    |  D13 | SCK  | RSPCKA | Serial clock         |
  | SDA    | ~D11 | COPI | COPIA  | Serial data input    |
  | RES    | ~D9  | PB1  | P303   | Reset signal         |
  | DC     |  D8  | PB0  | P304   | Display data/command |
*/
#define TFT_CS 10
#define TFT_RST 9  // Or set to -1 and connect to Arduino RESET pin
#define TFT_DC  8

#define DEVICE_WIDTH  240
#define DEVICE_HEIGHT 240
#define DEVICE_ORIGIN 2

Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST);

#define PIN_RACKET  A5  // Potentiometer or Joystick
#define PIN_SOUND   7   // Buzzer

// Pseudo screen scaling
#define SCREEN_SCALE  2 // 2 (60 x 60) or 3 (30 x 30)
#define SCREEN_DEV(v) ((int)(v) << SCREEN_SCALE) // Screen to Device
#define DEV_SCREEN(v) ((int)(v) >> SCREEN_SCALE) // Device to Screen
#define SCREEN_WIDTH  DEV_SCREEN(DEVICE_WIDTH)
#define SCREEN_HEIGHT DEV_SCREEN(DEVICE_HEIGHT)

// Block (Screen coordinate system)
#define BLOCK_ROWS    5
#define BLOCK_COLS    10
#define BLOCK_WIDTH   (SCREEN_WIDTH / BLOCK_COLS)
#define BLOCK_HEIGHT  DEV_SCREEN( 8)
#define BLOCK_TOP     DEV_SCREEN(18)
#define BLOCK_END(t)  ((t) + BLOCK_ROWS * BLOCK_HEIGHT - 1)

// Ball
#define BALL_SIZE     7 // [px] (Device coordinate system)
#define BALL_MOVE_X   (SCREEN_SCALE <= 2 ? 2 : 1) // Screen coordinate system
#define BALL_MOVE_Y   (SCREEN_SCALE <= 2 ? 2 : 1) // Screen coordinate system
#define BALL_CYCLE    (SCREEN_SCALE * 18) // [msec]
#define DEMO_CYCLE    (SCREEN_SCALE *  8) // [msec]

// Racket (Screen coordinate system)
#define RACKET_WIDTH  DEV_SCREEN(44)
#define RACKET_HEIGHT DEV_SCREEN( 8)
#define RACKET_TOP    (SCREEN_HEIGHT - RACKET_HEIGHT)
#define RACKET_CYCLE  16

// Wall (Screen coordinate system)
#define WALL_TOP      0
#define WALL_LEFT     0
#define WALL_RIGHT    (SCREEN_WIDTH - 1)

// Font size for setTextSize(2)
#define FONT_WIDTH    12 // [px] (Device coordinate system)
#define FONT_HEIGHT   16 // [px] (Device coordinate system)

// Drawing level and score
#define DRAW_SCORE    2
#define DRAW_ALL      3

// Tone frequency
#include "pitches.h"
#define HIT_BLOCK   NOTE_C4
#define HIT_RACKET  NOTE_C3

// Colors by 16-bit (R5-G6-B5)
#define BLACK     ST77XX_BLACK
#define WHITE     ST77XX_WHITE
#define RED       ST77XX_RED
#define GREEN     ST77XX_GREEN
#define BLUE      ST77XX_BLUE
#define CYAN      ST77XX_CYAN
#define MAGENTA   ST77XX_MAGENTA
#define YELLOW    ST77XX_YELLOW
#define ORANGE    ST77XX_ORANGE

// Misc functions
#define SIGN(a)   ((a) > (0) ? (1) : (-1))
#define NARR(a, t) (sizeof(a) / sizeof(t))

#define ClearScreen() tft.fillScreen(BLACK)
#define ClearMessage() tft.fillRect(0, DEVICE_HEIGHT / 2, DEVICE_WIDTH - 1, FONT_HEIGHT * 2, BLACK)

#if (SCREEN_SCALE <= 2)
#define DrawBall(ball, tft, color) tft.fillCircle(SCREEN_DEV(ball.x), SCREEN_DEV(ball.y), (BALL_SIZE >> 1), (color))
#else
#define DrawBall(ball, tft, color) tft.fillRect(SCREEN_DEV(ball.x), SCREEN_DEV(ball.y), BALL_SIZE, BALL_SIZE, (color))
#endif
#define DrawRacket(x, tft, color) tft.fillRect(SCREEN_DEV(x), SCREEN_DEV(RACKET_TOP), SCREEN_DEV(RACKET_WIDTH), SCREEN_DEV(RACKET_HEIGHT), (color))

// Type definitions
typedef enum {
  OPENING,
  START,
  PLAYING,
  CLEAR,
  GAMEOVER,
} Status_t;

typedef struct {
  bool      demo;
  Status_t  status;
  uint8_t   level;
  uint8_t   balls;
  uint8_t   block_top;
  uint8_t   block_end;
  uint8_t   ball_cycle;
  uint8_t   racket_width;
  uint8_t   combo;
  int8_t    spin;
  uint16_t  score;
  uint32_t  pause;
} Play_t;

typedef struct {
  int16_t   x, y;
  int16_t   dx, dy;
} Ball_t;

typedef struct {
  int16_t   x;
  int16_t   x_prev;
  uint8_t   count;
} Racket_t;

// Global variables
Play_t play;
Ball_t ball;
Racket_t racket;
bool blocks[BLOCK_ROWS][BLOCK_COLS];

void GameInit(bool demo);

void DrawMessage(uint32_t pause, uint16_t x, const char* msg) {
  tft.setTextSize(3);
  tft.setCursor(x, DEVICE_HEIGHT / 2);

  size_t len = strlen_P(msg);
  for (int i = 0 ; i < len ; i++) {
    tft.print((char)pgm_read_byte(msg++));
  }

  play.pause = millis() + pause;
}

void DrawScore(int refresh = 0) {
  tft.setTextSize(2);
  tft.setCursor(4, 0);
  tft.print("Lv:");

  // Level (3 digits)
  if (refresh == DRAW_ALL) {
    tft.fillRect(40, 0, FONT_WIDTH * 3, FONT_HEIGHT, BLACK);
  }
  if (refresh != DRAW_SCORE) {
    tft.setCursor(40, 0);
    tft.print(play.level);
  }

  // Score (5 digits)
  if (refresh & DRAW_SCORE) {
    tft.fillRect(96, 0, FONT_WIDTH * 5, FONT_HEIGHT, BLACK);
  }
  char buf[6];
  sprintf(buf, "%05d", play.score);
  tft.setCursor(96, 0);
  tft.print(buf);

  // Balls (5 digits)
  if (refresh == DRAW_ALL) {
    tft.fillRect(175, 0, DEVICE_WIDTH - 175, FONT_HEIGHT, BLACK);
  }
  if (refresh != DRAW_SCORE) {
    for (int i = 0; i < play.balls; i++) {
      tft.fillCircle(230 - (i * BALL_SIZE * 3 / 2), BALL_SIZE >> 1, BALL_SIZE >> 1, YELLOW);
    }
  }
}

// Block related method
void BlocksInit() {
  memset((void*)blocks, (int)true, NARR(blocks, bool));
}

int8_t BlocksCount() {
  int8_t n = 0;
  bool *p = (bool*)blocks;

  for (int8_t i = 0; i < NARR(blocks, bool); i++) {
    n += (int8_t)*p++;
  }

  return n;
}

void BlocksDrawAll() {
  static const uint16_t colors[] PROGMEM = {CYAN, MAGENTA, YELLOW, RED, GREEN, ORANGE};

  int16_t x, y;
  int16_t c = 0;
  bool *p = (bool*)blocks;

  for(y = play.block_top; y <= play.block_end; y += BLOCK_HEIGHT, c = (c + 1) % NARR(colors, uint16_t)) {
    for(x = 0; x < SCREEN_WIDTH; x += BLOCK_WIDTH) {
      if (*p++) {
        tft.fillRect(SCREEN_DEV(x), SCREEN_DEV(y), SCREEN_DEV(BLOCK_WIDTH), SCREEN_DEV(BLOCK_HEIGHT), pgm_read_word(&colors[c]));
        tft.drawRect(SCREEN_DEV(x), SCREEN_DEV(y), SCREEN_DEV(BLOCK_WIDTH), SCREEN_DEV(BLOCK_HEIGHT), BLACK);
      }
    }
  }
}

void BlocksEraseOne(int16_t row, int16_t col) {
  int16_t x = col * BLOCK_WIDTH;
  int16_t y = row * BLOCK_HEIGHT + play.block_top;

  tft.fillRect(SCREEN_DEV(x), SCREEN_DEV(y), SCREEN_DEV(BLOCK_WIDTH), SCREEN_DEV(BLOCK_HEIGHT), BLACK);
  tone(PIN_SOUND, HIT_BLOCK, 20);
  blocks[row][col] = false;
  play.score += ++play.combo;
  DrawScore(DRAW_SCORE);
}

bool BlockExist(int16_t x, int16_t y) {
  int16_t row = (y - play.block_top);
  int16_t col = (x - WALL_LEFT     );

  if (row >= 0 && col >= 0) {
    row /= BLOCK_HEIGHT;
    col /= BLOCK_WIDTH;

    if (row < BLOCK_ROWS && col < BLOCK_COLS && blocks[row][col]) {
      BlocksEraseOne(row, col);
      return true;
    }
  }

  return false;
}

void BlocksCheckHit(void) {
  if (BlockExist(ball.x + ball.dx, ball.y)) {
    ball.dx = -ball.dx;
  }

  if (BlockExist(ball.x, ball.y + ball.dy)) {
    ball.dy = -ball.dy;
  }

  if (BlockExist(ball.x + ball.dx, ball.y + ball.dy)) {
    ball.dx = -ball.dx;
    ball.dy = -ball.dy;
  }
}

// Ball related method
void BallInit(void) {
  DrawBall(ball, tft, BLACK);

  int16_t x = random(1, SCREEN_WIDTH - 1);
  ball = {
    .x  = (int16_t)x,
    .y  = (int16_t)(play.block_end + BLOCK_HEIGHT),
    .dx = (int16_t)(x > (SCREEN_WIDTH >> 1) ? -BALL_MOVE_X : BALL_MOVE_X),
    .dy = (int16_t)BALL_MOVE_Y
  };
}

bool BallLost(void) {
  return ball.y >= RACKET_TOP ? true : false;
}

void BallMove(void) {
  if (play.status  == PLAYING && play.pause == 0) {
    int16_t nx = abs(ball.dx);
    int16_t ny = abs(ball.dy);
    int16_t dx = SIGN(ball.dx);
    int16_t dy = SIGN(ball.dy);

    do {
      DrawBall(ball, tft, BLACK);

      if (nx > 0) {
        nx--;
        ball.x += dx;
        if (ball.x == SCREEN_WIDTH - 1 || ball.x == 0) {
          ball.dx = -ball.dx;
          dx = -dx;
        }
      }

      if (ny > 0) {
        ny--;
        ball.y += dy;
        if (ball.y == RACKET_TOP - 1) {
          if (racket.x - 1 <= ball.x && ball.x <= racket.x + RACKET_WIDTH) {
#if (SCREEN_SCALE <= 2)
            int8_t d = ball.x - (racket.x + (RACKET_WIDTH >> 1));
            if (abs(d) < (RACKET_WIDTH >> 2)) {
              ball.dx = SIGN(ball.dx) * (BALL_MOVE_X >> 1); // center
            } else {
              ball.dx = SIGN(ball.dx) * (BALL_MOVE_X); // edge
            }
#endif
            play.combo = 0;
            ball.dy = -ball.dy;
            dy = -dy;
            tone(PIN_SOUND, HIT_RACKET, 20);
          }
        }
      }

      if (ball.y == WALL_TOP) {
        ball.dy = -ball.dy;
        dy = -dy;
      }

      DrawBall(ball, tft, YELLOW);
      BlocksCheckHit();
    } while (nx > 0 || ny > 0);

    // Redraw game score when ball is inside the drawing area
    if (ball.y <= DEV_SCREEN(FONT_HEIGHT) + DEV_SCREEN(BALL_SIZE)) {
      DrawScore();
    }
  }
}

// Racket related method
void RacketInit() {
  racket = { racket.x, racket.x, 0 };
}

void RacketMove(void) {
  int16_t x, before = racket.x;

  x = map(analogRead(PIN_RACKET), 0, 1023, -5, SCREEN_WIDTH - RACKET_WIDTH + 5);
  x = constrain(x, WALL_LEFT, WALL_RIGHT - RACKET_WIDTH + 1);

  if (play.demo == false) {
    racket.x = x;
  } else {
    // Once user moves the racket sufficiently, demo mode will be disabled
    int16_t dx = x - racket.x_prev;
    if (abs(dx) > 1 && ++racket.count > 1) {
      racket.x = x;
      GameInit(false); // --> demo = false, status = OPENING
    } else {
      racket.x_prev = x;
      racket.x = ball.x - (RACKET_WIDTH >> 1);
      racket.x = min(max(racket.x, WALL_LEFT), WALL_RIGHT - RACKET_WIDTH + 1);
    }
  }

  if (before != racket.x) {
    DrawRacket(before, tft, BLACK);
  }

  DrawRacket(racket.x, tft, WHITE);  
}

// Play control method
void PlayInit(bool demo) {
  play = { demo, OPENING, 1, 5, BLOCK_TOP, BLOCK_END(BLOCK_TOP), (uint8_t)(demo ? DEMO_CYCLE : BALL_CYCLE), RACKET_WIDTH, 0, };
}

void PlayNext(void) {
  play.level++;
  play.ball_cycle -= 1;
  play.ball_cycle = max(play.ball_cycle, (play.demo ? DEMO_CYCLE : BALL_CYCLE >> 1));
  play.block_top += (BLOCK_HEIGHT >> 1);
  play.block_top = min(play.block_top, (BLOCK_TOP + BLOCK_HEIGHT * 5));
  play.block_end = BLOCK_END(play.block_top);
}

void PlayControl(void) {
  if (play.pause == 0) {
    switch (play.status) {
      case OPENING:
        ClearScreen();
        GameStart();
        play.status = START;
        break;
      case START:
        BallInit();
        play.status = PLAYING;
        if (play.demo == false) {
          DrawMessage(1000, 70, PSTR("Ready?"));
        }
        break;
      case PLAYING:
        if (BlocksCount() == 0) {
          play.status = CLEAR;
        } else if (BallLost()) {
          play.status = (--play.balls ? START : GAMEOVER);
          DrawScore(DRAW_ALL);
          DrawMessage(1000, 80, PSTR("Oops!"));
        }
        break;
      case CLEAR:
        PlayNext();
        play.status = OPENING;
        if (play.demo == false) {
          DrawMessage(1000, 80, PSTR("Nice!"));
        }
        break;
      case GAMEOVER:
        GameInit(true); // --> demo = true, status = OPENING
        DrawMessage(2000, 40, PSTR("Game Over"));
        break;
    }
  } else if (millis() >= play.pause) {
    ClearMessage();
    play.pause = 0;
  }
}

// Game initialize method
void GameInit(bool demo) {
  PlayInit(demo);
  RacketInit();
}

void GameStart(void) {
  BallInit();
  BlocksInit();
  BlocksDrawAll();
  DrawScore(DRAW_ALL);
}

void setup() {
  tft.init(DEVICE_WIDTH, DEVICE_HEIGHT, SPI_MODE2); // SPI_MODE2 or SPI_MODE3
  tft.setRotation(DEVICE_ORIGIN);
  tft.setTextColor(WHITE);

  GameInit(true);
}

// Non-preemptive multitasking
#define DO_EVERY(period, prev)  static uint32_t prev = 0; for (uint32_t now = millis(); now - prev >= period; prev = now)

void loop() {
  PlayControl();

  DO_EVERY(play.ball_cycle, TimeBall) {
    BallMove();
  }

  DO_EVERY(RACKET_CYCLE, TimeRacket) {
    RacketMove();
  }
}

おわりに

本記事では、オブジェクト指向的な分析・設計が実装と乖離した、何とも中途半端な記事となりましたが、主テーマはタイトル通りとご理解頂ければ幸いです。

興味を持たれた方は、boochow さんの記事や本記事を参考にご自分で実装し、マルチタスクのプログラミングを楽しんでもらうのが宜しいかと思います。

例えば、1画面に複数のボールを出現させようすれば C++ でないとキツイと思いますし、C++ でコーディングできれば、スキルアップ間違いナシです(と、手抜きの言い訳)。

それともう1つ。この手のプログラムでは、「ボールの移動 → 衝突判定 → ラケットの移動 → delay()」といった具合に処理が順番に組まれパターンが多いかと思います。

主題がゲームの実現にあるので致し方ないことですが、状態コントローラの分析次第でゲームの実装設計も変わり得るので、「状態遷移の分析は大事だョ」ということを伝えるべく、本記事を構成したつもりです。

というか、考え方は “Blink Without Delay” と何ら変わりません。Lチカで delay() の代わりに millis() を使うことが BWD というなら、本質の半分しか理解していない事になります。「ステートマシン + millis()」が正しい理解なので、お間違えなく :watermelon:


参考情報

  1. 応用情報技術者試験の設問「ノンプリエンプティブ方式のタスクの状態遷移に関する記述として、適切なものはどれか」が、ノンプリエンプティブなマルチタスクの特徴をよく言い表しています。 

  2. 割り込みハンドラがグローバルな状態変数を書き換える場合は、各タスクで noInterrupts() による割り込み禁止が必要になります。 

  3. 仕事で状態遷移を正確に書こうとすると、結構難しい のですが、慣れていなければ このチュートリアル から始めるのが分かり易くて良いと思います。