讓遊戲更好玩:手感、動畫、音效與難度曲線
規則一樣,有沒有「手感」會讓遊戲好玩程度差很多。這堂課加上音效、粒子、震動和更講究的難度曲線,最後找一位朋友來試玩,看看真實玩家卡在哪裡。
上一堂課結束時,你已經有一款能從頭玩到尾的接便當。不過你可能也發現了:它「能玩」,但好像還少了點什麼。接到便當時,只有左上角的數字默默變了一下。
少的那個東西,遊戲設計師叫它手感(game feel),也有人叫它「果汁」(juice)。意思是:玩家每做一個動作,遊戲都要馬上給出清楚、爽快的回應。這堂課我們不改任何規則,只加「回應」,你會發現同一款遊戲變得好玩很多。
規則完全一樣,差別只在遊戲有沒有「回應」玩家。
和上一堂一樣,我們一次加一種效果,每加一種就試玩一次。下面每一段都附上參考程式碼,Claude 寫的不一樣沒關係,效果對了就好。
音效:不用任何音效檔的嗶嗶聲
瀏覽器內建一套 Web Audio,可以用程式直接「合成」聲音:指定頻率(音高)和長度,它就會發出嗶一聲。好處是不用找音效檔,也就沒有授權問題,很適合復古風的小遊戲。
請用 Web Audio API 幫遊戲加上簡單的合成音效,不要用任何音效檔:
- 接到食物:短短兩聲由低到高的「叮咚」
- 接到鬧鐘:一聲低沉的「嗡」
- 遊戲結束:三個音由高到低,聽起來有點可惜
- 音量要小一點,不要嚇到人
- 注意瀏覽器規定要等玩家點擊或按鍵後才能發出聲音,請在開始遊戲時才建立 AudioContext
參考程式碼:
let audioCtx = null;
// 一定要在玩家按鍵或點擊後呼叫,瀏覽器才允許發聲
function unlockAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
}
// 發出一個音:頻率(越大越高音)、秒數、音色、音量
function beep(freq, duration, type, volume) {
if (!audioCtx) return;
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type || 'square';
osc.frequency.setValueAtTime(freq, now);
gain.gain.setValueAtTime(volume || 0.12, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration); // 聲音慢慢變小
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + duration);
}
const sound = {
catch: () => { beep(660, 0.08); setTimeout(() => beep(990, 0.1), 60); },
hurt: () => beep(140, 0.35, 'sawtooth', 0.15),
over: () => {
beep(440, 0.2, 'triangle');
setTimeout(() => beep(330, 0.2, 'triangle'), 180);
setTimeout(() => beep(220, 0.45, 'triangle'), 360);
},
};
接著在 startGame 的第一行呼叫 unlockAudio(),在接到食物、接到鬧鐘、gameOver 裡分別呼叫 sound.catch()、sound.hurt()、sound.over()。
粒子與飄字:讓「接到了」看得見
粒子是一堆會飛散、慢慢消失的小圓點。接到便當時,從托盤噴出一圈彩色小點,再加上一個往上飄的「+10」,玩家一眼就知道自己做對了。
接到食物時加兩個視覺效果:
- 粒子:從接到的位置噴出約 12 個小圓點,往四周飛散、受重力往下掉,0.6 秒內淡出消失。顏色跟食物有關,例如便當用珊瑚色
- 飄字:在托盤上方出現「+10」這樣的分數,往上飄並淡出
接到鬧鐘時,飄字改成紅色的「午休要結束了!」。 遊戲結束後,還沒跑完的粒子要繼續跑完,不要突然消失。
參考程式碼的核心是兩個清單,和掉落物的寫法很像:產生、每一格更新、時間到就移除。
let particles = [];
let popups = [];
function burst(x, y, color) {
for (let i = 0; i < 12; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 60 + Math.random() * 160;
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 120, // 先往上噴一點
life: 0.6,
color: color,
});
}
}
// 在 update 的最前面呼叫,這樣結束畫面時特效也會繼續跑
function updateEffects(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.vy += 600 * dt; // 重力
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) particles.splice(i, 1);
}
for (let i = popups.length - 1; i >= 0; i--) {
const p = popups[i];
p.y -= 50 * dt;
p.life -= dt;
if (p.life <= 0) popups.splice(i, 1);
}
shake = Math.max(0, shake - dt); // 下一段的震動也在這裡倒數
}
在 draw 裡畫出來,越接近消失越透明:
for (const p of particles) {
ctx.globalAlpha = Math.max(0, p.life / 0.6);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
for (const p of popups) {
ctx.globalAlpha = Math.max(0, p.life / 0.8);
ctx.fillStyle = p.color;
ctx.fillText(p.text, p.x, p.y);
}
ctx.globalAlpha = 1; // 記得改回不透明,不然後面畫的東西都會變淡
接到食物時呼叫 burst(...),並把 { text: '+10', x: ..., y: ..., life: 0.8, color: '#5b4ef0' } 這樣的物件放進 popups。
畫面震動:接到鬧鐘要「痛」
接到鬧鐘時讓整個畫面抖一下,玩家會立刻感覺「糟糕」。做法是在畫東西之前,把整張畫布隨機挪動幾個像素:
let shake = 0; // 剩下幾秒要震動;接到鬧鐘時設成 0.3
// draw 的開頭:
ctx.clearRect(0, 0, W, H);
ctx.save();
if (shake > 0) {
const power = shake * 40; // 越接近結束抖得越小
ctx.translate((Math.random() - 0.5) * power, (Math.random() - 0.5) * power);
}
// ……畫地板、掉落物、托盤、粒子……
ctx.restore(); // 分數和結束畫面放在 restore 之後畫,就不會跟著抖
震動要短、要小。0.3 秒剛好,抖太久會讓人頭暈。這種「小到幾乎沒注意到,但拿掉就覺得少了什麼」的程度,就是手感的甜蜜點。
難度曲線:慢慢變難,然後封頂
上一堂的接便當是「玩越久掉越快」,而且沒有上限。玩久了你會發現:過了某個時間點,東西快到根本反應不過來,最後幾秒只剩運氣。這種輸法會讓玩家覺得不公平。
更好的做法是設計一條難度曲線:一開始很簡單,讓新手熟悉操作;接著穩定變難;到了某個程度就不再加速,讓高手比的是專注力,而不是運氣。
把難度集中在一個函式裡,之後要調整只改這裡。
請把遊戲的難度集中到一個 difficulty 函式,根據這局玩了幾秒,回傳三個數值:
- 掉落速度:從每秒 150 像素開始,90 秒時到 380,之後維持不變
- 產生間隔:從 1.0 秒開始,90 秒時縮短到 0.4 秒
- 鬧鐘比例:從 20% 開始,90 秒時到 35%
產生掉落物和決定下次產生時間的地方,都改成使用這個函式。
function difficulty() {
const t = Math.min(elapsed / 90, 1); // 0 到 1,90 秒後固定為 1
return {
fallSpeed: 150 + 230 * t,
spawnEvery: 1.0 - 0.6 * t,
alarmChance: 0.2 + 0.15 * t,
};
}
t 是「難度進度」,從 0 慢慢走到 1。三個數值都跟著 t 走,所以只要改 90 這個數字,就能讓整個遊戲變得更急或更從容。這些數字不是標準答案,是讓你試玩後微調的起點。
找一位朋友來試玩
做到這裡,你已經玩了自己的遊戲幾十次,早就知道哪裡會掉鬧鐘、托盤要怎麼移。這反而讓你看不見新玩家的困難。所以最後一步,是找一位沒玩過的朋友或家人來試玩。
試玩時請遵守三個原則:
- 不要事先解釋。 只說「這是我做的小遊戲,你玩玩看」,看他能不能自己搞懂怎麼玩。
- 閉上嘴巴,只看不教。 他卡住時你會很想說「往左啦!」,請忍住。他卡住的地方,正是你的遊戲沒有說清楚的地方。
- 把觀察寫下來。 他在哪裡皺眉?哪裡笑出來?玩完一局有沒有主動按「再來一次」?
玩完之後,可以問他兩個問題:「哪個時候最好玩?」「哪個時候最想放棄?」
這個修正的參考程式碼:
let restartLockUntil = 0;
function tryStart() {
if (state === 'playing' || performance.now() < restartLockUntil) return;
startGame();
}
// 在 gameOver 裡加上這一行:
restartLockUntil = performance.now() + 800;
// 然後把按空白鍵和點擊畫布時呼叫的 startGame() 都改成 tryStart()
把試玩紀錄整理好,就能直接交給 Claude:
我找了一位朋友試玩接便當,這是我的觀察紀錄: (貼上你的紀錄) 請幫我把這些問題依照「影響有多大」排序,每一個提出一個最小的修改方式。先列出來給我選,我決定要改哪些之後你再動手。
- 一次一種,加上音效、粒子與飄字、畫面震動,每加一種就玩一局。
- 請 Claude 把難度集中到
difficulty函式,然後試著把 90 秒改成 60 秒和 120 秒,各玩一局,選一個你最喜歡的。 - 找一位沒玩過的朋友試玩,遵守「不解釋、只看不教」,寫下至少三個觀察。
- 從觀察裡挑一個影響最大的問題,和 Claude 一起修好。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。