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

讓遊戲更好玩:手感、動畫、音效與難度曲線

規則一樣,有沒有「手感」會讓遊戲好玩程度差很多。這堂課加上音效、粒子、震動和更講究的難度曲線,最後找一位朋友來試玩,看看真實玩家卡在哪裡。

第 8.4 堂約 14 分鐘

上一堂課結束時,你已經有一款能從頭玩到尾的接便當。不過你可能也發現了:它「能玩」,但好像還少了點什麼。接到便當時,只有左上角的數字默默變了一下。

少的那個東西,遊戲設計師叫它手感(game feel),也有人叫它「果汁」(juice)。意思是:玩家每做一個動作,遊戲都要馬上給出清楚、爽快的回應。這堂課我們不改任何規則,只加「回應」,你會發現同一款遊戲變得好玩很多。

左邊是沒有手感的版本,接到便當只有分數改變;右邊是加上手感的版本,有叮咚音效、粒子噴發、飄起來的 +10,接到鬧鐘時畫面會震動

規則完全一樣,差別只在遊戲有沒有「回應」玩家。

和上一堂一樣,我們一次加一種效果,每加一種就試玩一次。下面每一段都附上參考程式碼,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」,玩家一眼就知道自己做對了。

加上粒子和飄字

接到食物時加兩個視覺效果:

  1. 粒子:從接到的位置噴出約 12 個小圓點,往四周飛散、受重力往下掉,0.6 秒內淡出消失。顏色跟食物有關,例如便當用珊瑚色
  2. 飄字:在托盤上方出現「+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 秒剛好,抖太久會讓人頭暈。這種「小到幾乎沒注意到,但拿掉就覺得少了什麼」的程度,就是手感的甜蜜點。

難度曲線:慢慢變難,然後封頂

上一堂的接便當是「玩越久掉越快」,而且沒有上限。玩久了你會發現:過了某個時間點,東西快到根本反應不過來,最後幾秒只剩運氣。這種輸法會讓玩家覺得不公平。

更好的做法是設計一條難度曲線:一開始很簡單,讓新手熟悉操作;接著穩定變難;到了某個程度就不再加速,讓高手比的是專注力,而不是運氣。

折線圖:虛線是第 3 堂的難度,掉落速度隨時間一直上升,超過 120 秒進入太快的區域;實線是第 4 堂的難度,前 90 秒穩定上升到每秒 380 像素後維持水平;右側列出掉落速度、產生間隔、鬧鐘比例三個難度旋鈕

把難度集中在一個函式裡,之後要調整只改這裡。

重新設計難度曲線

請把遊戲的難度集中到一個 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 這個數字,就能讓整個遊戲變得更急或更從容。這些數字不是標準答案,是讓你試玩後微調的起點。

找一位朋友來試玩

做到這裡,你已經玩了自己的遊戲幾十次,早就知道哪裡會掉鬧鐘、托盤要怎麼移。這反而讓你看不見新玩家的困難。所以最後一步,是找一位沒玩過的朋友或家人來試玩。

試玩時請遵守三個原則:

  1. 不要事先解釋。 只說「這是我做的小遊戲,你玩玩看」,看他能不能自己搞懂怎麼玩。
  2. 閉上嘴巴,只看不教。 他卡住時你會很想說「往左啦!」,請忍住。他卡住的地方,正是你的遊戲沒有說清楚的地方。
  3. 把觀察寫下來。 他在哪裡皺眉?哪裡笑出來?玩完一局有沒有主動按「再來一次」?

玩完之後,可以問他兩個問題:「哪個時候最好玩?」「哪個時候最想放棄?」

這個修正的參考程式碼:

let restartLockUntil = 0;

function tryStart() {
  if (state === 'playing' || performance.now() < restartLockUntil) return;
  startGame();
}

// 在 gameOver 裡加上這一行:
restartLockUntil = performance.now() + 800;

// 然後把按空白鍵和點擊畫布時呼叫的 startGame() 都改成 tryStart()

把試玩紀錄整理好,就能直接交給 Claude:

把試玩回饋變成修改

我找了一位朋友試玩接便當,這是我的觀察紀錄: (貼上你的紀錄) 請幫我把這些問題依照「影響有多大」排序,每一個提出一個最小的修改方式。先列出來給我選,我決定要改哪些之後你再動手。

行動練習
  1. 一次一種,加上音效、粒子與飄字、畫面震動,每加一種就玩一局。
  2. 請 Claude 把難度集中到 difficulty 函式,然後試著把 90 秒改成 60 秒和 120 秒,各玩一局,選一個你最喜歡的。
  3. 找一位沒玩過的朋友試玩,遵守「不解釋、只看不教」,寫下至少三個觀察。
  4. 從觀察裡挑一個影響最大的問題,和 Claude 一起修好。

讀完了嗎?

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

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

留言與討論

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

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