【入門】processingで最小限の一筆書きゲームを作る

本記事は,processingで一筆書きのゲームを作る記事です.
早速ですが,完成版は画像のようになると思います.マウスを使ってマス目をなぞり,なぞったところは赤くなります.全てのマス目をなぞることができればクリアです.

f:id:gotutiyan:20200114001632g:plain
完成予想図

まだprocessingをよく知らない方でも完成までたどり着けるように、詳しく書いていきたいと思います。プログラムは60行です. 出てくるプログラムの知識は int型, float型、変数、if、配列、for程度です。どれも事前知識があると嬉しいですが、全て解説は入れているので、問題ないのではないかなと思います。

はじめに

座標系

processingを使うと、自由に図形を描くことができます。図形を描画する際に必ず指定するのが座標なのですが、processingの場合は座標系における軸の取り方がいつもとは違います。 数学における座標系は、上に行くほどy座標が増えて、右に行くほどx座標が増えます。また、原点は左下です。 対して、processingにおける座標系は、下に行くほどy座標が増えて、右に行くほどx座標が増えます。また、原点は左上です。

f:id:gotutiyan:20200102190010p:plain
座標系の違い(左:数学 右:processing)
最初は戸惑うかもしれませんが、徐々に慣れていきましょう。

setup()とdraw()

ゲームを作るに欠かせないのは「パラパラ漫画」の機構です。ページを素早くめくるように、ゲームは高速に画面が更新されています。テトリスを想像してみてください。ブロックが落ちていくだけでも、ゲーム画面は非常に高速に更新されています。この更新の速さを「フレームレート」と呼んだりしますが、フレームレートが低いことは、いわゆる「カクつく」「処理が重い」と感じる原因です。パラパラ漫画をめくる速度が遅ければ、当然絵もカクつきますね。

さて、processingにはこの「高速に画面を更新する」仕組みがあります。それがsetup()draw()です。これらはプログラミングの中では非常に重要な「関数」というものですが、今は割愛します。 setup()は、ゲームが始まった瞬間に一度だけ処理が行われます。 draw()は、ゲームを遊んでいる間、裏で何回も何回もひたすら処理が行われます。draw()が何回も実行されて、画面がその度に更新されることによって、パラパラ漫画はめくられます.ブロックは落ちるし、キャラは動きます。

具体的には、以下のようなテンプレを元に作成することになります。

void setup(){
  //1回だけ実行される処理
}

void draw(){
  //何回も実行される処理
}

初期設定

今回ゲームを作るにあたって、初期設定をしましょう。 具体的には,実行画面の大きさです。ゲームをプレイする画面を小さくしたり、大きくしたりできます。 これはsize()によって指定することができます。サイズは一度設定すればそれ以降固定されるので、setup()に書きます。 サイズは何でも良いですが、本記事では500x500にすることにします。

void setup(){
  size(500,500);
}

void draw(){
  //何回も実行される処理
}

実行ボタンを押すと、ある程度の大きさのウィンドウが表示されます。

フィールドを作成する

マス目の一辺の長さの決定と変数

一筆書きを行うためのフィールドを作成します.本記事でのフィールドとは,マス目の集合だとしましょう.一筆書きのゲームは,必ずマス目が用意されていなければなりません.今回は5x5マスによる一筆書きを実現します.目標はこんな感じです.

f:id:gotutiyan:20200111101104p:plain

いま,フィールド全体を見たときに,縦や横に並ぶマス目の数は5個です.また,フィールドの縦や横の長さは,「初期設定」のところで500x500と決めたので,マス目一つあたりの一辺の長さは自然に決まります.つまり,500/5=100です.これを,プログラムでは変数を用いて書いてみます.

変数とは,何かしらの値を格納できるものです.変数1つにつき1つの値しか格納できませんが,一度格納したものを後で上書きしたり,四則演算を行ったりできます.さらに,processingでは,変数にはというものがあります.型は変数に格納できる値の種類を表すもので,int型は,その変数には整数だけ格納できることを表します.

さて,これらを駆使して,マス目の個数と,マス目の一辺の長さは以下のように書けます.

int number_of_squares = 5;  //一辺に並べるマス目の数
int side_length = 0; //マス目の一辺の長さ
void setup(){
    size(500,500);
    side_length = width / number_of_squares; // 500/5=100
}

void draw(){
    //何回も実行される処理
}

width, heightは,変数の中でもシステム変数と呼ばれるものです.システム変数は,processingが勝手に用意してくれている変数で,公式のエディタでは桃色で表示されます.
widthは実行画面の横の長さを,heightは実行画面の縦の長さを表します.本記事であれば,width=height=500です.

フィールドの作成とfor

ここまでで,マス目の一辺の長さが決まったので,あとはそれを良い感じに並べるだけです.とは言いつつ,説明することは多いので順番にやっていきましょう.

まずは四角形の描画方法です.
rect(左上の頂点x, 左上の頂点y, 横の長さ, 縦の長さ)で書けます.

rect(0,0,100,50); // ((x,y)=(0,0)を左上の点とし,横100,縦50の四角形)

というわけで,まずは左上のマス目を書いてみます.

int number_of_squares = 5;  //一辺に並べるマス目の数
int side_length = 0; //マス目の一辺の長さ
void setup(){
    size(500,500);
    side_length = width / number_of_squares; // 500/5=100
}

void draw(){
    rect(0, 0, side_length, side_length);  //左上のマス目

}

f:id:gotutiyan:20200111002013p:plain

1つだけマス目を書けました! どんどん増やして並べていきましょう..と言いたいところですが,5x5=25個のマス目をこのように手打ちするのは,骨が折れます.できるんですけどね.

今やろうとしていることは,マス目を並べることです.もっと言えば,等間隔で並べようとしています.このようなとき,for文を用いると便利です.for文の一般的な文法はこうです.

for(int 変数名 = 初期値; 変数名 < 上限値; 変数名 += 加算値){
    //処理を書く
}

for文の変数名には,慣習的にi, j, kあたりがよく使われます.これを使えば,繰り返しの処理が簡単に記述できます.今回のゲーム制作で言えば,マス目の左上の座標を指定するのに役立ってきます.

まずは以下のコードを見てみましょう.

int number_of_squares = 5;  //一辺に並べるマス目の数
int side_length = 0; //マス目の一辺の長さ
void setup(){
  size(500,500);
  side_length = width / number_of_squares; // 500/5=100
}

void draw(){
  // for文を使って,5つのマス目を書いた
  for(int i = 0; i < number_of_squares; i++){
    rect(i*side_length, 0, side_length, side_length);
  }
}

f:id:gotutiyan:20200111002324p:plain

for文を使うことで,たった3行で5つのマス目を並べることができました.並べる時には,隣同士のマス目はside_lengthだけ離せば,綺麗に敷き詰められることを利用しています.

冗長に書けば,以下のようになります.

// for文を使用
for(int i = 0; i < number_of_squares; i++){
  rect(i*side_length, 0, side_length, side_length);
}
ーーーーーーーーーーーーーーーーーーーーーーー
// for文を未使用
rect(0, 0, side_length, side_length);
rect(side_length, 0, side_length, side_length);
rect(2*side_length, 0, side_length, side_length);
rect(3*side_length, 0, side_length, side_length);
rect(4*side_length, 0, side_length, side_length);

0→1→2→3→4と増えていく変数iが,座標指定にうまくマッチしていることが分かります.

残りの領域も敷き詰めていきます.先ほどは,1つのマス目を横に5個並べました.これと同じ要領で,「横に5個並んだもの」を縦に5個並べることを考えます.こうすれば,5x5のマス目になるはずです.つまり,for文を2つ重ねることをします.

int number_of_squares = 5;  
int side_length = 0; 
void setup(){
  size(500,500);
  side_length = width / number_of_squares; 
}

void draw(){
  // 「横に5つ並べた」ものを縦に5つ並べた
  for(int j = 0; j < number_of_squares; j++){
    // 1つのマス目を横に5つ並べた
    for(int i = 0; i < number_of_squares; i++){
      // iとjに注意
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

f:id:gotutiyan:20200111101104p:plain

for文の中にfor文を入れることを,「入れ子にする」,もしくは「ネストする」などと言います.外側のfor文の変数名は j で,四角のy座標に用いています.内側のfor文の変数名は i で,四角のx座標に用いています.

以上で,フィールドの作成ができました.

なぞれるようにする

一筆書きのゲームは,マス目をなぞっていくことでプレイするものです.前章でマス目の作成は済んでいるので,次はこれをなぞれるような機構を考えます.このため,以下のような要件を満たすようにしましょう.

  • プレイヤーが最後になぞったマス目から,次に移動できるのは上下左右だけ
  • なぞったマス目は赤色になる
  • 始点はこちらで指定する

なぞったマスの覚え方と配列, if

なぞり方に関するルールは定めましたが,まずはあるマスについて,そのマスが既になぞられたかどうかを覚える必要があります.これの実現には,あるマスについて,1なら既になぞられた,0なら まだなぞられていない,となるような変数を作成します.この変数はマスの数だけ作成することが望ましいので,5x5=25個の変数を作りたいです.でも,手打ちでするのはめんどくさいですね.

// 頑張って書いた例
int visited1 = 0, visited2 = 0, ..... , visited25 = 0;

これをもっと簡単に書くために,配列を使うことにします.配列は,大量の変数を一度に作成できるものです.上記を配列で書き換えると以下のようになります.

int visited[] = new int[25];   //25個のint型変数を作成

このようにして作られた配列は,visited[0], visited[1], ...., visited[24]として,値を代入したり,値を参照したりできます.このときの0~24を添字(index)と呼びます.0から始まることに注意してください.

さらに,今回は実装上の理由から,この配列を2次元にして使います.一応1次元でもできるのですが,2次元の方が後々分かりやすくなります.5x5個のマスに対しての変数なので,配列も5x5の2次元に揃えます.

int visited[][] = new int[number_of_squares][number_of_squares];

では,これをプログラムに組み込みましょう.組み込むに当たって,新たにif文を導入します.if文は「もし〜なら」という機構を実現するものです.文法の一例は以下の通りです.

if(条件式) {
  // 条件式がTrueの時の処理
} else {
  // そうでない時の処理
}

条件式もいろいろ書けますが,この段階では==を紹介します.==は,その左側も右側も同じであれば,True,そうでなければFalseを返します.

if(5 == 5) {
  // 5と5は等しいので実行される
}

if(4 == 5){
  // 4と5は異なるので実行されない
}

次では,このif文を赤に塗るか塗らないかを決定することに使います.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares]; // 塗ったかどうかの0 or 1
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  // v[2][2] = 1; //試しに真ん中を赤くする用
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0);  // 赤に塗る
      else fill(255);  // 白に塗る
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

if文を用いれば,visited[ ][ ]が1か0かで,赤く塗るかを決められます.ここでfill()は図形を塗る色を決められる関数です.基本的にRGBなので,
fill(赤の濃さ, 緑の濃さ, 青の濃さ)
で指定します.それぞれの値は0(最小)~255(最大)です.ここでは赤を塗りたいので,fill(255, 0, 0);です.

ただ,visited[ ][ ]は初期値が全て0なので,全て白に塗られます.なので特に実行結果に変化はありません.なので,setup()中にコメントアウトされている行を削除すれば,中心のマスだけ赤くなるでしょう.

f:id:gotutiyan:20200111201227p:plain

これで,塗るかどうかを記憶するために配列を作って,if文によって塗る色を決めることができました.

なぞったマスを赤くする

一筆書きのゲームはマウスを使ってプレイします.つまり,マウスの座標から,どのマスを塗っているのかを判定する必要があります.もっと具体的にいえば,どのようなときにvisited[ ][ ]を1にするかについて詳しく書きます.ここではその方法の紹介と,実際に塗ったところが赤くなる様子まで見れるようにします.

まずは,マウスの座標を取得する方法です.これはmouseX, mouseYで取得できます.これはwidthなどと同じシステム変数です.

また,マウスをクリック&ドラッグしている間だけ,マス目を塗れるようにしましょう.これには,void mouseDragged()という関数を用います.これはマウスをドラッグしている間だけ実行されるものです.関数については中級的な内容ですが,とりあえずサンプルを見てもらえればと思います.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares]; 
void setup(){
  size(500,500);
  side_length = width / number_of_squares; 
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0);  
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

void mouseDragged(){
  // マウスをドラッグしている間だけ実行
}

この節ではmouseDraggedに処理を記述することになります.

さて,やっと本題です.マウス座標から,今塗っているマス目を特定するにはどうすれば良いでしょうか.今やりたいことは,マウスの座標を,なんとかvisitedの添え字である0~4の値に変換することです.
また,mouseX, mouseYは,画面内に収まるとは限りません.画面サイズは500と決めていても,マウスが実行画面の外に行けば,mouseX,Yは600にも700にもなります.

このような時,constrain()を使います.これは値の下限と上限を決めて,その範囲に収めてくれる関数で,constrain(値, 下限, 上限)として使います.今回はmouseX, mouseYに対して使います.

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);  // 0~499になるようにする
  int x = mx / side_length; //添え字に変換
  
  int my = constrain(mouseY, 0, height-1);  // 0~499になるようにする
  int y = my / side_length;  //添え字に変換
  visited[x][y] = 1;  //該当マスを赤く塗る
}

constraintsを使って,マウス座標を画面内に収まるようにします.width-1やheight-1のように1引いているのは,500の時だけ5になってしまい,添え字として使うには都合が悪いからです.499なら,499/100=4(小数切り捨て)で,大丈夫です.
こうして得た添え字を使って,visited[x][y]に1を入れたら,マスを赤く塗る機構の完成です.

やり直し機構

ゲームはトライ&エラーを繰り返してクリアするものです.プレイヤーは,時に失敗することがあります.このような時,すぐにやり直せることができれば,プレイヤーのモチベーションは続きます.

この考えに基づき,このゲームではマウスを離したら(=クリックをやめたら),塗った形跡を全て消すことにします.マウスを離した時の処理は,mouseReleased()に記述します.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares]; 
void setup(){
  size(500,500);
  side_length = width / number_of_squares; 
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0);  
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / side_length; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / side_length; 
  visited[x][y] = 1;
}

void mouseReleased(){
  //マウスを離した時の処理 
}

次に,どうすれば塗った形跡を消せるかです.これは,visited[ ][ ]の全てに0を入れたら良いです.これはfor文を入れ子にして,以下のように書けます.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares]; 
void setup(){
  size(500,500);
  side_length = width / number_of_squares; 
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0);  
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / side_length; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / side_length; 
  visited[x][y] = 1;
}

void mouseReleased(){
  // 全ての変数を0に
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
}

これで,快適にトライ&エラーを繰り返すことができます.

始点を決める

一筆書きのゲームをプレイしたことがある人なら分かると思いますが,一般的には始点が決められています.複雑なステージであれば,始点を適切に指定しないとクリアできないステージとなってしまいます(別途解説しています).ここではその機構を作ります.

まずは,始点のための変数を作成します. 始点はsetup(),およびmouseDragged()で決めます.どちらも,プレイヤーがプレイを始める前には決めておくべきだからです.まずは適当に2を入れておきます.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int start_x=2, start_y=2; // 始点用変数
void setup(){
  .......

次に,ここで始点としたマス目は,実際に赤く塗らないと行けません.この処理も,setup(),およびmouseDragged()に書きます.いずれにせよ,ステージの初期化と同時に始点も塗るということです.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int start_x=3, start_y=2; // 始点用変数
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  visited[start_x][start_y] = 1; //始点を塗る
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0);  
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / side_length; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / side_length; 
  visited[x][y] = 1;
}

void mouseReleased(){
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
  visited[start_x][start_y] = 1; //始点を塗る
}

壁マスの作成

壁マスの作成

ここまでの一筆書きのゲームは,全てのマスを塗ることができます.しかし,これは流石に面白みに欠けていると思うので,「壁のマス」は必須だと思います.壁マスは,塗ることができないマスです.

壁マスの実現にも,やはりvisited[ ][ ]を用います.今,この値は0(塗ってない),もしくは1(塗った)でした.これに,-1(壁マス)であることを追加します.今,試しに一番左上のマスを壁マスにすることを考えます.壁マスの要件はこんな感じです.

  • 壁マスは,塗ることができない
  • 壁マスは,あらかじめ灰色に塗っておく

まずは灰色に塗るところをやります.壁マスにはvisited[ ][ ]に−1を入れたら良いです.これはsetup()に書きます.また,「やり直し機構」のところで,mouseReleased()でもフィールドの塗り直しを行なっているので,ここでも忘れずに壁マスの情報を入れます.

最後に,-1である時には灰色で塗ることを書きましょう.色を塗る情報はdraw()で書いているので,灰色で塗ることもdraw()で書きます.灰色はfill(150);で実現できます.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int start_x=2, start_y=2;
int wall_x = 0, wall_y = 0; // 壁マスの添え字
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;  // -1を入れる
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0); 
      else if(visited[i][j] == -1)fill(150);  // 灰色に塗る
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

........

void mouseReleased(){
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;  // -1を入れる
}

次に,灰色のマスは塗れないようにします.現状では,灰色のマスにマウスをかざせば,容赦無く赤に塗りつぶしてしまいます.
これが起こる原因は,マスの状態(壁,塗ってない,塗った)を見ずに,visited[ ][ ]に1を代入していることです.よって,塗る前にマスの状態を確認して,「塗っていない」マスに対してだけ塗るようにしましょう.

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / number_of_squares; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / number_of_squares; 
  if(visited[x][y] == 0)visited[x][y] = 1;  // 「塗ってない」マスなら塗る
}

壁マスを作ったことによる問題

さて,前節で壁マスを導入しました.しかし,これにより一つ問題が起こります.次の画像を見てください.

f:id:gotutiyan:20200112111953g:plain

このように,壁マスの色は変わりませんが,壁マスを貫通して一筆書きができてしまいます.この問題を解決します.

結論から言うと,「最後に塗ったマスから上下左右のマスしか塗らないようにする」という制約をつけることが解決策になります.この制約の元では,壁マスから上下左右には塗ることができません(壁マスは塗れないマスなので).不思議かもしれませんが,これだけで問題は解決できます.

さらにもう一点だけです.「最後に塗ったマス」というのは,最初に塗るときには存在しません.よって,最初は,始点としていたマスを最後に塗ったマスとみなすことにします.

実装としては,最後に塗ったマスをprev_x, prev_yとして持っておけば良いです. また,(x1, y1)と(x2, y2)の2つのマスがあると仮定したとき,それらが上下左右に位置する関係かどうかを判定する方法は,以下のように書けます.

abs(x1 - x2) + abs(y1 - y2) == 1

ここで,abs( )は絶対値を返す関数です.マンハッタン距離が1であれば良い,と聞いてわかる人はそれで十分です.わからない人は,とにかく「x座標,もしくはy座標が1だけ違えばいい」と解釈すれば,分かりやすいかなと思います.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int wall_x = 0, wall_y = 0; 
int start_x=2, start_y=2;
int prev_x, prev_y; // 最後に塗ったマスの添え字
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  // 最初は,始点を最後に塗ったマスとしておく
  prev_x = start_x;
  prev_y = start_y;
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0); 
      else if(visited[i][j] == -1)fill(150);
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / number_of_squares; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / number_of_squares; 
  // 最後に塗ったマスの上下左右であれば塗るという条件を追加
  if(visited[x][y] ==0
     && (abs(prev_x - x) + abs(prev_y - y) == 1)){
       visited[x][y] = 1; //塗る
       prev_x = x;  // 最後に塗ったマスを更新(x)
       prev_y = y;  // 最後に塗ったマスを更新(y)
     }
}

void mouseReleased(){
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  prev_x = start_x; // 忘れず初期化
  prev_y = start_y; // 忘れず初期化
}

以上で,壁マスの設置が完了し,それによって起こる問題も解決できました.

クリア判定とテキスト表示

ここまで,フィールドを塗っていくことができるようになりました.壁マスも自由に設置できるようになったので,ステージ作りもできるようになりました.残すはクリアしたかどうかの判定のみです.ここでは,最小限という名目なので,"Clear!"という文字を画面に表示することにしましょう.

結論から言えば,visited[ ][ ]の全部の値が-1か1になっていればクリアです.
解釈として,「(壁マスを除いて)全てのマスが塗られた」,もしくは,「塗られていないマスがない」と考えれば,visited[ ][ ]が-1と1だけになれば良いというのは納得がいくでしょう.

まずは実装を見てみましょう.クリア判定は常に監視するべきもので,かつマウスの状態には関係ない処理なので,draw( )で書くことにします.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int wall_x = 0, wall_y = 0; 
int start_x=2, start_y=2;
int prev_x, prev_y;
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  prev_x = start_x;
  prev_y = start_y;
  // テキストのサイズを大きめに
  textSize(50);
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0); 
      else if(visited[i][j] == -1)fill(150);
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
  // クリア判定
  boolean is_clear = true;
  for(int i = 0; i < number_of_squares; i++){
    for(int j = 0; j < number_of_squares; j++){
      if(visited[i][j] == 0) is_clear = false;
    }
  }
  // クリア条件を満たしていれば,テキスト表示
  fill(0);
  if(is_clear)text("Clear!", width/3, height/2);
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / number_of_squares; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / number_of_squares; 
  if(visited[x][y] ==0
     && (abs(prev_x - x) + abs(prev_y - y) == 1)){
       visited[x][y] = 1;
       prev_x = x;
       prev_y = y;
     }
}

void mouseReleased(){
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  prev_x = start_x;
  prev_y = start_y;
}

クリア判定の実装は少しアルゴリズミカルなところが強いです.
まずはじめに,クリア判定用のboolean型変数を作ります.boolean型は,trueかfalseのどちらかしか取らない型です.trueかfalseしかないので,if文の条件式に直接使用できます.今回はソースコードに習って,この変数のことをis_clearと呼ぶことにします.

クリアを判定するアルゴリズムはこうです.最初,仮にクリアできているものと考えます.よって,is_clearにtrueを入れます.
次に,visited[ ][ ]の値を全部見ていき,0であればまだ塗り残しがあってクリアしていないので,is_clearにfalseを入れます.
visited[ ][ ]を全て見終わった後,まだis_clearがtrueであれば,クリアしていることが分かります.

また,テキストの表示にはtext( )を使います.具体的には,文字列と座標をtext(文字列, x座標, y座標)で指定します.今回は"Clear!"を中央あたりに表示させたいので,text("Clear!", width/3, height/2)としています.
また,テキストには文字サイズがあります.これはtextSize( )で指定できます.今回は文字サイズを50くらいにすれば良い感じなので,textSize(50)とします.注意点として,textSize( )は少し処理が重いので,draw( )で何度も実行するとゲーム自体が重くなることがあります.よってこれはsetup( )に記述することにします.

重要な注意点

とても一般的な話をしますが,今回のゲームに限らず,一筆書きというのは,どうやってもできない場合があります.この辺りの話は数学の分野の1つであるグラフ理論に関わってきます.特にオイラーグラフ(厳密には半オイラーグラフ)に密接していますが,難しいので割愛します.

今回のゲームで言えば,クリアできるかできないかは,実は壁マスの位置と始点のマスの位置で決まります.どちらのマスも,ゲームの製作者が自由に決めることができてしまうので,作ったステージがちゃんとクリアできるものかは確認する必要があります.

おわりに

以上で,最小限の一筆書きゲームが完成しました!
たった60行のプログラムですが,それなりにプログラミングの知識を必要とし,簡単なアルゴリズムも学べる良い題材だったと思います.

最後にもう一度,コードの完成図を掲載します.

int number_of_squares = 5;  
int side_length = 0; 
int visited[][] = new int[number_of_squares][number_of_squares];
int wall_x = 0, wall_y = 0; 
int start_x=2, start_y=2;
int prev_x, prev_y;
void setup(){
  size(500,500);
  side_length = width / number_of_squares;
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  prev_x = start_x;
  prev_y = start_y;
  textSize(50);
}

void draw(){
  for(int j = 0; j < number_of_squares; j++){
    for(int i = 0; i < number_of_squares; i++){
      if(visited[i][j] == 1)fill(255,0,0); 
      else if(visited[i][j] == -1)fill(150);
      else fill(255);  
      rect(i*side_length, j*side_length, side_length, side_length);
    }
  }
  boolean is_clear = true;
  for(int i = 0; i < number_of_squares; i++){
    for(int j = 0; j < number_of_squares; j++){
      if(visited[i][j] == 0) is_clear = false;
    }
  }
  fill(0);
  if(is_clear)text("Clear!", width/3, height/2);
}

void mouseDragged(){
  int mx = constrain(mouseX, 0, width-1);
  int x = mx / side_length; 
  
  int my = constrain(mouseY, 0, height-1);
  int y = my / side_length; 
  if(visited[x][y] ==0
     && (abs(prev_x - x) + abs(prev_y - y) == 1)){
       visited[x][y] = 1;
       prev_x = x;
       prev_y = y;
     }
}

void mouseReleased(){
  for(int i = 0;i < number_of_squares; i++){
   for(int j = 0; j < number_of_squares; j++){
     visited[i][j] = 0;
   }
  }
  visited[start_x][start_y] = 1;
  visited[wall_x][wall_y] = -1;
  prev_x = start_x;
  prev_y = start_y;
}

これは宣伝なんですが,この記事と同じくらいの丁寧さでシューティングゲームを作るものも公開しているので,気になる方はご覧ください.

gotutiyan.hatenablog.com

ありがとうございました.

今後の展開

ここでは,このゲームをさらに拡張するためのアイデアを提案します.実装はしないのでコード例はありませんが,ご了承ください.

  • マスの数を変える
    マスの数は5とは言わず,もっと多くしても面白いでしょう.今回作成したプログラムは,number_of_squaresを変えるだけで,マス目の数を変えてプレイできるようになっています.

  • 一マス戻れるようにする
    あるマスを塗ったけど,なんか違う気がするので,一歩戻りたい時があると思います.そういう機能は今はないので,つければより遊びやすくなります.

  • 壁マスを複数つけられるようにする
    今は壁マスを1つしか作成できませんが,やはり複数作成できたほうが,ステージの難易度も調整できます.具体的には,wall_xとwall_yを配列にすれば良いです.

  • クリア時の演出をつける
    今は一瞬だけClear!の字がでるだけです.もっと派手な何かを考えると面白いです.

  • ステージ制にする
    ステージクリア制のゲームにすれば,もっと長く遊んでもらえます.各ステージについて壁マスの位置を設定するなどが考えられます.ステージに応じてマス目の数を変えれば,面白いかもしれません.

  • BGMをつける
    これは優先順位が低いかもしれませんが,やはりBGMの有無では雰囲気が変わってきます.

  • 塗ったマスに透明度を与える
    透明度を設定すれば,若干マス目が透けることになります.背景として何かの絵みたいなものを置いておけば,塗れば塗るほど後ろの絵が浮かび上がってくるような演出が可能になります.
    透明度の実装は,fill( )の4つ目に値を設定します.

他にもいろいろあると思うので,考えてみてください!
ありがとうございました.

他の最小限ゲーム制作シリーズ

本記事では一筆書きを扱いましたが,他のゲームについても同様の粒度で説明しています.合わせてご覧ください.

gotutiyan.hatenablog.com

gotutiyan.hatenablog.com