- 删除旧的 AI03 课程文件(第4-5课) - 新增 AICODE-03 课程体系(AI是怎么想的、AI训练师、我是大作家等) - 新增 AICODE-06 课程体系(从扣子到代码、个人主页、涂鸦PK等)
577 lines
14 KiB
HTML
577 lines
14 KiB
HTML
<!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>
|