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>
This commit is contained in:
Rocky
2026-04-09 14:37:58 +02:00
parent 7eac00a35c
commit 3384472d0d
4 changed files with 1788 additions and 0 deletions

View File

@@ -0,0 +1,576 @@
<!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>