WEEK 06 · JAVASCRIPT
射擊遊戲:分段導讀與修改練習
看懂一小段,動手改一點,再回到遊戲找用途。
怎麼讀這份程式?
先找末尾的啟動點,再找事件,最後讀 update 與 draw。下方每段都對應你提供的 shooter.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.speed | 320 → 450 |
| 發射間隔 | fire 的 now - lastShot < 250 | 250 → 500,變慢 |
| 我方子彈速度 | fire 的 vy: -520 | -520 → -700,往上更快 |
| 子彈大小 | fire 的 r: 4 | 4 → 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) }讀懂即可的細節
- b.y = -100 先把命中的子彈移出畫面,下一次 update 的 filter 才移除它。
- enemies.forEach 裡的 return 只略過該架敵機;不會離開整個 update。
- 本例用布林值 alive 與字串 state 管理狀態,沒有使用 class 類別。
- 碰撞是簡化判斷,子彈半徑沒有算進命中範圍;調大圖形不代表命中範圍自動變大。
完整原始碼
下列內容對應本包原封不動的 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>