WEEK 06 · JAVASCRIPT

射擊遊戲:分段導讀與修改練習

看懂一小段,動手改一點,再回到遊戲找用途。

怎麼讀這份程式?

先找末尾的啟動點,再找事件,最後讀 update 與 draw。下方每段都對應你提供的 shooter.html,遊戲原檔保持不變。

開啟遊戲 · 下載原始 HTML · 完整原始碼

1. 先認識遊戲中的資料

cannon 是砲台;enemies 是敵機;bullets 與 enemyBullets 分開存放雙方子彈;particles 是爆炸粒子。init 重新指定資料,相當於重新開始。

原始檔約第 47 行起。

展開這段原碼
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

let cannon, enemies, bullets, enemyBullets, particles, state, lastShot;
const keys = {};

function init() {
  cannon = { x: W / 2, y: H - 60, w: 50, h: 56, speed: 320 };
  enemies = [
    { x: W * 0.3, y: 60, w: 54, h: 50, vx: 60,  alive: true, nextShot: rand(0.8, 2) },
    { x: W * 0.7, y: 60, w: 54, h: 50, vx: -50, alive: true, nextShot: rand(1.2, 2.5) }
  ];
  bullets = [];        // 使用者子彈
  enemyBullets = [];   // 敵機子彈
  particles = [];      // 爆炸粒子
  state = 'playing';   // playing | win | lose
  lastShot = 0;
}
function rand(a, b) { return a + Math.random() * (b - a); }

2. 輸入與發射

鍵盤按下/放開時記錄 keys;滑鼠與觸控直接設定砲台 x。fire 先檢查遊戲狀態,再檢查距離上次發射是否已滿 250 毫秒,最後新增子彈。

原始檔約第 68 行起。

展開這段原碼
/* ---------- 輸入 ---------- */
addEventListener('keydown', e => {
  keys[e.key] = true;
  if (e.key === ' ') { e.preventDefault(); fire(); }
  if (e.key === 'r' || e.key === 'R') init();
});
addEventListener('keyup', e => keys[e.key] = false);

function canvasX(clientX) {
  const r = canvas.getBoundingClientRect();
  return (clientX - r.left) * (W / r.width);
}
canvas.addEventListener('mousemove', e => { cannon.x = canvasX(e.clientX); });
canvas.addEventListener('mousedown', fire);
canvas.addEventListener('touchstart', e => { cannon.x = canvasX(e.touches[0].clientX); fire(); e.preventDefault(); }, { passive: false });
canvas.addEventListener('touchmove',  e => { cannon.x = canvasX(e.touches[0].clientX); e.preventDefault(); }, { passive: false });
document.getElementById('restart').onclick = init;

function fire() {
  if (state !== 'playing') return;
  const now = performance.now();
  if (now - lastShot < 250) return;   // 發射間隔
  lastShot = now;
  bullets.push({ x: cannon.x, y: cannon.y - cannon.h / 2, r: 4, vy: -520 });
}

3. 更新與碰撞

先更新粒子,即使遊戲結束仍能顯示爆炸。遊戲進行中再更新砲台、敵機與子彈;filter 移除超出範圍的資料;碰撞後設定 alive 或 state。Math.min/max 把砲台位置限制在畫布內。

原始檔約第 94 行起。

展開這段原碼
/* ---------- 更新 ---------- */
function update(dt) {
  // 爆炸粒子(結束後仍要動)
  particles.forEach(p => { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; });
  particles = particles.filter(p => p.life > 0);

  if (state !== 'playing') return;

  // 砲台移動
  if (keys['ArrowLeft'])  cannon.x -= cannon.speed * dt;
  if (keys['ArrowRight']) cannon.x += cannon.speed * dt;
  cannon.x = Math.max(cannon.w / 2, Math.min(W - cannon.w / 2, cannon.x));

  // 敵機:左右移動 + 慢慢往下 + 不定時射擊
  enemies.forEach(en => {
    if (!en.alive) return;
    en.x += en.vx * dt;
    en.y += 14 * dt;                          // 由上往下慢慢靠近
    if (en.x < en.w / 2)     { en.x = en.w / 2;     en.vx = Math.abs(en.vx); }
    if (en.x > W - en.w / 2) { en.x = W - en.w / 2; en.vx = -Math.abs(en.vx); }
    if (Math.random() < 0.4 * dt) en.vx = -en.vx;   // 偶爾隨機轉向

    en.nextShot -= dt;
    if (en.nextShot <= 0) {
      enemyBullets.push({ x: en.x, y: en.y + en.h / 2, r: 5, vy: 230 });
      en.nextShot = rand(0.8, 2.8);           // 不定時
    }
    // 敵機飛到砲台高度 → 輸
    if (en.y + en.h / 2 >= cannon.y - cannon.h / 2) state = 'lose';
  });

  // 使用者子彈
  bullets.forEach(b => b.y += b.vy * dt);
  bullets = bullets.filter(b => b.y > -10);
  bullets.forEach(b => {
    enemies.forEach(en => {
      if (en.alive && Math.abs(b.x - en.x) < en.w / 2 && Math.abs(b.y - en.y) < en.h / 2) {
        en.alive = false;
        b.y = -100;                           // 讓子彈消失
        explode(en.x, en.y);
      }
    });
  });

  // 敵機子彈
  enemyBullets.forEach(b => b.y += b.vy * dt);
  enemyBullets = enemyBullets.filter(b => b.y < H + 10);
  enemyBullets.forEach(b => {
    if (Math.abs(b.x - cannon.x) < cannon.w / 2 && Math.abs(b.y - cannon.y) < cannon.h / 2) {
      state = 'lose';
      explode(cannon.x, cannon.y, '#ee2028');
    }
  });

  // 勝利判斷
  if (state === 'playing' && enemies.every(e => !e.alive)) state = 'win';
}

4. 爆炸粒子

每次建立 30 個粒子,給它們不同方向、速度與壽命。i % 3 輪流選三種顏色。沒有傳入 color 時,用 || 右邊的顏色。

原始檔約第 152 行起。

展開這段原碼
function explode(x, y, color) {
  for (let i = 0; i < 30; i++) {
    const a = Math.random() * Math.PI * 2, s = rand(40, 220);
    particles.push({
      x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
      life: rand(0.3, 0.8), max: 0.8,
      color: color || ['#ff9800', '#ffeb3b', '#ff5722'][i % 3]
    });
  }
}

5. 畫出目前畫面

drawEnemy 和 drawCannon 用基本圖形組合飛機與砲台。draw 清除上一張畫面,再畫敵機、砲台、子彈與粒子。globalAlpha 讓粒子逐漸透明;結束時顯示勝負文字。

原始檔約第 163 行起。

展開這段原碼
/* ---------- 繪圖 ---------- */
function drawEnemy(en) {
  ctx.save();
  ctx.translate(en.x, en.y);
  ctx.lineWidth = 3;
  ctx.strokeStyle = '#000';
  ctx.fillStyle = '#00a2e8';
  // 機翼(朝下的飛機)
  ctx.beginPath();
  ctx.moveTo(0, 18);
  ctx.lineTo(-27, -16);
  ctx.lineTo(-27, -22);
  ctx.lineTo(-8, -10);
  ctx.lineTo(8, -10);
  ctx.lineTo(27, -22);
  ctx.lineTo(27, -16);
  ctx.closePath();
  ctx.fill(); ctx.stroke();
  // 機身
  ctx.beginPath();
  ctx.ellipse(0, 0, 10, 24, 0, 0, Math.PI * 2);
  ctx.fill(); ctx.stroke();
  // 座艙
  ctx.fillStyle = '#d6f1ff';
  ctx.beginPath();
  ctx.ellipse(0, 6, 4, 7, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function drawCannon() {
  ctx.save();
  ctx.translate(cannon.x, cannon.y);
  ctx.fillStyle = '#ee2028';
  ctx.strokeStyle = '#7a0a10';
  ctx.lineWidth = 3;
  // 砲管
  ctx.fillRect(-5, -28, 10, 24);
  ctx.strokeRect(-5, -28, 10, 24);
  // 砲身
  ctx.beginPath();
  ctx.ellipse(0, 4, 24, 22, 0, 0, Math.PI * 2);
  ctx.fill(); ctx.stroke();
  // 高光
  ctx.fillStyle = 'rgba(255,255,255,.35)';
  ctx.beginPath();
  ctx.ellipse(-8, -2, 6, 9, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

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

  enemies.forEach(en => en.alive && drawEnemy(en));
  if (state !== 'lose') drawCannon();

  ctx.fillStyle = '#ee2028';
  bullets.forEach(b => { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, 7); ctx.fill(); });
  ctx.fillStyle = '#0077b6';
  enemyBullets.forEach(b => { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, 7); ctx.fill(); });

  particles.forEach(p => {
    ctx.globalAlpha = Math.max(0, p.life / p.max);
    ctx.fillStyle = p.color;
    ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, 7); ctx.fill();
  });
  ctx.globalAlpha = 1;

  if (state !== 'playing') {
    ctx.fillStyle = 'rgba(255,255,255,.75)';
    ctx.fillRect(0, H / 2 - 60, W, 120);
    ctx.textAlign = 'center';
    ctx.font = 'bold 44px "Microsoft JhengHei", sans-serif';
    ctx.fillStyle = state === 'win' ? '#1b9e3e' : '#d62020';
    ctx.fillText(state === 'win' ? '你贏了!' : '你輸了…', W / 2, H / 2 + 8);
    ctx.font = '18px "Microsoft JhengHei", sans-serif';
    ctx.fillStyle = '#333';
    ctx.fillText('按 R 或「重新開始」再玩一次', W / 2, H / 2 + 40);
  }
}

6. 啟動與主迴圈

原始碼雖然由上往下宣告函式,但函式本體要被呼叫才執行。末尾 init 建立資料,再由 requestAnimationFrame 啟動循環;玩家操作時另外觸發事件處理函式。

原始檔約第 245 行起。

展開這段原碼
/* ---------- 主迴圈 ---------- */
let last = performance.now();
function loop(now) {
  const dt = Math.min((now - last) / 1000, 0.05);
  last = now;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}
init();
requestAnimationFrame(loop);

常用修改位置

想改什麼找哪裡修改例子
砲台速度init 的 cannon.speed320 → 450
發射間隔fire 的 now - lastShot < 250250 → 500,變慢
我方子彈速度fire 的 vy: -520-520 → -700,往上更快
子彈大小fire 的 r: 44 → 7(繪圖變大,原碰撞仍用中心點)
敵機數量init 的 enemies 陣列再加一個敵機物件,項目間記得逗號
勝負文字draw 的 fillText修改引號內文字

動手修改

增加第三架敵機,位置 x = W * 0.5。

展開參考答案

在 enemies 陣列中加一項,原本最後一項後面補上逗號。

{ x: W * 0.5, y: 120, w: 54, h: 50, vx: 40, alive: true, nextShot: rand(1, 2) }

讀懂即可的細節

完整原始碼

下列內容對應本包原封不動的 shooter.html。可直接選取複製,或使用上方下載連結。

展開完整 HTML/CSS/JavaScript
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>簡單射擊遊戲</title>
<style>
  body {
    margin: 0;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    background: #1a1f2b;
    color: #dfe8ff;
    font-family: "Segoe UI", "Microsoft JhengHei", sans-serif;
    user-select: none;
  }
  canvas {
    background: #fff;
    border: 3px solid #4a5a7a;
    border-radius: 8px;
    cursor: none;
    touch-action: none;
    max-height: 80vh;
  }
  #hint { font-size: 14px; opacity: .8; }
  #restart {
    padding: 8px 22px;
    font-size: 16px;
    border: none;
    border-radius: 10px;
    background: #1e90ff;
    color: #fff;
    cursor: pointer;
  }
</style>
</head>
<body>
  <canvas id="game" width="360" height="640"></canvas>
  <div id="hint">← → 或滑鼠移動砲台;空白鍵 / 點擊 發射;R 重新開始</div>
  <button id="restart">重新開始</button>

<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

let cannon, enemies, bullets, enemyBullets, particles, state, lastShot;
const keys = {};

function init() {
  cannon = { x: W / 2, y: H - 60, w: 50, h: 56, speed: 320 };
  enemies = [
    { x: W * 0.3, y: 60, w: 54, h: 50, vx: 60,  alive: true, nextShot: rand(0.8, 2) },
    { x: W * 0.7, y: 60, w: 54, h: 50, vx: -50, alive: true, nextShot: rand(1.2, 2.5) }
  ];
  bullets = [];        // 使用者子彈
  enemyBullets = [];   // 敵機子彈
  particles = [];      // 爆炸粒子
  state = 'playing';   // playing | win | lose
  lastShot = 0;
}
function rand(a, b) { return a + Math.random() * (b - a); }

/* ---------- 輸入 ---------- */
addEventListener('keydown', e => {
  keys[e.key] = true;
  if (e.key === ' ') { e.preventDefault(); fire(); }
  if (e.key === 'r' || e.key === 'R') init();
});
addEventListener('keyup', e => keys[e.key] = false);

function canvasX(clientX) {
  const r = canvas.getBoundingClientRect();
  return (clientX - r.left) * (W / r.width);
}
canvas.addEventListener('mousemove', e => { cannon.x = canvasX(e.clientX); });
canvas.addEventListener('mousedown', fire);
canvas.addEventListener('touchstart', e => { cannon.x = canvasX(e.touches[0].clientX); fire(); e.preventDefault(); }, { passive: false });
canvas.addEventListener('touchmove',  e => { cannon.x = canvasX(e.touches[0].clientX); e.preventDefault(); }, { passive: false });
document.getElementById('restart').onclick = init;

function fire() {
  if (state !== 'playing') return;
  const now = performance.now();
  if (now - lastShot < 250) return;   // 發射間隔
  lastShot = now;
  bullets.push({ x: cannon.x, y: cannon.y - cannon.h / 2, r: 4, vy: -520 });
}

/* ---------- 更新 ---------- */
function update(dt) {
  // 爆炸粒子(結束後仍要動)
  particles.forEach(p => { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; });
  particles = particles.filter(p => p.life > 0);

  if (state !== 'playing') return;

  // 砲台移動
  if (keys['ArrowLeft'])  cannon.x -= cannon.speed * dt;
  if (keys['ArrowRight']) cannon.x += cannon.speed * dt;
  cannon.x = Math.max(cannon.w / 2, Math.min(W - cannon.w / 2, cannon.x));

  // 敵機:左右移動 + 慢慢往下 + 不定時射擊
  enemies.forEach(en => {
    if (!en.alive) return;
    en.x += en.vx * dt;
    en.y += 14 * dt;                          // 由上往下慢慢靠近
    if (en.x < en.w / 2)     { en.x = en.w / 2;     en.vx = Math.abs(en.vx); }
    if (en.x > W - en.w / 2) { en.x = W - en.w / 2; en.vx = -Math.abs(en.vx); }
    if (Math.random() < 0.4 * dt) en.vx = -en.vx;   // 偶爾隨機轉向

    en.nextShot -= dt;
    if (en.nextShot <= 0) {
      enemyBullets.push({ x: en.x, y: en.y + en.h / 2, r: 5, vy: 230 });
      en.nextShot = rand(0.8, 2.8);           // 不定時
    }
    // 敵機飛到砲台高度 → 輸
    if (en.y + en.h / 2 >= cannon.y - cannon.h / 2) state = 'lose';
  });

  // 使用者子彈
  bullets.forEach(b => b.y += b.vy * dt);
  bullets = bullets.filter(b => b.y > -10);
  bullets.forEach(b => {
    enemies.forEach(en => {
      if (en.alive && Math.abs(b.x - en.x) < en.w / 2 && Math.abs(b.y - en.y) < en.h / 2) {
        en.alive = false;
        b.y = -100;                           // 讓子彈消失
        explode(en.x, en.y);
      }
    });
  });

  // 敵機子彈
  enemyBullets.forEach(b => b.y += b.vy * dt);
  enemyBullets = enemyBullets.filter(b => b.y < H + 10);
  enemyBullets.forEach(b => {
    if (Math.abs(b.x - cannon.x) < cannon.w / 2 && Math.abs(b.y - cannon.y) < cannon.h / 2) {
      state = 'lose';
      explode(cannon.x, cannon.y, '#ee2028');
    }
  });

  // 勝利判斷
  if (state === 'playing' && enemies.every(e => !e.alive)) state = 'win';
}

function explode(x, y, color) {
  for (let i = 0; i < 30; i++) {
    const a = Math.random() * Math.PI * 2, s = rand(40, 220);
    particles.push({
      x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s,
      life: rand(0.3, 0.8), max: 0.8,
      color: color || ['#ff9800', '#ffeb3b', '#ff5722'][i % 3]
    });
  }
}

/* ---------- 繪圖 ---------- */
function drawEnemy(en) {
  ctx.save();
  ctx.translate(en.x, en.y);
  ctx.lineWidth = 3;
  ctx.strokeStyle = '#000';
  ctx.fillStyle = '#00a2e8';
  // 機翼(朝下的飛機)
  ctx.beginPath();
  ctx.moveTo(0, 18);
  ctx.lineTo(-27, -16);
  ctx.lineTo(-27, -22);
  ctx.lineTo(-8, -10);
  ctx.lineTo(8, -10);
  ctx.lineTo(27, -22);
  ctx.lineTo(27, -16);
  ctx.closePath();
  ctx.fill(); ctx.stroke();
  // 機身
  ctx.beginPath();
  ctx.ellipse(0, 0, 10, 24, 0, 0, Math.PI * 2);
  ctx.fill(); ctx.stroke();
  // 座艙
  ctx.fillStyle = '#d6f1ff';
  ctx.beginPath();
  ctx.ellipse(0, 6, 4, 7, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function drawCannon() {
  ctx.save();
  ctx.translate(cannon.x, cannon.y);
  ctx.fillStyle = '#ee2028';
  ctx.strokeStyle = '#7a0a10';
  ctx.lineWidth = 3;
  // 砲管
  ctx.fillRect(-5, -28, 10, 24);
  ctx.strokeRect(-5, -28, 10, 24);
  // 砲身
  ctx.beginPath();
  ctx.ellipse(0, 4, 24, 22, 0, 0, Math.PI * 2);
  ctx.fill(); ctx.stroke();
  // 高光
  ctx.fillStyle = 'rgba(255,255,255,.35)';
  ctx.beginPath();
  ctx.ellipse(-8, -2, 6, 9, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

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

  enemies.forEach(en => en.alive && drawEnemy(en));
  if (state !== 'lose') drawCannon();

  ctx.fillStyle = '#ee2028';
  bullets.forEach(b => { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, 7); ctx.fill(); });
  ctx.fillStyle = '#0077b6';
  enemyBullets.forEach(b => { ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, 7); ctx.fill(); });

  particles.forEach(p => {
    ctx.globalAlpha = Math.max(0, p.life / p.max);
    ctx.fillStyle = p.color;
    ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, 7); ctx.fill();
  });
  ctx.globalAlpha = 1;

  if (state !== 'playing') {
    ctx.fillStyle = 'rgba(255,255,255,.75)';
    ctx.fillRect(0, H / 2 - 60, W, 120);
    ctx.textAlign = 'center';
    ctx.font = 'bold 44px "Microsoft JhengHei", sans-serif';
    ctx.fillStyle = state === 'win' ? '#1b9e3e' : '#d62020';
    ctx.fillText(state === 'win' ? '你贏了!' : '你輸了…', W / 2, H / 2 + 8);
    ctx.font = '18px "Microsoft JhengHei", sans-serif';
    ctx.fillStyle = '#333';
    ctx.fillText('按 R 或「重新開始」再玩一次', W / 2, H / 2 + 40);
  }
}

/* ---------- 主迴圈 ---------- */
let last = performance.now();
function loop(now) {
  const dt = Math.min((now - last) / 1000, 0.05);
  last = now;
  update(dt);
  draw();
  requestAnimationFrame(loop);
}
init();
requestAnimationFrame(loop);
</script>
</body>
</html>