所有入門單元免費開放 · 課程內容持續更新中

和 Claude 一步一步做出「接便當」

把設計書拆成五個小步驟,一次只請 Claude 做一件事:托盤、掉落物、碰撞與分數、鬧鐘與生命、最高分。每一步都先玩過、確認沒問題,再往下走。

第 8.3 堂約 18 分鐘

還記得上一堂課那個來回跑的方塊嗎?這堂課我們要把它變成真正的遊戲。

這是整個單元最長的一堂,但別緊張。我們不會一口氣叫 Claude「做出整個遊戲」,而是把設計書拆成五個小步驟。每一步你只要做三件事:貼上提示詞、看 Claude 改了什麼、打開遊戲玩一下確認。

為什麼要這麼麻煩?因為一次做太多,萬一壞掉,你和 Claude 都很難找出是哪裡出問題。一次一小步,每一步都是可以玩的版本,這是專業開發者也在用的方法。

這堂課要做出的畫面

接便當的遊戲畫面示意:左上角是分數,右上角是三顆愛心,畫面中有便當、珍奶、雞排和鬧鐘正在往下掉,底部是紫色托盤

五個步驟做完,就是這個畫面。

開始之前,在 bento-game 資料夾啟動 Claude Code。第一句話先請它複習一下:

開工前

請先讀 DESIGN.md 和目前的 index.html、game.js。接下來我會一步一步請你加功能,每次只做我說的那一步,不要先做後面的功能。做完請用一兩句話告訴我改了哪裡、我要怎麼測試。

步驟一:可以控制的托盤

步驟一:托盤

把原本來回跑的方塊換成玩家控制的托盤:

  • 托盤是一個寬 100、高 18 的紫色長方形,放在畫面底部上方一點
  • 電腦:按左右方向鍵(或 A、D)移動,放開就停
  • 手機與滑鼠:手指或滑鼠按著畫布拖曳,托盤的中心跟著手指的 x 位置
  • 托盤不能跑出畫面左右邊緣
  • 注意畫布在手機上會被縮小,觸控位置要換算回 canvas 的座標

驗收:重新整理網頁,按左右鍵托盤會動、放開會停、推到邊緣會停住。用滑鼠按住畫布左右拖,托盤會跟著。

最後一點「觸控位置要換算」很重要。畫布在手機上可能被縮成一半大,手指點在螢幕上第 100 個像素,其實是畫布座標的第 200 個像素。沒有換算的話,托盤會跟手指對不上。這種細節 AI 不一定會主動想到,所以我們先寫進提示詞。

步驟二:從天上掉下來的食物

步驟二:掉落物

加入會從畫面上方掉下來的東西:

  • 每隔大約 1 秒,在畫面最上方隨機的 x 位置產生一個掉落物
  • 掉落物先用 emoji 畫:便當 🍱、珍奶 🧋、雞排先用 🍗 代替
  • 每個掉落物大小約 40 x 40,往下掉的速度每個稍微不同
  • 掉出畫面底部就把它移除,不要讓它一直留在記憶體裡

驗收:畫面上會一直有食物掉下來,而且不會越積越多(玩一分鐘以上,遊戲也不會變卡)。

步驟三:接住就得分

遊戲終於要有「互動」了。程式怎麼知道托盤接到了便當?答案出乎意料地簡單:把每個東西都當成一個方框,檢查兩個方框有沒有重疊。

左邊說明便當外面有一個看不見的碰撞框;中間是便當的框和托盤的框重疊,代表接到;右邊兩個框沒有重疊,代表還沒碰到;下方列出判斷重疊的四個條件

這種「方框碰撞」很常見,又快又好懂。

步驟三:碰撞與分數

加入接住的判定和分數:

  • 寫一個函式,用兩個方框有沒有重疊來判斷掉落物是不是碰到托盤
  • 接到便當 +10 分,接到珍奶或雞排 +5 分,接到的東西要消失
  • 沒接到、掉出畫面的東西不扣分
  • 在畫面左上角顯示「分數 0」,接到時數字會增加

驗收:故意去接便當,分數加 10;接珍奶,加 5;沒接到的分數不變。

步驟四:鬧鐘、生命與結束畫面

現在加入危險:鬧鐘。這一步會用到一個新觀念叫狀態。遊戲在任何時刻都處在某一個狀態裡:

狀態 畫面 玩家能做什麼
等待開始 標題「接便當」與最高分 按空白鍵或點一下開始
遊戲中 東西在掉、分數在跳 移動托盤
結束 「午休結束!」與這次得分 按空白鍵或點一下重來
步驟四:鬧鐘與生命

加入鬧鐘和生命:

  • 掉落物中大約四分之一是鬧鐘 ⏰,接到鬧鐘扣一條命
  • 玩家一開始有 3 條命,在右上角用愛心顯示
  • 命用完就結束這一局
  • 加入三個遊戲狀態:等待開始、遊戲中、結束
    • 等待開始:顯示大標題「接便當」和「按空白鍵或點一下開始」
    • 結束:顯示「午休結束!」和這次得分,可以再按一次重新開始
    • 重新開始時,分數、生命、掉落物都要歸零
  • 遊戲越玩越快:掉落速度和產生頻率隨著時間慢慢增加,但產生間隔不要短於 0.35 秒

驗收:故意接三個鬧鐘,會出現結束畫面;按空白鍵能重新開始,分數歸零、愛心回到三顆。玩個一分鐘,感覺得出來東西掉得比較快。

步驟五:記住最高分

步驟五:最高分

用 localStorage 記住最高分:

  • 每局結束時,如果這次分數比最高分高,就更新並存起來
  • 開始畫面和結束畫面都顯示「最高分」
  • 讀取和儲存 localStorage 時要用 try catch 包起來,就算瀏覽器不允許儲存,遊戲也要能正常玩

localStorage 是瀏覽器提供的小儲物櫃,可以把資料存在這台電腦的這個瀏覽器裡。關掉網頁、明天再打開,最高分還在。不過它只存在你自己的瀏覽器,朋友的手機上會有他自己的最高分。

驗收:玩一局,記下分數,重新整理網頁,開始畫面上的最高分還在。

遇到 bug 時怎麼跟 Claude 說

一路上你很可能遇到「怪怪的」狀況:托盤穿過便當卻沒得分、重來之後愛心還是零顆……這很正常,連專業工程師每天都在處理。關鍵是描述清楚:

回報 bug 的好範本

我做了:連續接到三個鬧鐘,出現結束畫面後按空白鍵重新開始。 我預期:愛心回到三顆,分數歸零。 實際上:分數有歸零,但右上角沒有愛心,接到鬧鐘也不會結束。 請先找出原因並解釋給我聽,再修改。

「先解釋再修改」這個習慣很值得養成。你會慢慢看懂自己的遊戲,而不是只能一直按允許。

完整參考版本:game.js

五個步驟都做完後,你的 game.js 功能上應該和下面這份差不多(index.html 不用改,和上一堂一樣)。如果你卡住了,可以把這份貼給 Claude,請它「比較我的版本和這份參考版本,告訴我差在哪裡」。

// 接便當:第 3 堂完成版
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;

// 會掉下來的東西
const FOODS = [
  { emoji: '🍱', points: 10 }, // 便當
  { emoji: '🧋', points: 5 },  // 珍奶
  { emoji: '🍗', points: 5 },  // 雞排(還沒有雞排 emoji,先請雞腿代打)
];
const ALARM = { emoji: '⏰', points: 0 };

const tray = { x: W / 2 - 50, y: H - 70, w: 100, h: 18, speed: 450 };
const keys = { left: false, right: false };
let pointerX = null; // 手指或滑鼠按著時的 x,沒按著就是 null

let items = [];
let score = 0;
let lives = 3;
let elapsed = 0;    // 這一局玩了幾秒
let spawnTimer = 0; // 倒數到下一個掉落物
let state = 'ready'; // ready 等待開始、playing 遊戲中、over 結束
let best = loadBest();

function loadBest() {
  try {
    return Number(localStorage.getItem('bento-best')) || 0;
  } catch (e) {
    return 0;
  }
}

function saveBest() {
  try {
    localStorage.setItem('bento-best', String(best));
  } catch (e) {
    // 有些瀏覽器的無痕模式不能存,沒關係,遊戲照玩
  }
}

function startGame() {
  items = [];
  score = 0;
  lives = 3;
  elapsed = 0;
  spawnTimer = 0.5;
  tray.x = W / 2 - tray.w / 2;
  state = 'playing';
}

function gameOver() {
  state = 'over';
  if (score > best) {
    best = score;
    saveBest();
  }
}

function spawnItem() {
  const isAlarm = Math.random() < 0.25;
  const kind = isAlarm ? ALARM : FOODS[Math.floor(Math.random() * FOODS.length)];
  items.push({
    x: Math.random() * (W - 40),
    y: -40,
    w: 40,
    h: 40,
    vy: 150 + elapsed * 4 + Math.random() * 60, // 玩越久掉越快
    emoji: kind.emoji,
    points: kind.points,
    isAlarm: isAlarm,
  });
}

// 兩個方框有沒有重疊
function overlaps(a, b) {
  return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}

function update(dt) {
  if (state !== 'playing') return;
  elapsed += dt;

  // 移動托盤
  if (keys.left) tray.x -= tray.speed * dt;
  if (keys.right) tray.x += tray.speed * dt;
  if (pointerX !== null) tray.x = pointerX - tray.w / 2;
  tray.x = Math.max(0, Math.min(W - tray.w, tray.x));

  // 時間到就產生新的掉落物
  spawnTimer -= dt;
  if (spawnTimer <= 0) {
    spawnItem();
    spawnTimer = Math.max(0.35, 1 - elapsed * 0.01);
  }

  // 移動掉落物、檢查有沒有接到(倒著跑,刪除才不會漏掉)
  for (let i = items.length - 1; i >= 0; i--) {
    const it = items[i];
    it.y += it.vy * dt;
    if (overlaps(it, tray)) {
      items.splice(i, 1);
      if (it.isAlarm) {
        lives -= 1;
        if (lives <= 0) gameOver();
      } else {
        score += it.points;
      }
    } else if (it.y > H) {
      items.splice(i, 1);
    }
  }
}

function draw() {
  ctx.clearRect(0, 0, W, H);

  // 地板
  ctx.fillStyle = '#f2a516';
  ctx.fillRect(0, H - 40, W, 40);

  // 掉落物
  ctx.font = '36px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  for (const it of items) {
    ctx.fillText(it.emoji, it.x + it.w / 2, it.y + it.h / 2);
  }

  // 托盤
  ctx.fillStyle = '#5b4ef0';
  ctx.fillRect(tray.x, tray.y, tray.w, tray.h);
  ctx.fillStyle = '#eeecff';
  ctx.fillRect(tray.x + 6, tray.y + 4, tray.w - 12, 4);

  // 分數與生命
  ctx.fillStyle = '#1b1d2e';
  ctx.font = 'bold 22px sans-serif';
  ctx.textAlign = 'left';
  ctx.fillText('分數 ' + score, 16, 28);
  ctx.textAlign = 'right';
  ctx.fillText('❤️'.repeat(lives), W - 16, 28);

  // 開始畫面與結束畫面
  if (state !== 'playing') {
    ctx.fillStyle = 'rgba(27, 29, 46, 0.65)';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#ffffff';
    ctx.textAlign = 'center';
    ctx.font = 'bold 40px sans-serif';
    ctx.fillText(state === 'ready' ? '接便當' : '午休結束!', W / 2, H / 2 - 70);
    ctx.font = '22px sans-serif';
    if (state === 'over') ctx.fillText('這次得分 ' + score, W / 2, H / 2 - 10);
    ctx.fillText('最高分 ' + best, W / 2, H / 2 + 26);
    ctx.fillText('按空白鍵或點一下開始', W / 2, H / 2 + 90);
  }
}

// 鍵盤
window.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowLeft' || e.key === ' ' || e.key === 'ArrowRight') e.preventDefault();
  if (e.key === 'ArrowLeft' || e.key === 'a') keys.left = true;
  if (e.key === 'ArrowRight' || e.key === 'd') keys.right = true;
  if ((e.key === ' ' || e.key === 'Enter') && state !== 'playing') startGame();
});
window.addEventListener('keyup', (e) => {
  if (e.key === 'ArrowLeft' || e.key === 'a') keys.left = false;
  if (e.key === 'ArrowRight' || e.key === 'd') keys.right = false;
});

// 觸控與滑鼠:畫面上的位置要換算成 canvas 裡的座標
function toCanvasX(e) {
  const rect = canvas.getBoundingClientRect();
  return (e.clientX - rect.left) * (W / rect.width);
}
canvas.addEventListener('pointerdown', (e) => {
  if (state !== 'playing') {
    startGame();
    return;
  }
  canvas.setPointerCapture(e.pointerId);
  pointerX = toCanvasX(e);
});
canvas.addEventListener('pointermove', (e) => {
  if (pointerX !== null) pointerX = toCanvasX(e);
});
canvas.addEventListener('pointerup', () => { pointerX = null; });
canvas.addEventListener('pointercancel', () => { pointerX = null; });

// 遊戲迴圈
let lastTime = 0;
function loop(time) {
  const dt = Math.min((time - lastTime) / 1000, 0.05);
  lastTime = time;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

大約兩百行,就是一款完整的遊戲。你不需要每一行都看懂,但試著找找看:哪一段是上一堂學的遊戲迴圈?哪一段是碰撞?哪裡決定了便當值 10 分?

行動練習
  1. 照五個步驟,和 Claude 一起做出接便當。每一步都要通過驗收才往下走。
  2. 玩到你的最高分超過 100 分。玩的時候記下一件「覺得不太對勁」的地方,例如「鬧鐘太多」「托盤太慢」。
  3. 不靠 Claude,自己在 game.js 裡找到便當的分數,改成 20 分,存檔試玩。
  4. 用「我做了、我預期、實際上」的格式,請 Claude 修正你在第 2 點發現的問題。下一堂課我們會專門處理「好不好玩」這件事。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。