用純 JS 刻出童年回憶!工程師打造「火柴人奧義對決」網頁版
你還記得小時候在學校電腦課,偷偷在畫面上畫火柴人決鬥的感動嗎?近日,一位熱血的前端工程師將這份童年情懷,用現代網頁技術進行了史詩級的魔改!
這款名為「火柴人:奧義對決(爆炸特效版)」的網頁小遊戲,完全不需要下載任何封包,只要打開瀏覽器就能直接在網頁上觀看一場驚心動魄的對決。
🏹 精靈弓箭手 vs 🛡️ 皇家劍盾兵:硬核機制大公開!
點進網頁,映入眼簾的是充滿科技感的深色漸層背景與極簡精緻的 UI 看板。這款遊戲是由前端工程師利用 Tailwind CSS 搭配原生 HTML5 Canvas 打造。
看它只是火柴人,遊戲內部的代碼邏輯可是一點都不含糊:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>火柴人:奧義對決(爆炸特效版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background: radial-gradient(circle at center, #141428 0%, #07070f 100%);
color: #fff;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow-x: hidden;
}
canvas {
background-color: #04040a;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.9), inset 0 0 40px rgba(255, 255, 255, 0.03);
border-radius: 18px;
border: 2px solid #2d2d48;
max-width: 100%;
height: auto;
}
</style>
</head>
<body class="p-4 flex flex-col items-center">
<div class="text-center mb-3">
<h1 class="text-3xl font-extrabold tracking-wider text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-rose-400 to-indigo-500 drop-shadow-lg">
⚔️ 火柴人:奧義對決(爆炸特效版) 🛡️
</h1>
<p class="text-gray-400 text-xs sm:text-sm mt-1">精靈弓箭手 (P1) vs 皇家劍盾兵 (P2) · 獨立異步回能與震撼衝擊波模擬器</p>
</div>
<div class="w-[850px] max-w-full flex justify-between items-center mb-3 px-2">
<!-- P1 資訊看板 -->
<div class="flex flex-col w-[42%] bg-gray-900/80 p-3 rounded-2xl border border-gray-800 backdrop-blur-md shadow-lg">
<div class="flex justify-between items-center mb-1 text-xs sm:text-sm font-bold">
<span class="text-amber-400 flex items-center gap-1">🏹 精靈弓箭手 (P1)</span>
<span id="p1HpText" class="text-gray-300 font-mono">100/100</span>
</div>
<div class="w-full bg-gray-800 h-3 rounded-full overflow-hidden border border-gray-700 shadow-inner mb-2">
<div id="p1HpBar" class="h-full bg-gradient-to-r from-amber-600 to-amber-400 transition-all duration-200" style="width: 100%;"></div>
</div>
<div class="flex justify-between items-center text-[11px] text-cyan-400 font-semibold">
<span id="p1MpLabel">奧義能量 (MP)</span>
<span id="p1MpText" class="font-mono">0%</span>
</div>
<div class="w-full bg-gray-800 h-2 rounded-full overflow-hidden border border-gray-700 mt-1">
<div id="p1MpBar" class="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 transition-all duration-200" style="width: 0%;"></div>
</div>
</div>
<div class="text-sm font-extrabold text-gray-500 px-2 animate-pulse">VS</div>
<!-- P2 資訊看板 -->
<div class="flex flex-col w-[42%] bg-gray-900/80 p-3 rounded-2xl border border-gray-800 backdrop-blur-md shadow-lg">
<div class="flex justify-between items-center mb-1 text-xs sm:text-sm font-bold">
<span id="p2HpText" class="text-gray-300 font-mono">100/100</span>
<span class="text-rose-400 flex items-center gap-1">皇家劍盾兵 (P2) 🛡️</span>
</div>
<div class="w-full bg-gray-800 h-3 rounded-full overflow-hidden border border-gray-700 shadow-inner mb-2 flex justify-end">
<div id="p2HpBar" class="h-full bg-gradient-to-r from-rose-400 to-red-600 transition-all duration-200" style="width: 100%;"></div>
</div>
<div class="flex justify-between items-center text-[11px] text-rose-400 font-semibold">
<span id="p2MpText" class="font-mono">0%</span>
<span id="p2MpLabel">奧義能量 (MP)</span>
</div>
<div class="w-full bg-gray-800 h-2 rounded-full overflow-hidden border border-gray-700 mt-1 flex justify-end">
<div id="p2MpBar" class="h-full bg-gradient-to-r from-rose-400 to-red-500 transition-all duration-200" style="width: 0%;"></div>
</div>
</div>
</div>
<div class="relative shadow-2xl rounded-2xl overflow-hidden border border-gray-700/60">
<canvas id="gameCanvas" width="850" height="420"></canvas>
</div>
<div class="mt-4 flex flex-wrap gap-3 justify-center">
<button onclick="triggerForcedUlt()" class="px-5 py-2.5 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-purple-400/30">
✨ 雙方奧義大爆炸
</button>
<button onclick="togglePause()" id="pauseBtn" class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-gray-700">
⏸️ 暫停 / 繼續
</button>
<button onclick="manualReset()" class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-gray-700">
🔄 重新開始決鬥
</button>
</div>
<script>
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const GROUND_Y = 320;
let arrows = [];
let activeMegaSword = null;
let boomEffects = []; // 爆炸衝擊波特效清單
let particles = [];
let floatingTexts = [];
let gameOver = false;
let restartTimer = 0;
let isPaused = false;
let screenShake = 0;
function addFloatingText(text, x, y, color) {
floatingTexts.push({ text, x, y, alpha: 1.0, vy: -1.8, color });
}
function createParticles(x, y, color, count, speedMult = 1) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (1 + Math.random() * 6) * speedMult;
particles.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 2 + Math.random() * 5,
color: color,
alpha: 1,
decay: 0.02 + Math.random() * 0.03
});
}
}
function addBoomEffect(x, y, color) {
boomEffects.push({
x: x,
y: y,
radius: 10,
maxRadius: 110,
color: color,
alpha: 1.0
});
}
class Stickman {
constructor(x, color, name, role) {
this.x = x;
this.y = GROUND_Y;
this.color = color;
this.name = name;
this.role = role; // "archer" 或 "sword"
this.direction = 1;
this.hp = 100;
this.mp = 0;
this.state = "idle"; // idle, walking, attacking, hit, frozen
this.stateTimer = 0;
// 物理跳躍變數
this.vy = 0;
this.gravity = 0.5;
this.isGrounded = true;
// 各自獨立被動每幀回能速度(隨機非同步)
this.passiveMpGain = 0.12 + Math.random() * 0.18;
}
update(opponent) {
// 1. 各自獨立回能
if (this.hp > 0 && opponent.hp > 0 && !gameOver && !isPaused) {
this.mp = Math.min(100, this.mp + this.passiveMpGain);
}
// 2. 重力與跳躍物理
this.y += this.vy;
if (this.y < GROUND_Y) {
this.vy += this.gravity;
this.isGrounded = false;
} else {
this.y = GROUND_Y;
this.vy = 0;
this.isGrounded = true;
}
// 3. 定格或狀態計時
if (this.state === "frozen") {
if (--this.stateTimer <= 0) this.state = "idle";
return;
}
if (this.stateTimer > 0) {
if (--this.stateTimer <= 0) this.state = "idle";
}
// 永遠面向對手
this.direction = (opponent.x > this.x) ? 1 : -1;
if (opponent.hp > 0) {
let dist = Math.abs(this.x - opponent.x);
// 【跳過對方換位機制】極度貼身時原地高跳跨越
if (dist < 60 && this.isGrounded && opponent.isGrounded && this.state === "idle") {
this.vy = -12;
this.x += this.direction * 95;
this.state = "walking";
this.stateTimer = 15;
createParticles(this.x, this.y, '#ffffff', 8);
return;
}
// 【大招判定】滿 100 能量釋放
if (this.mp >= 100 && this.state === "idle") {
this.mp = 0;
this.state = "attacking";
this.stateTimer = 22;
screenShake = 16;
if (this.role === "archer") {
addFloatingText("奧義:爆裂穿心箭!", this.x, this.y - 110, '#38bdf8');
arrows.push({ x: this.x, y: this.y - 60, vx: this.direction * 13, owner: this, isUlt: true });
} else {
addFloatingText("奧義:天誅轟天劍陣!", this.x, this.y - 110, '#f43f5e');
activeMegaSword = { x: opponent.x, y: this.y - 260, targetY: opponent.y - 60, target: opponent };
}
return;
}
// 【一般 AI 行動】
if (this.role === "archer") {
if (dist < 180) {
this.x -= this.direction * 2.8;
}
if (this.state === "idle" && Math.random() < 0.04) {
this.state = "attacking";
this.stateTimer = 12;
arrows.push({ x: this.x, y: this.y - 60, vx: this.direction * 8, owner: this, isUlt: false });
}
} else {
if (this.state === "idle") {
if (dist > 55) {
this.x += this.direction * 2.5;
} else if (Math.random() < 0.15) {
this.state = "attacking";
this.stateTimer = 12;
if (dist < 60 && Math.abs(this.y - opponent.y) < 40 && opponent.state !== "frozen") {
screenShake = 5;
createParticles(opponent.x, opponent.y - 40, '#ffffff', 10);
opponent.hp = Math.max(0, opponent.hp - 10);
opponent.state = "hit";
opponent.stateTimer = 10;
if (!opponent.isGrounded) opponent.vy = 2;
addFloatingText("-10", opponent.x, opponent.y - 80, '#ef4444');
this.mp = Math.min(100, this.mp + 8);
}
}
}
}
}
if (this.x < 40) this.x = 40;
if (this.x > canvas.width - 40) this.x = canvas.width - 40;
}
draw() {
ctx.save();
ctx.strokeStyle = (this.state === "frozen") ? "#facc15" : (this.state === "hit" ? "#ef4444" : this.color);
ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(this.x, this.y - 80, 12, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(this.x, this.y - 68); ctx.lineTo(this.x, this.y - 40); ctx.stroke();
ctx.beginPath();
if (!this.isGrounded) {
ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x - 6, this.y - 20);
ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x + 6, this.y - 20);
} else {
ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x - 10, this.y);
ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x + 10, this.y);
}
ctx.stroke();
let sy = this.y - 60;
if (this.role === "archer") {
ctx.beginPath();
ctx.arc(this.x + this.direction * 15, sy, 20, -Math.PI/2, Math.PI/2, this.direction < 0);
ctx.stroke();
} else {
ctx.beginPath(); ctx.strokeStyle = "#ddd";
ctx.moveTo(this.x, sy); ctx.lineTo(this.x + this.direction * 35, this.state === "attacking" ? sy : sy - 20); ctx.stroke();
ctx.beginPath(); ctx.strokeStyle = "#888"; ctx.lineWidth = 6;
ctx.moveTo(this.x + this.direction * 15, sy - 15); ctx.lineTo(this.x + this.direction * 15, sy + 25); ctx.stroke();
}
ctx.restore();
}
}
let player1 = new Stickman(150, "#fbbf24", "精靈弓箭手", "archer");
let player2 = new Stickman(650, "#f43f5e", "皇家劍盾兵", "sword");
function updateEntities() {
for (let i = arrows.length - 1; i >= 0; i--) {
let a = arrows[i];
a.x += a.vx;
let opponent = (a.owner === player1) ? player2 : player1;
if (Math.abs(a.x - opponent.x) < 25 && a.y > opponent.y - 90 && a.y < opponent.y) {
screenShake = a.isUlt ? 14 : 5;
createParticles(opponent.x, opponent.y - 40, a.isUlt ? '#38bdf8' : '#ffffff', a.isUlt ? 25 : 8, a.isUlt ? 1.5 : 1);
if (a.isUlt) {
addBoomEffect(opponent.x, opponent.y - 40, '#38bdf8');
opponent.hp = Math.max(0, opponent.hp - 28);
opponent.state = "hit";
opponent.stateTimer = 25;
opponent.x += (a.vx > 0 ? 1 : -1) * 220;
opponent.mp = Math.min(100, opponent.mp + 12);
addFloatingText("-28 (爆裂穿心)", opponent.x, opponent.y - 85, '#38bdf8');
} else {
let isFacing = (opponent.role === "sword" && ((a.vx < 0 && opponent.direction === 1) || (a.vx > 0 && opponent.direction === -1)));
if (isFacing && opponent.state !== "frozen" && opponent.state !== "hit") {
opponent.x += (a.vx > 0 ? 1 : -1) * 15;
opponent.mp = Math.min(100, opponent.mp + 10);
addFloatingText("格擋", opponent.x, opponent.y - 85, '#facc15');
} else {
opponent.hp = Math.max(0, opponent.hp - 8);
opponent.state = "hit";
opponent.stateTimer = 10;
opponent.x += (a.vx > 0 ? 1 : -1) * 30;
a.owner.mp = Math.min(100, a.owner.mp + 6);
addFloatingText("-8", opponent.x, opponent.y - 85, '#ef4444');
}
}
arrows.splice(i, 1);
continue;
}
if (a.x < 0 || a.x > canvas.width) arrows.splice(i, 1);
}
if (activeMegaSword) {
activeMegaSword.y += 14;
if (activeMegaSword.y >= activeMegaSword.targetY) {
let target = activeMegaSword.target;
screenShake = 22;
createParticles(target.x, target.y - 30, '#f43f5e', 35, 2);
addBoomEffect(target.x, target.y - 30, '#f43f5e');
target.hp = Math.max(0, target.hp - 30);
target.state = "frozen";
target.stateTimer = 100;
target.vy = 0;
target.y = GROUND_Y;
target.mp = Math.min(100, target.mp + 15);
addFloatingText("-30 (天誅定格)", target.x, target.y - 85, '#f43f5e');
activeMegaSword = null;
}
}
}
function drawEntities() {
arrows.forEach(a => {
ctx.fillStyle = a.isUlt ? "#38bdf8" : "#fff";
ctx.fillRect(a.x, a.y, a.isUlt ? 18 : 8, a.isUlt ? 7 : 3);
});
if (activeMegaSword) {
ctx.fillStyle = "#f43f5e";
ctx.fillRect(activeMegaSword.x - 7, activeMegaSword.y - 45, 14, 55);
}
ctx.strokeStyle = '#2d2d48'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(0, GROUND_Y); ctx.lineTo(canvas.width, GROUND_Y); ctx.stroke();
}
function updateUI() {
document.getElementById('p1HpText').innerText = `${Math.ceil(player1.hp)}/100`;
document.getElementById('p1HpBar').style.width = `${Math.max(0, player1.hp)}%`;
document.getElementById('p1MpText').innerText = `${Math.floor(player1.mp)}%`;
document.getElementById('p1MpBar').style.width = `${player1.mp}%`;
document.getElementById('p1MpLabel').innerText = player1.mp >= 100 ? "⚡ 奧義就緒!" : "奧義能量 (MP)";
document.getElementById('p2HpText').innerText = `${Math.ceil(player2.hp)}/100`;
document.getElementById('p2HpBar').style.width = `${Math.max(0, player2.hp)}%`;
document.getElementById('p2MpText').innerText = `${Math.floor(player2.mp)}%`;
document.getElementById('p2MpBar').style.width = `${player2.mp}%`;
document.getElementById('p2MpLabel').innerText = player2.mp >= 100 ? "⚡ 奧義就緒!" : "奧義能量 (MP)";
}
function triggerForcedUlt() {
if (gameOver) return;
player1.mp = 100;
player2.mp = 100;
}
function togglePause() {
isPaused = !isPaused;
}
function manualReset() {
player1 = new Stickman(150, "#fbbf24", "精靈弓箭手", "archer");
player2 = new Stickman(650, "#f43f5e", "皇家劍盾兵", "sword");
arrows = [];
activeMegaSword = null;
boomEffects = [];
particles = [];
floatingTexts = [];
gameOver = false;
restartTimer = 0;
}
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
if (screenShake > 0) {
const dx = (Math.random() - 0.5) * screenShake;
const dy = (Math.random() - 0.5) * screenShake;
ctx.translate(dx, dy);
screenShake *= 0.82;
if (screenShake < 0.4) screenShake = 0;
}
if (!isPaused && !gameOver) {
player1.update(player2);
player2.update(player1);
updateEntities();
if (player1.hp <= 0 || player2.hp <= 0) {
gameOver = true;
restartTimer = 180;
}
}
drawEntities();
player1.draw();
player2.draw();
// 爆炸衝擊波特效更新
for (let i = boomEffects.length - 1; i >= 0; i--) {
let b = boomEffects[i];
b.radius += 5;
b.alpha -= 0.04;
if (b.alpha <= 0 || b.radius >= b.maxRadius) {
boomEffects.splice(i, 1);
continue;
}
ctx.save();
ctx.strokeStyle = b.color;
ctx.lineWidth = 4 * b.alpha;
ctx.globalAlpha = b.alpha;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 粒子更新
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx; p.y += p.vy; p.alpha -= p.decay;
if (p.alpha <= 0) { particles.splice(i, 1); continue; }
ctx.fillStyle = p.color; ctx.globalAlpha = p.alpha;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1.0;
// 飄浮文字更新
for (let i = floatingTexts.length - 1; i >= 0; i--) {
let ft = floatingTexts[i];
ft.y += ft.vy; ft.alpha -= 0.022;
if (ft.alpha <= 0) { floatingTexts.splice(i, 1); continue; }
ctx.save();
ctx.font = "bold 18px Inter, sans-serif";
ctx.fillStyle = ft.color; ctx.globalAlpha = ft.alpha;
ctx.shadowBlur = 10; ctx.shadowColor = ft.color; ctx.textAlign = "center";
ctx.fillText(ft.text, ft.x, ft.y);
ctx.restore();
}
ctx.restore();
updateUI();
if (gameOver) {
ctx.fillStyle = "rgba(7, 7, 16, 0.88)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff";
ctx.font = "bold 26px Inter, sans-serif";
ctx.textAlign = "center";
const winner = player1.hp > 0 ? player1.name : player2.name;
ctx.fillText(`🏆 決鬥結束! ${winner} 獲得最終勝利!`, canvas.width / 2, canvas.height / 2 - 25);
restartTimer--;
const sec = Math.ceil(restartTimer / 60);
ctx.font = "15px Inter, sans-serif";
ctx.fillStyle = "#94a3b8";
ctx.fillText(`將在 ${Math.max(1, sec)} 秒後自動重新開始決鬥...`, canvas.width / 2, canvas.height / 2 + 20);
ctx.textAlign = "left";
if (restartTimer <= 0) {
manualReset();
}
}
requestAnimationFrame(gameLoop);
}
gameLoop();
</script>
</body>
</html>