Files
AICODE2026/3-lessons/AICODE-06/demo/demo-level1-bomb.html
Rocky 3384472d0d feat: 新增 AICODE-06 魔幻俄罗斯方块第6-7课教案及 demo
- AICODE06-06 魔幻俄罗斯方块(上):工程师思维启蒙,Level 0 需求文档 + 压力测试 + 结果溯源
- AICODE06-07 魔幻俄罗斯方块(下):增量需求文档 + 魔改升级 + 成果路演
- demo/demo-level0.html:完整基础俄罗斯方块(含虚影、暂停、升级)
- demo/demo-level1-bomb.html:炸弹方块示例(含粒子爆炸特效)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:37:58 +02:00

577 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>魔幻俄罗斯方块 · Level 1 示例:炸弹方块</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: Arial, sans-serif;
color: #fff;
}
.game-container { display: flex; gap: 20px; align-items: flex-start; }
canvas#board {
border: 2px solid #e94560;
box-shadow: 0 0 20px rgba(233,69,96,0.3);
display: block;
}
.side-panel { width: 130px; }
.panel-box {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 12px;
margin-bottom: 14px;
}
.panel-box h3 {
font-size: 11px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.panel-box p { font-size: 22px; font-weight: bold; }
.bomb-hint {
font-size: 11px;
color: #ffd700;
line-height: 1.7;
}
.controls { font-size: 11px; color: #888; line-height: 2; }
#overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
justify-content: center;
align-items: center;
}
#overlay.show { display: flex; }
.overlay-box {
background: #16213e;
border: 2px solid #e94560;
border-radius: 12px;
padding: 32px 40px;
text-align: center;
}
.overlay-box h2 { color: #e94560; font-size: 24px; margin-bottom: 12px; }
.overlay-box p { color: #aaa; margin-bottom: 8px; }
.overlay-box span { color: #fff; font-size: 28px; font-weight: bold; }
button {
padding: 10px 28px;
background: #e94560;
color: #fff;
border: none;
border-radius: 6px;
font-size: 15px;
cursor: pointer;
margin-top: 16px;
}
button:hover { background: #c73652; }
/* 爆炸动画层 */
#explosion-canvas {
position: fixed;
pointer-events: none;
top: 0; left: 0;
width: 100%; height: 100%;
}
</style>
</head>
<body>
<canvas id="explosion-canvas"></canvas>
<div class="game-container">
<canvas id="board" width="300" height="600"></canvas>
<div class="side-panel">
<div class="panel-box">
<h3>下一个</h3>
<canvas id="next-canvas" width="80" height="80"></canvas>
</div>
<div class="panel-box">
<h3>得分</h3>
<p id="score">0</p>
</div>
<div class="panel-box">
<h3>等级</h3>
<p id="level">1</p>
</div>
<div class="panel-box">
<h3>消行</h3>
<p id="lines">0</p>
</div>
<div class="panel-box bomb-hint">
<h3>💣 炸弹规则</h3>
出现概率20%<br>
落地爆炸:<br>
炸掉 3×3 范围<br>
爆炸后触发消行<br>
额外奖励 200 分
</div>
<div class="panel-box controls">
<h3>操作</h3>
← → 移动<br>
↑ 旋转<br>
↓ 加速<br>
空格 直落<br>
P 暂停
</div>
</div>
</div>
<div id="overlay">
<div class="overlay-box">
<h2>游戏结束</h2>
<p>最终得分</p>
<span id="final-score">0</span>
<br>
<button onclick="startGame()">再来一次</button>
</div>
</div>
<script>
// =====================
// 配置
// =====================
const CONFIG = {
COLS: 10,
ROWS: 20,
CELL: 30,
BASE_SPEED: 800,
SPEED_STEP: 50,
LINES_PER_LEVEL: 10,
SCORE_TABLE: [0, 100, 300, 500, 800],
// 炸弹方块配置
BOMB_CHANCE: 0.20, // 出现概率 20%
BOMB_RADIUS: 1, // 爆炸半径(炸 3×3 = 半径1
BOMB_BONUS: 200, // 爆炸奖励分
};
// =====================
// 方块定义
// =====================
const TETROMINOES = [
{ shape: [[1,1,1,1]], color: '#00f5ff' },
{ shape: [[1,1],[1,1]], color: '#ffd700' },
{ shape: [[0,1,0],[1,1,1]], color: '#bf5fff' },
{ shape: [[0,1,1],[1,1,0]], color: '#39ff14' },
{ shape: [[1,1,0],[0,1,1]], color: '#ff3131' },
{ shape: [[1,0,0],[1,1,1]], color: '#ff8c00' },
{ shape: [[0,0,1],[1,1,1]], color: '#0080ff' },
];
// 炸弹方块(特殊形状:单格)
const BOMB_PIECE = {
shape: [[1]],
color: '#ff4500',
isBomb: true,
};
// =====================
// 状态
// =====================
let board, current, next;
let score, level, lines;
let running, paused;
let lastTs, dropAcc, dropInterval;
let rafId;
let particles = []; // 爆炸粒子
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const nextCvs = document.getElementById('next-canvas');
const nextCtx = nextCvs.getContext('2d');
const expCvs = document.getElementById('explosion-canvas');
const expCtx = expCvs.getContext('2d');
// 让爆炸画布覆盖全屏
function resizeExpCanvas() {
expCvs.width = window.innerWidth;
expCvs.height = window.innerHeight;
}
resizeExpCanvas();
window.addEventListener('resize', resizeExpCanvas);
// =====================
// 启动游戏
// =====================
function startGame() {
document.getElementById('overlay').classList.remove('show');
board = Array.from({ length: CONFIG.ROWS }, () => Array(CONFIG.COLS).fill(0));
score = 0; level = 1; lines = 0;
running = true; paused = false;
dropInterval = CONFIG.BASE_SPEED;
dropAcc = 0; lastTs = 0;
particles = [];
updateHUD();
next = newPiece();
spawnPiece();
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
}
// =====================
// 生成方块20% 概率炸弹)
// =====================
function newPiece() {
if (Math.random() < CONFIG.BOMB_CHANCE) {
return { ...BOMB_PIECE, shape: [[1]], x: 0, y: 0 };
}
const t = TETROMINOES[Math.floor(Math.random() * TETROMINOES.length)];
return { shape: t.shape.map(r => [...r]), color: t.color, isBomb: false, x: 0, y: 0 };
}
function spawnPiece() {
current = next;
current.x = Math.floor(CONFIG.COLS / 2) - Math.floor(current.shape[0].length / 2);
current.y = 0;
next = newPiece();
drawNext();
if (hit(current, 0, 0)) endGame();
}
// =====================
// 碰撞检测
// =====================
function hit(piece, dx, dy) {
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue;
const nx = piece.x + c + dx;
const ny = piece.y + r + dy;
if (nx < 0 || nx >= CONFIG.COLS) return true;
if (ny >= CONFIG.ROWS) return true;
if (ny >= 0 && board[ny][nx]) return true;
}
}
return false;
}
// =====================
// 旋转
// =====================
function rotateCW(shape) {
const R = shape.length, C = shape[0].length;
const out = Array.from({ length: C }, () => Array(R).fill(0));
for (let r = 0; r < R; r++)
for (let c = 0; c < C; c++)
out[c][R-1-r] = shape[r][c];
return out;
}
function tryRotate() {
if (current.isBomb) return; // 炸弹不需要旋转
const rotated = rotateCW(current.shape);
const orig = current.shape;
current.shape = rotated;
if (hit(current, 0, 0)) {
if (!hit(current, 1, 0)) current.x++;
else if (!hit(current, -1, 0)) current.x--;
else current.shape = orig;
}
}
// =====================
// 锁定方块
// =====================
function lock() {
if (current.isBomb) {
// 炸弹落地:触发爆炸
const bx = current.x;
const by = current.y;
explode(bx, by);
} else {
for (let r = 0; r < current.shape.length; r++)
for (let c = 0; c < current.shape[r].length; c++)
if (current.shape[r][c] && current.y + r >= 0)
board[current.y + r][current.x + c] = current.color;
clearLines();
}
spawnPiece();
}
// =====================
// 炸弹爆炸逻辑
// =====================
function explode(cx, cy) {
// 获取爆炸中心在棋盘上的坐标
const R = CONFIG.BOMB_RADIUS;
// 计算爆炸中心像素坐标(用于粒子特效)
const S = CONFIG.CELL;
// 找到 board 画布在屏幕上的位置
const boardRect = canvas.getBoundingClientRect();
const pixelX = boardRect.left + cx * S + S / 2;
const pixelY = boardRect.top + cy * S + S / 2;
// 清除爆炸范围内的所有格子
for (let r = cy - R; r <= cy + R; r++) {
for (let c = cx - R; c <= cx + R; c++) {
if (r >= 0 && r < CONFIG.ROWS && c >= 0 && c < CONFIG.COLS) {
board[r][c] = 0;
}
}
}
// 爆炸奖励分
score += CONFIG.BOMB_BONUS * level;
// 爆炸后触发消行检查
clearLines();
// 产生粒子特效
spawnParticles(pixelX, pixelY);
updateHUD();
}
// =====================
// 消行
// =====================
function clearLines() {
let cleared = 0;
for (let r = CONFIG.ROWS - 1; r >= 0; r--) {
if (board[r].every(cell => cell !== 0)) {
board.splice(r, 1);
board.unshift(Array(CONFIG.COLS).fill(0));
cleared++;
r++;
}
}
if (!cleared) return;
score += CONFIG.SCORE_TABLE[Math.min(cleared, 4)] * level;
lines += cleared;
const newLevel = Math.floor(lines / CONFIG.LINES_PER_LEVEL) + 1;
if (newLevel > level) {
level = newLevel;
dropInterval = Math.max(100, CONFIG.BASE_SPEED - (level-1) * CONFIG.SPEED_STEP);
}
updateHUD();
}
// =====================
// 粒子系统(爆炸特效)
// =====================
function spawnParticles(px, py) {
for (let i = 0; i < 30; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 3 + Math.random() * 6;
particles.push({
x: px, y: py,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0,
color: `hsl(${Math.floor(Math.random()*60)}, 100%, 60%)`,
size: 3 + Math.random() * 4,
});
}
}
function updateParticles() {
expCtx.clearRect(0, 0, expCvs.width, expCvs.height);
particles = particles.filter(p => p.life > 0);
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.2; // 重力
p.life -= 0.03;
expCtx.globalAlpha = p.life;
expCtx.fillStyle = p.color;
expCtx.beginPath();
expCtx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
expCtx.fill();
}
expCtx.globalAlpha = 1;
}
// =====================
// 主循环
// =====================
function loop(ts) {
rafId = requestAnimationFrame(loop);
if (!running || paused) return;
const dt = ts - lastTs;
lastTs = ts;
dropAcc += dt;
if (dropAcc >= dropInterval) {
if (!hit(current, 0, 1)) current.y++;
else lock();
dropAcc = 0;
}
render();
updateParticles();
}
// =====================
// 键盘
// =====================
document.addEventListener('keydown', e => {
if (!running) return;
switch (e.key) {
case 'ArrowLeft': e.preventDefault(); if (!paused && !hit(current,-1,0)) current.x--; break;
case 'ArrowRight': e.preventDefault(); if (!paused && !hit(current, 1,0)) current.x++; break;
case 'ArrowDown':
e.preventDefault();
if (!paused) { if (!hit(current,0,1)) current.y++; else lock(); dropAcc = 0; }
break;
case 'ArrowUp': e.preventDefault(); if (!paused) tryRotate(); break;
case ' ':
e.preventDefault();
if (!paused) { while (!hit(current,0,1)) current.y++; lock(); }
break;
case 'p': case 'P': paused = !paused; break;
}
});
// =====================
// 渲染
// =====================
function render() {
const S = CONFIG.CELL;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
ctx.strokeRect(c*S, r*S, S, S);
for (let r = 0; r < CONFIG.ROWS; r++)
for (let c = 0; c < CONFIG.COLS; c++)
if (board[r][c]) drawCell(ctx, c, r, board[r][c], S);
// 虚影(炸弹不显示虚影)
if (!current.isBomb) {
const ghost = ghostPiece();
ghost.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect((ghost.x+c)*S+1, (ghost.y+r)*S+1, S-2, S-2);
})
);
}
// 当前方块
current.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
if (current.isBomb) {
drawBombCell(ctx, current.x+c, current.y+r, S);
} else {
drawCell(ctx, current.x+c, current.y+r, current.color, S);
}
})
);
if (paused) {
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.fillText('暂停', canvas.width/2, canvas.height/2);
ctx.font = '15px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText('按 P 继续', canvas.width/2, canvas.height/2+34);
}
}
function drawCell(context, cx, cy, color, S) {
context.fillStyle = color;
context.fillRect(cx*S+1, cy*S+1, S-2, S-2);
context.fillStyle = 'rgba(255,255,255,0.18)';
context.fillRect(cx*S+1, cy*S+1, S-2, 4);
}
function drawBombCell(context, cx, cy, S) {
// 炸弹外观:深红底 + 💣 表情
context.fillStyle = '#2a0a0a';
context.fillRect(cx*S+1, cy*S+1, S-2, S-2);
context.strokeStyle = '#ff4500';
context.lineWidth = 2;
context.strokeRect(cx*S+2, cy*S+2, S-4, S-4);
context.font = `${S-8}px serif`;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('💣', cx*S + S/2, cy*S + S/2 + 1);
}
function ghostPiece() {
const g = { shape: current.shape, x: current.x, y: current.y };
while (!hit(g, 0, 1)) g.y++;
return g;
}
function drawNext() {
const S = 20;
nextCtx.fillStyle = '#16213e';
nextCtx.fillRect(0, 0, nextCvs.width, nextCvs.height);
if (next.isBomb) {
nextCtx.font = '36px serif';
nextCtx.textAlign = 'center';
nextCtx.textBaseline = 'middle';
nextCtx.fillText('💣', nextCvs.width/2, nextCvs.height/2);
return;
}
const ox = Math.floor((4 - next.shape[0].length) / 2);
const oy = Math.floor((4 - next.shape.length) / 2);
next.shape.forEach((row, r) =>
row.forEach((v, c) => {
if (!v) return;
nextCtx.fillStyle = next.color;
nextCtx.fillRect((ox+c)*S, (oy+r)*S, S-1, S-1);
})
);
}
function updateHUD() {
document.getElementById('score').textContent = score;
document.getElementById('level').textContent = level;
document.getElementById('lines').textContent = lines;
}
function hardDrop() {
while (!hit(current, 0, 1)) current.y++;
lock();
}
function endGame() {
running = false;
document.getElementById('final-score').textContent = score;
document.getElementById('overlay').classList.add('show');
}
startGame();
</script>
</body>
</html>