8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIと協同してゲームをつくってみた

Last updated at Posted at 2025-12-18

みなさまこんにちは~
マーケティング部のタカツカです。

突如参加が決まりまして、題材どうしようと思いながら執筆しています。

昨年のアドベントカレンダーではエンジニア以外のメンバーが【非エンジニアがGASでテトリス作ってみた】
という記事をアップしておりまして、めちゃめちゃハードルが上がっています。怖い…。

えー…そうなると私も何か作った方が良いのか…?ということで、前回がChatGPTなら今回はGeminiだ!ということで、Geminiと協力して1つアプリを作ってみることにしました。

…が、早々に諦めてChatGPTでの作成に舵を切っています。そちら踏まえてご覧ください!

何をつくるか

そもそも何を作りましょうかね…というところで考えたのですが、お恥ずかしながらあまりにもAIを使いこなせておらず…

やってみたいことをGeminiへ相談したところ「難しいです」と言われてしまったので、それならまずはテトリス作成を踏襲すべきでは??となりまして、そちらからチャレンジしてみることにしました。

テトリスつくる

まずはGeminiに相談。

gas_gemini1.png

前回の記事にあったプロンプトをそのまま流し込みました

このあとも説明が続くのですが、Geminiはこのプロンプトではコードの記述を教えてくれない…。なんならGASでやるのはちょっと特殊なんですけど…と引かれているまであります。

さっきからあんまり乗り気じゃないな?

仕方ないのでGeminiとは一旦距離を置いて、ChatGPTへ同様のプロンプトで依頼したら、一回目でコードを教えてくれました。

gas_chatgpt1.png

コード教えてくれるまでが早い…

gas_chatgpt2.png

そして優しい…すき…(;O;)

ChatGPTは乗り気だ!私は前向きに手伝ってくれる人がすき!
今回はChatGPTと組ませてもらうね。

さっそく教えてもらった通りにやってみました。

Code.gs を作ってサーバ側の処理を置く

スプレッドシートからApp Scriptを開いて、Code.gs を作ってサーバ側の処理を置く…

gas_chatgpt3.png

たぶんここだ!
ということでChatGPTに教えてもらった以下のコードを入れてみます。

Code.gs
function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('GAS Tetris');
}

できた!
gas_chatgpt4.png

index.html を作ってゲーム(HTML + JS + CSS)を置く

同じプロジェクトで新規ファイルを作り、index.html を作ってゲーム(HTML + JS + CSS)を置く…
こうですね?

gas_chatgpt5.png

ちなみに入力したコードはこちら。

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Tetris (GAS)</title>
  <style>
    body { font-family: Arial, sans-serif; display:flex; gap:20px; align-items:flex-start; padding:20px; }
    #game { border:1px solid #333; background:#000; }
    .panel { width:140px; }
    h2 { margin:0 0 8px 0; font-size:16px; }
    #next { width:100px; height:100px; border:1px solid #ccc; background:#111; }
    .info { color:#222; background:#f5f5f5; padding:8px; border-radius:6px; margin-bottom:8px; }
    button { padding:6px 10px; margin-top:6px; cursor:pointer; }
    .footer { margin-top:10px; font-size:12px; color:#666; }
  </style>
</head>
<body>
  <canvas id="game" width="200" height="400"></canvas>

  <div class="panel">
    <h2>Controls</h2>
    <div class="info">
      ← → : move<br>
      ↑ : rotate<br>
      ↓ : soft drop<br>
      Space : hard drop
    </div>

    <h2>Next</h2>
    <canvas id="next" width="100" height="100"></canvas>

    <h2>Score</h2>
    <div id="score" class="info">0</div>

    <button id="startBtn">Start / Restart</button>

    <div class="footer">Made with Google Apps Script (HTMLService)</div>
  </div>

<script>
(() => {
  // 設定
  const COLS = 10, ROWS = 20;
  const CELL = 20; // 1セルのピクセル
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');
  const nextCanvas = document.getElementById('next');
  const nctx = nextCanvas.getContext('2d');

  let board, current, nextPiece, score, gameOver, dropInterval, dropTimer;

  // ブロックの形状(4x4 ブロック表現)
  const SHAPES = {
    I: [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],
    J: [[1,0,0],[1,1,1],[0,0,0]],
    L: [[0,0,1],[1,1,1],[0,0,0]],
    O: [[1,1],[1,1]],
    S: [[0,1,1],[1,1,0],[0,0,0]],
    T: [[0,1,0],[1,1,1],[0,0,0]],
    Z: [[1,1,0],[0,1,1],[0,0,0]]
  };
  const COLORS = { I:'#00f0f0', J:'#0000f0', L:'#f0a000', O:'#f0f000', S:'#00f000', T:'#a000f0', Z:'#f00000' };
  const KEYS = { LEFT:37, UP:38, RIGHT:39, DOWN:40, SPACE:32 };

  function createBoard(){
    const b = [];
    for(let r=0;r<ROWS;r++){
      b.push(new Array(COLS).fill(0));
    }
    return b;
  }

  function randPiece(){
    const keys = Object.keys(SHAPES);
    const k = keys[Math.floor(Math.random()*keys.length)];
    return { shape: SHAPES[k].map(row=>row.slice()), type:k, x: Math.floor((COLS - (SHAPES[k][0].length))/2), y: 0 };
  }

  function rotate(matrix){
    // 時計回り回転
    const N = matrix.length;
    const res = Array.from({length:N},()=>Array(N).fill(0));
    for(let r=0;r<N;r++) for(let c=0;c<N;c++) res[c][N-1-r] = matrix[r][c];
    return res;
  }

  function collides(board, piece, offsetX=0, offsetY=0){
    const shape = piece.shape;
    for(let r=0;r<shape.length;r++){
      for(let c=0;c<shape[r].length;c++){
        if(shape[r][c]){
          const x = piece.x + c + offsetX;
          const y = piece.y + r + offsetY;
          if(x<0 || x>=COLS || y>=ROWS) return true;
          if(y>=0 && board[y][x]) return true;
        }
      }
    }
    return false;
  }

  function merge(board, piece){
    const shape = piece.shape;
    for(let r=0;r<shape.length;r++){
      for(let c=0;c<shape[r].length;c++){
        if(shape[r][c]){
          const x = piece.x + c;
          const y = piece.y + r;
          if(y>=0) board[y][x] = piece.type;
        }
      }
    }
  }

  function clearLines(){
    let lines = 0;
    outer: for(let r=ROWS-1;r>=0;r--){
      for(let c=0;c<COLS;c++){
        if(!board[r][c]) continue outer;
      }
      // この行は満杯
      lines++;
      board.splice(r,1);
      board.unshift(new Array(COLS).fill(0));
      r++; // 次は同じインデックスをチェック
    }
    if(lines>0){
      const points = [0,40,100,300,1200]; // 0,1,2,3,4 lines
      score += points[lines];
      document.getElementById('score').innerText = score;
    }
  }

  function hardDrop(){
    while(!collides(board, current, 0, 1)){
      current.y++;
    }
    tickLock();
  }

  function tickLock(){
    merge(board, current);
    clearLines();
    spawn();
    if(collides(board, current, 0, 0)){
      gameOver = true;
      stop();
      alert('Game Over\nScore: '+score);
    }
  }

  function spawn(){
    current = nextPiece;
    nextPiece = randPiece();
    current.x = Math.floor((COLS - current.shape[0].length)/2);
    current.y = -1; // 少し上から落とす
    drawNext();
  }

  function step(){
    if(gameOver) return;
    if(!collides(board, current, 0, 1)){
      current.y++;
    } else {
      tickLock();
    }
    draw();
  }

  function drawCell(x,y,fill){
    ctx.fillStyle = fill || '#222';
    ctx.fillRect(x*CELL, y*CELL, CELL-1, CELL-1);
  }

  function draw(){
    // 背景
    ctx.fillStyle = '#000';
    ctx.fillRect(0,0,canvas.width,canvas.height);

    // 固定ブロック
    for(let r=0;r<ROWS;r++){
      for(let c=0;c<COLS;c++){
        if(board[r][c]){
          drawCell(c,r, COLORS[board[r][c]] || '#888');
        } else {
          drawCell(c,r, '#111');
        }
      }
    }

    // 現在のピース
    const shape = current.shape;
    for(let r=0;r<shape.length;r++){
      for(let c=0;c<shape[r].length;c++){
        if(shape[r][c]){
          const x = current.x + c;
          const y = current.y + r;
          if(y>=0) drawCell(x,y, COLORS[current.type]);
        }
      }
    }
  }

  function drawNext(){
    nctx.fillStyle = '#111';
    nctx.fillRect(0,0,nextCanvas.width,nextCanvas.height);
    const size = 20;
    const shape = nextPiece.shape;
    const offsetX = 2;
    const offsetY = 1;
    for(let r=0;r<shape.length;r++){
      for(let c=0;c<shape[r].length;c++){
        if(shape[r][c]){
          nctx.fillStyle = COLORS[nextPiece.type];
          nctx.fillRect((c+offsetX)*size, (r+offsetY)*size, size-2, size-2);
        }
      }
    }
  }

  function start(){
    board = createBoard();
    score = 0;
    document.getElementById('score').innerText = score;
    gameOver = false;
    nextPiece = randPiece();
    spawn();
    draw();
    // 落下速度(ms)
    dropInterval = 600;
    if(dropTimer) clearInterval(dropTimer);
    dropTimer = setInterval(step, dropInterval);
  }

  function stop(){
    if(dropTimer) clearInterval(dropTimer);
    dropTimer = null;
  }

  // 入力
  document.addEventListener('keydown', (e)=>{
    if(gameOver) return;
    if(e.keyCode === KEYS.LEFT){
      if(!collides(board, current, -1, 0)){ current.x--; draw(); }
    } else if(e.keyCode === KEYS.RIGHT){
      if(!collides(board, current, 1, 0)){ current.x++; draw(); }
    } else if(e.keyCode === KEYS.DOWN){
      if(!collides(board, current, 0, 1)){ current.y++; draw(); }
    } else if(e.keyCode === KEYS.UP){
      // 回転(壁蹴りは簡単に)
      const rotated = rotatePad(current.shape);
      const oldShape = current.shape;
      current.shape = rotated;
      if(collides(board, current, 0, 0)){
        // try kick right/left
        if(!collides(board, current, 1, 0)){ current.x++; }
        else if(!collides(board, current, -1, 0)){ current.x--; }
        else current.shape = oldShape; // 戻す
      }
      draw();
    } else if(e.keyCode === KEYS.SPACE){
      e.preventDefault();
      hardDrop();
      draw();
    }
  });

  function rotatePad(shape){
    // 正方行列になるようパディングして回転する
    const N = Math.max(shape.length, shape[0].length);
    const mat = Array.from({length:N},()=>Array(N).fill(0));
    for(let r=0;r<shape.length;r++) for(let c=0;c<shape[r].length;c++) mat[r][c]=shape[r][c];
    // 回転
    const res = Array.from({length:N},()=>Array(N).fill(0));
    for(let r=0;r<N;r++) for(let c=0;c<N;c++) res[c][N-1-r] = mat[r][c];
    // トリム(下と右が0の列を削る)
    // トリム上と左も可能だがシンプルにそのまま返す
    return res;
  }

  // スタートボタン
  document.getElementById('startBtn').addEventListener('click', ()=>{
    start();
  });

  // 自動開始
  start();

})();
</script>
</body>
</html>

なんか前の記事より記述長いような…
ともあれ次!

デプロイしてウェブアプリとして開く

デプロイ…デプロイ…これかな。

gas_chatgpt6.png

ウェブアプリとしてデプロイしてみます。

gas_chatgpt7.png

できたかな…?開いてみます。
.
.
.
.
.
.
テトリス.png
できてるー!!!!!!
えっすごいわたしにもできてしまった…

Geminiでもやってみる

あのあと、Geminiとも対話を重ねまして、コードを教えてもらうまでに関係値を深めましたので、Gemini出力Ver.でもやってみました。

Code.gs
function doGet() {
  // index.htmlを表示させるためのおまじないです
  return HtmlService.createHtmlOutputFromFile('index');
}

おまじない…なんかかわいい…
GeminiはGeminiで優しいかもしれない。

そしてindex.htmlのコードを入力します。

index.html
<!DOCTYPE html>
<html>
<head>
  <style>
    body { background: #333; color: white; text-align: center; font-family: sans-serif; }
    canvas { border: 2px solid #fff; background: #000; }
  </style>
</head>
<body>
  <h1>GASテトリス</h1>
  <canvas id="game" width="240" height="400"></canvas>
  <p>矢印キーで移動・回転</p>

  <script>
    const canvas = document.getElementById('game');
    const context = canvas.getContext('2d');
    context.scale(20, 20); // 1マスを20pxとして計算

    // ブロック(テトリミノ)の形
    const pieces = [
      [[1, 1, 1, 1]], // I
      [[1, 1], [1, 1]], // O
      [[0, 1, 0], [1, 1, 1]], // T
    ];

    const arena = Array.from({length: 20}, () => Array(12).fill(0)); // 20行12列の舞台

    const player = {
      pos: {x: 5, y: 0},
      matrix: pieces[Math.floor(Math.random() * pieces.length)],
    };

    // 描画関数
    function draw() {
      context.fillStyle = '#000';
      context.fillRect(0, 0, canvas.width, canvas.height);
      drawMatrix(arena, {x: 0, y: 0});
      drawMatrix(player.matrix, player.pos);
    }

    function drawMatrix(matrix, offset) {
      matrix.forEach((row, y) => {
        row.forEach((value, x) => {
          if (value !== 0) {
            context.fillStyle = 'red';
            context.fillRect(x + offset.x, y + offset.y, 1, 1);
          }
        });
      });
    }

    // 衝突判定
    function collide(arena, player) {
      const [m, o] = [player.matrix, player.pos];
      for (let y = 0; y < m.length; ++y) {
        for (let x = 0; x < m[y].length; ++x) {
          if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) {
            return true;
          }
        }
      }
      return false;
    }

    // ブロックを舞台に固定する
    function merge(arena, player) {
      player.matrix.forEach((row, y) => {
        row.forEach((value, x) => {
          if (value !== 0) {
            arena[y + player.pos.y][x + player.pos.x] = value;
          }
        });
      });
    }

    // 落下処理
    function playerDrop() {
      player.pos.y++;
      if (collide(arena, player)) {
        player.pos.y--;
        merge(arena, player);
        player.pos.y = 0;
        player.pos.x = 5;
        player.matrix = pieces[Math.floor(Math.random() * pieces.length)];
      }
      dropCounter = 0;
    }

    // ループ処理
    let dropCounter = 0;
    let lastTime = 0;
    function update(time = 0) {
      const deltaTime = time - lastTime;
      lastTime = time;
      dropCounter += deltaTime;
      if (dropCounter > 1000) { playerDrop(); } // 1秒ごとに落下
      draw();
      requestAnimationFrame(update);
    }

    // キー操作
    document.addEventListener('keydown', event => {
      if (event.keyCode === 37) { // 左
        player.pos.x--;
        if (collide(arena, player)) player.pos.x++;
      } else if (event.keyCode === 39) { // 右
        player.pos.x++;
        if (collide(arena, player)) player.pos.x--;
      } else if (event.keyCode === 40) { // 下
        playerDrop();
      }
    });

    update();
  </script>
</body>
</html>

さてどうなる…?

gas_gemini2.png

見栄え全然違う…
ChatGPTの方がスタイリッシュな感じでしたね。

おまけのような本題

冒頭でお伝えした通り本当はインベーダーゲームみたいなものを作ってみたかったんですが、Geminiには「GASでやるのは難しいです」と即答され、代案を出されてのテトリス作成だったのですが、上記の対応を経て、ChatGPTだったら前向きな回答してくれるのでは?となり、相談してみました。

gas_chatgpt8.png

普通に教えてくれました。できるんかい。
せっかく教えてもらったので、こちらも作ってみました。

Code.gs
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index');
}
index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>かわいいインベーダー</title>

  <style>
    body {
      background: #f0f8ff;
      text-align: center;
      font-family: "Arial Rounded MT Bold", sans-serif;
    }

    h1 {
      color: #333;
    }

    #info {
      font-size: 18px;
      margin-bottom: 10px;
    }

    canvas {
      background: #222;
      border-radius: 12px;
      display: block;
      margin: 0 auto;
    }

    #gameOver {
      font-size: 28px;
      color: #ff4d6d;
      margin-top: 15px;
      display: none;
    }
  </style>
</head>

<body>
  <h1>👾 ミニインベーダー 👾</h1>
  <div id="info">スコア:0 | ❤️❤️❤️</div>
  <canvas id="game" width="400" height="400"></canvas>
  <div id="gameOver">😵 GAME OVER</div>

  <script>
    const canvas = document.getElementById("game");
    const ctx = canvas.getContext("2d");
    const infoEl = document.getElementById("info");
    const gameOverEl = document.getElementById("gameOver");

    let score = 0;
    let life = 3;
    let gameOver = false;

    const player = { x: 180, y: 360, w: 40, h: 20, speed: 6 };

    let bullets = [];
    let enemyBullets = [];

    let enemies = [];
    let enemyDir = 1;

    function createEnemies() {
      enemies = [];
      for (let i = 0; i < 6; i++) {
        enemies.push({
          x: i * 55 + 40,
          y: 50,
          w: 32,
          h: 24
        });
      }
    }
    createEnemies();

    document.addEventListener("keydown", e => {
      if (gameOver) return;
      if (e.key === "ArrowLeft") player.x -= player.speed;
      if (e.key === "ArrowRight") player.x += player.speed;
      if (e.key === " ") {
        bullets.push({ x: player.x + 18, y: player.y, w: 4, h: 10 });
      }
    });

    function updateInfo() {
      infoEl.textContent =
        "スコア:" + score + "" + "❤️".repeat(life);
    }

    function update() {
      if (gameOver) return;

      // 自機の弾
      bullets.forEach(b => b.y -= 6);
      bullets = bullets.filter(b => b.y > 0);

      // 敵の弾
      enemyBullets.forEach(b => b.y += 4);
      enemyBullets = enemyBullets.filter(b => b.y < canvas.height);

      // 敵の移動
      enemies.forEach(e => e.x += enemyDir * 1.2);

      // 端で折り返し+ゆっくり降下
      if (enemies.some(e => e.x < 10 || e.x + e.w > canvas.width - 10)) {
        enemyDir *= -1;
        enemies.forEach(e => e.y += 8); // ← ここが減速ポイント
      }

      // 敵がランダムで弾を撃つ
      if (Math.random() < 0.02 && enemies.length > 0) {
        const shooter = enemies[Math.floor(Math.random() * enemies.length)];
        enemyBullets.push({
          x: shooter.x + shooter.w / 2 - 2,
          y: shooter.y + shooter.h,
          w: 4,
          h: 10
        });
      }

      // 敵弾 vs 自機
      enemyBullets.forEach((b, bi) => {
        if (
          b.x < player.x + player.w &&
          b.x + b.w > player.x &&
          b.y < player.y + player.h &&
          b.y + b.h > player.y
        ) {
          enemyBullets.splice(bi, 1);
          life--;
          updateInfo();
          if (life <= 0) {
            gameOver = true;
            gameOverEl.style.display = "block";
          }
        }
      });

      // 自機弾 vs 敵
      enemies.forEach((e, ei) => {
        bullets.forEach((b, bi) => {
          if (
            b.x < e.x + e.w &&
            b.x + b.w > e.x &&
            b.y < e.y + e.h &&
            b.y + b.h > e.y
          ) {
            enemies.splice(ei, 1);
            bullets.splice(bi, 1);
            score += 100;
            updateInfo();
          }
        });
      });

      // 敵が下まで来たらアウト
      if (enemies.some(e => e.y + e.h >= player.y)) {
        life--;
        updateInfo();
        createEnemies();
        if (life <= 0) {
          gameOver = true;
          gameOverEl.style.display = "block";
        }
      }
    }

    function drawEnemy(e) {
      // 本体
      ctx.fillStyle = "#ff355e";
      ctx.fillRect(e.x, e.y, e.w, e.h);

      // 目
      ctx.fillStyle = "#fff";
      ctx.fillRect(e.x + 6, e.y + 6, 5, 5);
      ctx.fillRect(e.x + 20, e.y + 6, 5, 5);

      ctx.fillStyle = "#000";
      ctx.fillRect(e.x + 7, e.y + 7, 3, 3);
      ctx.fillRect(e.x + 21, e.y + 7, 3, 3);
    }

    function draw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // 自機
      ctx.fillStyle = "#4CAF50";
      ctx.fillRect(player.x, player.y, player.w, player.h);

      // 自機の弾
      ctx.fillStyle = "#FFD700";
      bullets.forEach(b => ctx.fillRect(b.x, b.y, b.w, b.h));

      // 敵の弾
      ctx.fillStyle = "#00e5ff";
      enemyBullets.forEach(b => ctx.fillRect(b.x, b.y, b.w, b.h));

      // 敵
      enemies.forEach(drawEnemy);
    }

    function loop() {
      update();
      draw();
      requestAnimationFrame(loop);
    }

    updateInfo();
    loop();
  </script>
</body>
</html>

結果はこんな感じ!

gas_chatgpt9.png

かわいい~~~!!!

とこんな感じでアプリ作成を体験することができました。

コードは要素を検索してみると解説がヒットしたりしますので、エンジニアを目指している方はここから読み取って何を指定しているのか理解していくのも面白いと思います!

というかこの作業が楽しいと思えたら、たぶんエンジニアに向いていると思うので、ぜひチャレンジしてみてください。
当社では説明会も定期的にやってますので、お待ちしております~!

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?