和 Claude 一步一步做出「接便當」
把設計書拆成五個小步驟,一次只請 Claude 做一件事:托盤、掉落物、碰撞與分數、鬧鐘與生命、最高分。每一步都先玩過、確認沒問題,再往下走。
還記得上一堂課那個來回跑的方塊嗎?這堂課我們要把它變成真正的遊戲。
這是整個單元最長的一堂,但別緊張。我們不會一口氣叫 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 說
一路上你很可能遇到「怪怪的」狀況:托盤穿過便當卻沒得分、重來之後愛心還是零顆……這很正常,連專業工程師每天都在處理。關鍵是描述清楚:
我做了:連續接到三個鬧鐘,出現結束畫面後按空白鍵重新開始。 我預期:愛心回到三顆,分數歸零。 實際上:分數有歸零,但右上角沒有愛心,接到鬧鐘也不會結束。 請先找出原因並解釋給我聽,再修改。
「先解釋再修改」這個習慣很值得養成。你會慢慢看懂自己的遊戲,而不是只能一直按允許。
完整參考版本: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 分?
- 照五個步驟,和 Claude 一起做出接便當。每一步都要通過驗收才往下走。
- 玩到你的最高分超過 100 分。玩的時候記下一件「覺得不太對勁」的地方,例如「鬧鐘太多」「托盤太慢」。
- 不靠 Claude,自己在
game.js裡找到便當的分數,改成 20 分,存檔試玩。 - 用「我做了、我預期、實際上」的格式,請 Claude 修正你在第 2 點發現的問題。下一堂課我們會專門處理「好不好玩」這件事。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。