Reskilling ❘ Programming

iphoneアプリを作ろう!クラッシュパズル編

#リスキリング #レジリエンス #ICT教育 #ITスキル #プログラミング

iphoneアプリを作ろう!
クラッシュパズル編

スマホでパズルゲームをしたことがある方も多いのではないでしょうか。

シンプルでありながら奥が深いパズルゲームですが、

ゲーム開発者はどのようにアプリを作っているのでしょうか?

今回は実際にクラッシュパズルアプリを作りながら学んでみましょう!

  • 2015年

    創業

  • 120名

    従業員数

  • 2.3M

    月間UU

  • 1,800戸

    管理戸数

今回は、Expo Snackを使って、8×8の簡易パズルゲームを少しずつ作る授業として構成します。

授業の基本ルールは、各ステップで次の流れにします。

  1. 説明を読む

  2. Expo Snackのコードを変更する

  3. そのステップの完成ソース全体をコピーして貼り付けてもOK

  4. プレビューを見る

  5. 「ここまで動いた」を確認して次へ進む

特に途中でコードを壊しても復帰できるように、各ステップに必ず、その時点で完成している App.js 全体を掲載します。

授業のゴール

最終的に、次のようなゲームを完成させます。

  • 8×8のパズル

  • 赤・青・黄色・緑・紫の5色

  • 同じ色が3個以上つながった場所をタップ

  • つながったブロックが消える

  • 上からブロックが落ちてくる

  • 空いた場所に新しいブロックが入る

  • 消した数に応じてスコアが増える

  • RESETボタンで最初からやり直せる

  • 消えるときだけ簡単なアニメーションを付ける

ステップ1:まず画面にタイトルを表示する

最初はゲームを作ろうとせず、「スマホ画面に文字を表示する」ところから始めます。

Expo Snackを開いて、App.js の内容を以下にします。

ここではまだパズルはありません。

import React from 'react';
import {
  View,
  Text,
  StyleSheet,
} from 'react-native';

export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text>Score : 0</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },
});

プレビューに Simple Puzzle と表示されたら成功です。

この段階では、View が画面の入れ物、Text が文字を表示するためのもの、と理解できれば十分です。

ステップ2:8×8のマスを作る

次は、パズルの土台を作ります。

まだ色は付けません。

8行×8列のマスを作るために、Array.from() と map() を使います。

import React from 'react';
import {
  View,
  Text,
  StyleSheet,
} from 'react-native';

const SIZE = 8;

export default function App() {
  const board = Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () => 0)
  );

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((cell, colIndex) => (
              <View
                key={`${rowIndex}-${colIndex}`}
                style={styles.cell}
              />
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },
});

プレビューに、8×8の空白のマスが表示されれば成功です。

ここでは、

8行 × 8列 = 64マス

という考え方を体験します。

ステップ3:マスにランダムな色を付ける

ここからパズルらしくしていきます。

5色の中からランダムに1色を選び、それぞれのマスに表示します。

import React from 'react';
import {
  View,
  Text,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const board = createBoard();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <View
                key={`${rowIndex}-${colIndex}`}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor: COLORS[color],
                    },
                  ]}
                />
              </View>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

プレビューを確認してください。

赤・青・黄色・緑・紫のブロックが8×8で表示されれば成功です。

この段階では、プレビューが更新されるたびにランダムな配置になることがあります。

ステップ4:タップできるようにする

次は、パズルを「操作できる」ようにします。

React Nativeの Pressable を使います。

この段階では、タップしてもまだブロックは消えません。

import React from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const board = createBoard();

  const handlePress = (row, col) => {
    console.log('タップ:', row, col);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor: COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

ここでは、

タップ
 ↓
handlePress()
 ↓
row と col が分かる

という仕組みを作っています。

ステップ5:タップした場所と同じ色を探す

ここからゲームの中心部分です。

タップしたブロックから、

  • 上

  • 下

  • 左

  • 右

に同じ色がつながっているかを調べます。

今回は初心者向けなので、斜め方向は調べません。

import React, { useState } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board] = useState(createBoard);

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const handlePress = (row, col) => {
    const group = findGroup(row, col);

    console.log('つながった数:', group.length);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor: COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

このステップでは、画面上の見た目はほぼ変わりません。

タップしたときに、内部で「何個つながっていたか」を調べています。

ステップ6:3個以上つながっていたら消す

いよいよパズルらしい動きです。

3個以上つながっていた場合、その場所を null にします。

そのため、今回は board を変更できるように useState を使います。

import React, { useState } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeGroup = (group) => {
    const nextBoard = board.map(row => [...row]);

    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    const group = findGroup(row, col);

    if (group.length < 3) {
      return;
    }

    removeGroup(group);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null
                          ? 'transparent'
                          : COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

ここで初めて、

「同じ色が3個以上つながっているところをタップすると消える」

というゲームルールが完成します。

ステップ7:上からブロックを落として、新しいブロックを入れる

ブロックを消しただけでは空白が残ります。

そこで、

消す
↓
下に詰める
↓
上を新しい色で埋める

という処理を追加します。

import React, { useState } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeAndFall = (group) => {
    const nextBoard = board.map(row => [...row]);

    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    for (let col = 0; col < SIZE; col++) {
      const colors = [];

      for (let row = SIZE - 1; row >= 0; row--) {
        if (nextBoard[row][col] !== null) {
          colors.push(nextBoard[row][col]);
        }
      }

      while (colors.length < SIZE) {
        colors.push(
          Math.floor(Math.random() * COLORS.length)
        );
      }

      for (let row = SIZE - 1; row >= 0; row--) {
        nextBoard[row][col] = colors[SIZE - 1 - row];
      }
    }

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    const group = findGroup(row, col);

    if (group.length < 3) {
      return;
    }

    removeAndFall(group);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>
      <Text style={styles.score}>Score : 0</Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null
                          ? 'transparent'
                          : COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

これで、タップするとブロックが消え、その列のブロックが下に詰まり、上に新しいブロックが入ります。

ここまででゲームとして最低限遊べる状態になっています。

ステップ8:スコアを追加する

次にゲームらしさを少しだけ追加します。

3個消したら30点、5個なら50点というように、

消した個数 × 10

をスコアにします。

import React, { useState } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);
  const [score, setScore] = useState(0);

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeAndFall = (group) => {
    const nextBoard = board.map(row => [...row]);

    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    for (let col = 0; col < SIZE; col++) {
      const colors = [];

      for (let row = SIZE - 1; row >= 0; row--) {
        if (nextBoard[row][col] !== null) {
          colors.push(nextBoard[row][col]);
        }
      }

      while (colors.length < SIZE) {
        colors.push(
          Math.floor(Math.random() * COLORS.length)
        );
      }

      for (let row = SIZE - 1; row >= 0; row--) {
        nextBoard[row][col] = colors[SIZE - 1 - row];
      }
    }

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    const group = findGroup(row, col);

    if (group.length < 3) {
      return;
    }

    setScore(score + group.length * 10);
    removeAndFall(group);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>

      <Text style={styles.score}>
        Score : {score}
      </Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null
                          ? 'transparent'
                          : COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },
});

ここまでで、

「消す → 落ちる → 補充する → 得点が増える」

という一連のゲーム処理が完成します。

ステップ9:RESETボタンを追加する

授業では何度もゲームを試すので、最初からやり直せるボタンを追加します。

import React, { useState } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);
  const [score, setScore] = useState(0);

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeAndFall = (group) => {
    const nextBoard = board.map(row => [...row]);

    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    for (let col = 0; col < SIZE; col++) {
      const colors = [];

      for (let row = SIZE - 1; row >= 0; row--) {
        if (nextBoard[row][col] !== null) {
          colors.push(nextBoard[row][col]);
        }
      }

      while (colors.length < SIZE) {
        colors.push(
          Math.floor(Math.random() * COLORS.length)
        );
      }

      for (let row = SIZE - 1; row >= 0; row--) {
        nextBoard[row][col] = colors[SIZE - 1 - row];
      }
    }

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    const group = findGroup(row, col);

    if (group.length < 3) {
      return;
    }

    setScore(score + group.length * 10);
    removeAndFall(group);
  };

  const resetGame = () => {
    setBoard(createBoard());
    setScore(0);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>

      <Text style={styles.score}>
        Score : {score}
      </Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null
                          ? 'transparent'
                          : COLORS[color],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>

      <Pressable
        style={styles.resetButton}
        onPress={resetGame}
      >
        <Text style={styles.resetText}>RESET</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },

  resetButton: {
    marginTop: 20,
    padding: 10,
    backgroundColor: 'yellow',
  },

  resetText: {
    color: 'black',
  },
});

ここまで来たら、かなりゲームらしくなっています。

ステップ10:消えるときの簡単なアニメーションを追加する

最後に、少しだけ動きを加えます。

今回は複雑なアニメーションは扱いません。

消えるブロックが小さくなるだけにします。

これなら授業で Animated の役割も説明しやすく、コード量も大きく増えません。

import React, { useState, useRef } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
  Animated,
} from 'react-native';

const SIZE = 8;
const COLORS = ['red', 'blue', 'yellow', 'green', 'purple'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);
  const [score, setScore] = useState(0);
  const [busy, setBusy] = useState(false);

  const scaleAnimations = useRef(
    Array.from({ length: SIZE }, () =>
      Array.from(
        { length: SIZE },
        () => new Animated.Value(1)
      )
    )
  ).current;

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeAndFall = (group) => {
    const nextBoard = board.map(row => [...row]);

    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    for (let col = 0; col < SIZE; col++) {
      const colors = [];

      for (let row = SIZE - 1; row >= 0; row--) {
        if (nextBoard[row][col] !== null) {
          colors.push(nextBoard[row][col]);
        }
      }

      while (colors.length < SIZE) {
        colors.push(
          Math.floor(Math.random() * COLORS.length)
        );
      }

      for (let row = SIZE - 1; row >= 0; row--) {
        nextBoard[row][col] = colors[SIZE - 1 - row];
      }
    }

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    if (busy || board[row][col] === null) {
      return;
    }

    const group = findGroup(row, col);

    if (group.length < 3) {
      return;
    }

    setBusy(true);

    const animations = group.map(([r, c]) => {
      return Animated.timing(
        scaleAnimations[r][c],
        {
          toValue: 0,
          duration: 150,
          useNativeDriver: true,
        }
      );
    });

    Animated.parallel(animations).start(() => {
      setScore(score + group.length * 10);

      removeAndFall(group);

      group.forEach(([r, c]) => {
        scaleAnimations[r][c].setValue(1);
      });

      setBusy(false);
    });
  };

  const resetGame = () => {
    setBoard(createBoard());
    setScore(0);

    for (let row = 0; row < SIZE; row++) {
      for (let col = 0; col < SIZE; col++) {
        scaleAnimations[row][col].setValue(1);
      }
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>

      <Text style={styles.score}>
        Score : {score}
      </Text>

      <View style={styles.boardFrame}>
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() =>
                  handlePress(rowIndex, colIndex)
                }
                style={styles.cell}
              >
                <Animated.View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null
                          ? 'transparent'
                          : COLORS[color],
                      transform: [
                        {
                          scale:
                            scaleAnimations[rowIndex][
                              colIndex
                            ],
                        },
                      ],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>

      <Pressable
        style={styles.resetButton}
        onPress={resetGame}
      >
        <Text style={styles.resetText}>RESET</Text>
      </Pressable>

      <Text style={styles.help}>
        同じ色が3個以上つながっている場所をタップ
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },

  title: {
    fontSize: 28,
  },

  score: {
    fontSize: 18,
  },

  boardFrame: {
    padding: 24,
  },

  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
  },

  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 4,
  },

  block: {
    flex: 1,
  },

  resetButton: {
    marginTop: 20,
    padding: 10,
    backgroundColor: 'yellow',
  },

  resetText: {
    color: 'black',
  },

  help: {
    color: 'red',
  },
});

完成!

これで、授業で目標としていたシンプルなパズルゲームが完成です。

完成したゲームの処理は、次のようになっています。

画面を表示
    ↓
8×8のブロックを作る
    ↓
色をランダムに設定
    ↓
ブロックをタップ
    ↓
同じ色がつながっている場所を探す
    ↓
3個以上?
 ┌──┴──┐
NO     YES
 ↓      ↓
何もしない  小さくする
          ↓
        消す
          ↓
       下に詰める
          ↓
       新しい色を追加
          ↓
        スコア加算

応用ステップ11:アレンジしよう

文字のサイズカラー、背景色などカスタマイズしましょう!

import React, { useState, useEffect, useRef } from 'react';
import {
  View,
  Text,
  Pressable,
  StyleSheet,
  Animated,
} from 'react-native';

const SIZE = 8;
const COLORS = ['#F45B69', '#4D96FF', '#FFD93D', '#6BCB77', '#9B5DE5'];

const createBoard = () => {
  return Array.from({ length: SIZE }, () =>
    Array.from({ length: SIZE }, () =>
      Math.floor(Math.random() * COLORS.length)
    )
  );
};

export default function App() {
  const [board, setBoard] = useState(createBoard);
  const [score, setScore] = useState(0);
  const [busy, setBusy] = useState(false);

  const scaleAnimations = useRef(
    Array.from({ length: SIZE }, () =>
      Array.from({ length: SIZE }, () => new Animated.Value(1))
    )
  ).current;

  const findGroup = (row, col) => {
    const color = board[row][col];

    if (color === null) {
      return [];
    }

    const group = [];
    const queue = [[row, col]];
    const visited = new Set();

    while (queue.length > 0) {
      const [r, c] = queue.shift();
      const key = `${r}-${c}`;

      if (visited.has(key)) {
        continue;
      }

      visited.add(key);

      if (
        r < 0 ||
        r >= SIZE ||
        c < 0 ||
        c >= SIZE ||
        board[r][c] !== color
      ) {
        continue;
      }

      group.push([r, c]);

      queue.push([r - 1, c]);
      queue.push([r + 1, c]);
      queue.push([r, c - 1]);
      queue.push([r, c + 1]);
    }

    return group;
  };

  const removeAndFall = (group) => {
    const nextBoard = board.map(row => [...row]);

    // 消す
    group.forEach(([row, col]) => {
      nextBoard[row][col] = null;
    });

    // 上から落とす
    for (let col = 0; col < SIZE; col++) {
      const colors = [];

      for (let row = SIZE - 1; row >= 0; row--) {
        if (nextBoard[row][col] !== null) {
          colors.push(nextBoard[row][col]);
        }
      }

      // 空いた部分を新しい色で補充
      while (colors.length < SIZE) {
        colors.push(Math.floor(Math.random() * COLORS.length));
      }

      for (let row = SIZE - 1; row >= 0; row--) {
        nextBoard[row][col] = colors[SIZE - 1 - row];
      }
    }

    setBoard(nextBoard);
  };

  const handlePress = (row, col) => {
    if (busy || board[row][col] === null) {
      return;
    }

    const group = findGroup(row, col);

    // 3個未満なら何もしない
    if (group.length < 3) {
      return;
    }

    setBusy(true);

    // 簡単な消えるアニメーション
    const animations = group.map(([r, c]) => {
      return Animated.timing(scaleAnimations[r][c], {
        toValue: 0,
        duration: 150,
        useNativeDriver: true,
      });
    });

    Animated.parallel(animations).start(() => {
      setScore(score + group.length * 10);

      removeAndFall(group);

      // アニメーション値を元に戻す
      group.forEach(([r, c]) => {
        scaleAnimations[r][c].setValue(1);
      });

      setBusy(false);
    });
  };

  const resetGame = () => {
    setBoard(createBoard());
    setScore(0);

    for (let row = 0; row < SIZE; row++) {
      for (let col = 0; col < SIZE; col++) {
        scaleAnimations[row][col].setValue(1);
      }
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Simple Puzzle</Text>

      <Text style={styles.score}>Score : {score}</Text>

      {/* パズル外枠 */}
      <View style={styles.boardFrame}>
        {/* 外枠とパズルの間に余白を確保 */}
        <View style={styles.board}>
          {board.map((row, rowIndex) =>
            row.map((color, colIndex) => (
              <Pressable
                key={`${rowIndex}-${colIndex}`}
                onPress={() => handlePress(rowIndex, colIndex)}
                style={styles.cell}
              >
                <Animated.View
                  style={[
                    styles.block,
                    {
                      backgroundColor:
                        color === null ? 'transparent' : COLORS[color],
                      transform: [
                        {
                          scale:
                            scaleAnimations[rowIndex][colIndex],
                        },
                      ],
                    },
                  ]}
                />
              </Pressable>
            ))
          )}
        </View>
      </View>

      <Pressable style={styles.resetButton} onPress={resetGame}>
        <Text style={styles.resetText}>RESET</Text>
      </Pressable>

      <Text style={styles.help}>
        同じ色が3個以上つながっている場所をタップ
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#FFFFFF',
  },

  title: {
    fontSize: 28,
    fontWeight: 'bold',
    marginBottom: 8,
  },

  score: {
    fontSize: 18,
    marginBottom: 20,
  },

  // パズル全体の外枠
  boardFrame: {
    padding: 12,
    borderWidth: 2,
    borderColor: '#333333',
    borderRadius: 10,
    backgroundColor: '#EEEEEE',
  },

  // 実際のパズル部分
  board: {
    width: 320,
    height: 320,
    flexDirection: 'row',
    flexWrap: 'wrap',
    backgroundColor: '#CCCCCC',
    padding: 4,
  },

  // 1マス
  cell: {
    width: '12.5%',
    height: '12.5%',
    padding: 2,
  },

  // 色のブロック
  block: {
    flex: 1,
    borderRadius: 6,
  },

  resetButton: {
    marginTop: 24,
    paddingVertical: 10,
    paddingHorizontal: 28,
    borderRadius: 6,
    backgroundColor: '#333333',
  },

  resetText: {
    color: '#FFFFFF',
    fontSize: 16,
    fontWeight: 'bold',
  },

  help: {
    marginTop: 16,
    fontSize: 13,
    color: '#666666',
  },
});

・本講座の制作
CIT経営開発事務所

・監修
CIT経営開発事務所 代表
井上 隆寛(いのうえ・たかひろ)

事業コンサルタント
AI開発エンジニア
行政書士R6合格者未登録

大手システム開発会社にてフルスタックSE兼Webデザイナーとして従事。2021年にコンサルタントとして独立し、企業に対するITコンサルティング・ソリューション導入支援事業を開始。2023年にはイベント企画・運営事業を新たに展開、2024年には行政書士試験に合格。現在はAIコンサルティング、システム開発、エンターテイメントの3事業を柱に、企業の技術顧問や講師としてICT教育やプログラミング・マーケティング授業も手がける。

公開中のリスキリング体験授業・学習教材

リスキリングに役立つ学習教材や体験授業を公開しています。ご自身の学習にお役立てください。

  • 2015年

    創業

  • 120名

    従業員数

  • 2.3M

    月間UU

  • 1,800戸

    管理戸数