feat: 新增俄罗斯方块小游戏(games/game7.html)
纯 HTML 单文件实现,Canvas 渲染,含 7-bag 发牌、暂存、落点虚影、硬降/软降、等级加速与触屏按键,最高分本地持久化。
This commit is contained in:
1 parent
9e8ad76400
commit
6b77cdcf3a
1 file changed
+718
@@ -0,0 +1,718 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>俄罗斯方块</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 860px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
background: linear-gradient(to right, #00e5ff, #7c4dff, #ff4081);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 30px rgba(0, 229, 255, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #8fd8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 255, 255, .06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .12);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
font-size: .85rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #00e5ff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 229, 255, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: #9fb3c8;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(0, 0, 0, .45);
|
||||||
|
border: 1px solid rgba(0, 229, 255, .25);
|
||||||
|
box-shadow: 0 0 40px rgba(0, 229, 255, .15), inset 0 0 30px rgba(0, 0, 0, .6);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#board {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #05080f;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.preview {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(0, 0, 0, .35);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #04121a;
|
||||||
|
background: linear-gradient(135deg, #00e5ff, #2979ff);
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 229, 255, .35);
|
||||||
|
transition: transform .12s ease, box-shadow .12s ease, filter .12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { transform: translateY(-2px); filter: brightness(1.1); }
|
||||||
|
.btn:active { transform: translateY(1px) scale(.97); }
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: rgba(255, 255, 255, .08);
|
||||||
|
color: #cfeaff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: #9fb3c8;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
background: rgba(255, 255, 255, .1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .25);
|
||||||
|
border-bottom-width: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .78rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖层(开始 / 暂停 / 结束) */
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(3, 8, 15, .85);
|
||||||
|
border-radius: 14px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: #00e5ff;
|
||||||
|
text-shadow: 0 0 18px rgba(0, 229, 255, .6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay p { color: #cfeaff; font-size: .95rem; }
|
||||||
|
|
||||||
|
/* 手机触屏按键 */
|
||||||
|
.touch-controls {
|
||||||
|
display: none;
|
||||||
|
margin-top: 16px;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 420px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-controls button {
|
||||||
|
border: 1px solid rgba(0, 229, 255, .35);
|
||||||
|
background: rgba(0, 229, 255, .1);
|
||||||
|
color: #aef;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-controls button:active {
|
||||||
|
background: rgba(0, 229, 255, .35);
|
||||||
|
transform: scale(.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 { font-size: 1.9rem; }
|
||||||
|
.touch-controls { display: grid; }
|
||||||
|
.controls-hint { display: none; }
|
||||||
|
.side { flex-direction: row; flex-wrap: wrap; justify-content: center; }
|
||||||
|
.panel { min-width: 105px; padding: 10px 12px; }
|
||||||
|
.stat-value { font-size: 1.25rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>俄罗斯方块</h1>
|
||||||
|
<p class="subtitle">经典消除 · 挑战极限手速</p>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<!-- 左侧面板 -->
|
||||||
|
<div class="side">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>暂存 Hold</h3>
|
||||||
|
<canvas class="preview" id="holdCanvas" width="96" height="72"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="stat-value" id="score">0</div>
|
||||||
|
<div class="stat-label">分数 SCORE</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="stat-value" id="highScore">0</div>
|
||||||
|
<div class="stat-label">最高分 BEST</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 游戏主区域 -->
|
||||||
|
<div class="board-wrap">
|
||||||
|
<canvas id="board" width="300" height="600"></canvas>
|
||||||
|
|
||||||
|
<div class="overlay" id="startOverlay">
|
||||||
|
<h2>俄罗斯方块</h2>
|
||||||
|
<p>消除整行得分,连续消除分数更高!</p>
|
||||||
|
<button class="btn" id="startBtn">开始游戏</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overlay hidden" id="pauseOverlay">
|
||||||
|
<h2>已暂停</h2>
|
||||||
|
<p>按 P 或点击按钮继续</p>
|
||||||
|
<button class="btn" id="resumeBtn">继续游戏</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overlay hidden" id="overOverlay">
|
||||||
|
<h2>游戏结束</h2>
|
||||||
|
<p id="finalScore">本局得分:0</p>
|
||||||
|
<p id="newRecord" style="color:#ffd54f;display:none;">🏆 新纪录!</p>
|
||||||
|
<button class="btn" id="restartBtn">再来一局</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧面板 -->
|
||||||
|
<div class="side">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>下一个 Next</h3>
|
||||||
|
<canvas class="preview" id="nextCanvas" width="96" height="216"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="stat-value" id="lines">0</div>
|
||||||
|
<div class="stat-label">消除行数 LINES</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="stat-value" id="level">1</div>
|
||||||
|
<div class="stat-label">等级 LEVEL</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:18px;">
|
||||||
|
<button class="btn ghost" id="pauseBtn">暂停 (P)</button>
|
||||||
|
<button class="btn ghost" id="resetBtn">重新开始</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 触屏按键(移动端显示) -->
|
||||||
|
<div class="touch-controls">
|
||||||
|
<button data-act="left">◀</button>
|
||||||
|
<button data-act="rotate">⟳</button>
|
||||||
|
<button data-act="right">▶</button>
|
||||||
|
<button data-act="drop">⤓</button>
|
||||||
|
<button data-act="hold">暂存</button>
|
||||||
|
<button data-act="down">▼</button>
|
||||||
|
<button data-act="pause">⏸</button>
|
||||||
|
<button data-act="reset">↺</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="controls-hint">
|
||||||
|
<kbd>←</kbd><kbd>→</kbd> 移动 <kbd>↓</kbd> 软降 <kbd>↑</kbd>/<kbd>X</kbd> 旋转
|
||||||
|
<kbd>空格</kbd> 硬降 <kbd>C</kbd> 暂存 <kbd>P</kbd> 暂停
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
const COLS = 10, ROWS = 20, BLOCK = 30;
|
||||||
|
const boardCanvas = document.getElementById('board');
|
||||||
|
const bctx = boardCanvas.getContext('2d');
|
||||||
|
const nextCanvas = document.getElementById('nextCanvas');
|
||||||
|
const nctx = nextCanvas.getContext('2d');
|
||||||
|
const holdCanvas = document.getElementById('holdCanvas');
|
||||||
|
const hctx = holdCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// 7 种方块:I J L O S T Z(形状矩阵 + 颜色)
|
||||||
|
const SHAPES = [
|
||||||
|
[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], // I
|
||||||
|
[[1,0,0],[1,1,1],[0,0,0]], // J
|
||||||
|
[[0,0,1],[1,1,1],[0,0,0]], // L
|
||||||
|
[[1,1],[1,1]], // O
|
||||||
|
[[0,1,1],[1,1,0],[0,0,0]], // S
|
||||||
|
[[0,1,0],[1,1,1],[0,0,0]], // T
|
||||||
|
[[1,1,0],[0,1,1],[0,0,0]] // Z
|
||||||
|
];
|
||||||
|
const COLORS = ['#00e5ff', '#2979ff', '#ff9100', '#ffd600', '#00e676', '#b388ff', '#ff4081'];
|
||||||
|
|
||||||
|
const LINE_SCORES = [0, 100, 300, 500, 800]; // 0~4 行基础分
|
||||||
|
|
||||||
|
// ---------- 状态 ----------
|
||||||
|
let grid, piece, nextQueue, holdType, canHold;
|
||||||
|
let score, lines, level, highScore;
|
||||||
|
let dropCounter, dropInterval, lastTime;
|
||||||
|
let running = false, paused = false, gameOver = false;
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
|
highScore = parseInt(localStorage.getItem('tetris_best') || '0', 10);
|
||||||
|
document.getElementById('highScore').textContent = highScore;
|
||||||
|
|
||||||
|
// ---------- 工具 ----------
|
||||||
|
function rotate(matrix) {
|
||||||
|
const n = matrix.length;
|
||||||
|
const res = Array.from({length: n}, () => Array(n).fill(0));
|
||||||
|
for (let y = 0; y < n; y++)
|
||||||
|
for (let x = 0; x < n; x++)
|
||||||
|
res[x][n - 1 - y] = matrix[y][x];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7-bag 随机发牌,保证每种方块均匀出现
|
||||||
|
let bag = [];
|
||||||
|
function nextType() {
|
||||||
|
if (bag.length === 0) {
|
||||||
|
bag = [0,1,2,3,4,5,6];
|
||||||
|
for (let i = bag.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bag.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(type) {
|
||||||
|
const shape = SHAPES[type].map(row => row.slice());
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
shape,
|
||||||
|
color: COLORS[type],
|
||||||
|
x: Math.floor((COLS - shape[0].length) / 2),
|
||||||
|
y: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collides(shape, ox, oy) {
|
||||||
|
for (let y = 0; y < shape.length; y++) {
|
||||||
|
for (let x = 0; x < shape[y].length; x++) {
|
||||||
|
if (!shape[y][x]) continue;
|
||||||
|
const nx = ox + x, ny = oy + y;
|
||||||
|
if (nx < 0 || nx >= COLS || ny >= ROWS) return true;
|
||||||
|
if (ny >= 0 && grid[ny][nx] !== 0) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge() {
|
||||||
|
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||||
|
if (v && piece.y + y >= 0) grid[piece.y + y][piece.x + x] = piece.type + 1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLines() {
|
||||||
|
let cleared = 0;
|
||||||
|
outer: for (let y = ROWS - 1; y >= 0; y--) {
|
||||||
|
for (let x = 0; x < COLS; x++) if (!grid[y][x]) continue outer;
|
||||||
|
grid.splice(y, 1);
|
||||||
|
grid.unshift(Array(COLS).fill(0));
|
||||||
|
cleared++;
|
||||||
|
y++; // 同一行再检查一次
|
||||||
|
}
|
||||||
|
if (cleared > 0) {
|
||||||
|
score += LINE_SCORES[cleared] * level;
|
||||||
|
lines += cleared;
|
||||||
|
const newLevel = Math.floor(lines / 10) + 1;
|
||||||
|
if (newLevel !== level) {
|
||||||
|
level = newLevel;
|
||||||
|
dropInterval = Math.max(60, 1000 * Math.pow(0.8, level - 1));
|
||||||
|
}
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 操作 ----------
|
||||||
|
function move(dir) {
|
||||||
|
if (!canPlay()) return;
|
||||||
|
if (!collides(piece.shape, piece.x + dir, piece.y)) piece.x += dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function softDrop() {
|
||||||
|
if (!canPlay()) return;
|
||||||
|
if (!collides(piece.shape, piece.x, piece.y + 1)) {
|
||||||
|
piece.y++;
|
||||||
|
score += 1;
|
||||||
|
updateStats();
|
||||||
|
} else {
|
||||||
|
lockPiece();
|
||||||
|
}
|
||||||
|
dropCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardDrop() {
|
||||||
|
if (!canPlay()) return;
|
||||||
|
let d = 0;
|
||||||
|
while (!collides(piece.shape, piece.x, piece.y + 1)) { piece.y++; d++; }
|
||||||
|
score += d * 2;
|
||||||
|
lockPiece();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePiece() {
|
||||||
|
if (!canPlay()) return;
|
||||||
|
const rotated = rotate(piece.shape);
|
||||||
|
// 简单踢墙:依次尝试原地、左1、右1、左2、右2、上1
|
||||||
|
const kicks = [0, -1, 1, -2, 2];
|
||||||
|
for (const k of kicks) {
|
||||||
|
if (!collides(rotated, piece.x + k, piece.y)) {
|
||||||
|
piece.shape = rotated;
|
||||||
|
piece.x += k;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!collides(rotated, piece.x, piece.y - 1)) {
|
||||||
|
piece.shape = rotated;
|
||||||
|
piece.y -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdPiece() {
|
||||||
|
if (!canPlay() || !canHold) return;
|
||||||
|
if (holdType === null) {
|
||||||
|
holdType = piece.type;
|
||||||
|
piece = spawn(nextQueue.shift());
|
||||||
|
nextQueue.push(nextType());
|
||||||
|
} else {
|
||||||
|
[holdType, piece] = [piece.type, spawn(holdType)];
|
||||||
|
}
|
||||||
|
canHold = false;
|
||||||
|
drawHold();
|
||||||
|
drawNext();
|
||||||
|
if (collides(piece.shape, piece.x, piece.y)) endGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPiece() {
|
||||||
|
merge();
|
||||||
|
clearLines();
|
||||||
|
piece = spawn(nextQueue.shift());
|
||||||
|
nextQueue.push(nextType());
|
||||||
|
canHold = true;
|
||||||
|
drawNext();
|
||||||
|
if (collides(piece.shape, piece.x, piece.y)) endGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ghostY() {
|
||||||
|
let gy = piece.y;
|
||||||
|
while (!collides(piece.shape, piece.x, gy + 1)) gy++;
|
||||||
|
return gy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPlay() { return running && !paused && !gameOver; }
|
||||||
|
|
||||||
|
// ---------- 绘制 ----------
|
||||||
|
function drawBlock(ctx, x, y, size, color, ghost = false) {
|
||||||
|
if (ghost) {
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(x + 1, y + 1, size - 2, size - 2);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const g = ctx.createLinearGradient(x, y, x, y + size);
|
||||||
|
g.addColorStop(0, lighten(color, 30));
|
||||||
|
g.addColorStop(1, color);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(x + 1, y + 1, size - 2, size - 2);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,.25)';
|
||||||
|
ctx.fillRect(x + 3, y + 3, size - 6, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lighten(hex, amt) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.min(255, (n >> 16) + amt);
|
||||||
|
const g = Math.min(255, ((n >> 8) & 255) + amt);
|
||||||
|
const b = Math.min(255, (n & 255) + amt);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
bctx.clearRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||||
|
|
||||||
|
// 网格线
|
||||||
|
bctx.strokeStyle = 'rgba(255,255,255,.05)';
|
||||||
|
bctx.lineWidth = 1;
|
||||||
|
for (let x = 1; x < COLS; x++) {
|
||||||
|
bctx.beginPath(); bctx.moveTo(x * BLOCK, 0); bctx.lineTo(x * BLOCK, ROWS * BLOCK); bctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 1; y < ROWS; y++) {
|
||||||
|
bctx.beginPath(); bctx.moveTo(0, y * BLOCK); bctx.lineTo(COLS * BLOCK, y * BLOCK); bctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已固定方块
|
||||||
|
for (let y = 0; y < ROWS; y++)
|
||||||
|
for (let x = 0; x < COLS; x++)
|
||||||
|
if (grid[y][x]) drawBlock(bctx, x * BLOCK, y * BLOCK, BLOCK, COLORS[grid[y][x] - 1]);
|
||||||
|
|
||||||
|
if (piece && !gameOver) {
|
||||||
|
// 落点虚影
|
||||||
|
const gy = ghostY();
|
||||||
|
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||||
|
if (v && gy + y >= 0) drawBlock(bctx, (piece.x + x) * BLOCK, (gy + y) * BLOCK, BLOCK, piece.color, true);
|
||||||
|
}));
|
||||||
|
// 当前方块
|
||||||
|
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||||
|
if (v && piece.y + y >= 0) drawBlock(bctx, (piece.x + x) * BLOCK, (piece.y + y) * BLOCK, BLOCK, piece.color);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreviewShape(ctx, canvas, type, offsetY, cellSize) {
|
||||||
|
const shape = SHAPES[type];
|
||||||
|
const color = COLORS[type];
|
||||||
|
let minX = 4, maxX = -1, minY = 4, maxY = -1;
|
||||||
|
shape.forEach((row, y) => row.forEach((v, x) => {
|
||||||
|
if (v) { minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }
|
||||||
|
}));
|
||||||
|
const w = (maxX - minX + 1) * cellSize, h = (maxY - minY + 1) * cellSize;
|
||||||
|
const ox = (canvas.width - w) / 2, oy = offsetY + (72 - h) / 2;
|
||||||
|
shape.forEach((row, y) => row.forEach((v, x) => {
|
||||||
|
if (v) drawBlock(ctx, ox + (x - minX) * cellSize, oy + (y - minY) * cellSize, cellSize, color);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawNext() {
|
||||||
|
nctx.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
|
||||||
|
nextQueue.slice(0, 3).forEach((t, i) => drawPreviewShape(nctx, nextCanvas, t, i * 72, 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHold() {
|
||||||
|
hctx.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
|
||||||
|
if (holdType !== null) {
|
||||||
|
hctx.globalAlpha = canHold ? 1 : 0.35;
|
||||||
|
drawPreviewShape(hctx, holdCanvas, holdType, 0, 18);
|
||||||
|
hctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
document.getElementById('score').textContent = score;
|
||||||
|
document.getElementById('lines').textContent = lines;
|
||||||
|
document.getElementById('level').textContent = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主循环 ----------
|
||||||
|
function loop(time = 0) {
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
if (!running || paused || gameOver) { draw(); return; }
|
||||||
|
const delta = time - lastTime;
|
||||||
|
lastTime = time;
|
||||||
|
dropCounter += delta;
|
||||||
|
if (dropCounter > dropInterval) {
|
||||||
|
if (!collides(piece.shape, piece.x, piece.y + 1)) {
|
||||||
|
piece.y++;
|
||||||
|
} else {
|
||||||
|
lockPiece();
|
||||||
|
}
|
||||||
|
dropCounter = 0;
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 流程控制 ----------
|
||||||
|
function resetGame() {
|
||||||
|
grid = Array.from({length: ROWS}, () => Array(COLS).fill(0));
|
||||||
|
bag = [];
|
||||||
|
nextQueue = [nextType(), nextType(), nextType()];
|
||||||
|
piece = spawn(nextQueue.shift());
|
||||||
|
nextQueue.push(nextType());
|
||||||
|
holdType = null;
|
||||||
|
canHold = true;
|
||||||
|
score = 0; lines = 0; level = 1;
|
||||||
|
dropCounter = 0; dropInterval = 1000; lastTime = 0;
|
||||||
|
gameOver = false; paused = false;
|
||||||
|
updateStats();
|
||||||
|
drawNext();
|
||||||
|
drawHold();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
resetGame();
|
||||||
|
running = true;
|
||||||
|
hideOverlays();
|
||||||
|
if (!rafId) loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endGame() {
|
||||||
|
gameOver = true;
|
||||||
|
running = false;
|
||||||
|
let record = false;
|
||||||
|
if (score > highScore) {
|
||||||
|
highScore = score;
|
||||||
|
localStorage.setItem('tetris_best', String(highScore));
|
||||||
|
document.getElementById('highScore').textContent = highScore;
|
||||||
|
record = true;
|
||||||
|
}
|
||||||
|
document.getElementById('finalScore').textContent = '本局得分:' + score;
|
||||||
|
document.getElementById('newRecord').style.display = record ? 'block' : 'none';
|
||||||
|
show('overOverlay');
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePause() {
|
||||||
|
if (!running || gameOver) return;
|
||||||
|
paused = !paused;
|
||||||
|
lastTime = performance.now();
|
||||||
|
document.getElementById('pauseBtn').textContent = paused ? '继续 (P)' : '暂停 (P)';
|
||||||
|
paused ? show('pauseOverlay') : hide('pauseOverlay');
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(id) { document.getElementById(id).classList.remove('hidden'); }
|
||||||
|
function hide(id) { document.getElementById(id).classList.add('hidden'); }
|
||||||
|
function hideOverlays() { ['startOverlay', 'pauseOverlay', 'overOverlay'].forEach(hide); }
|
||||||
|
|
||||||
|
// ---------- 输入 ----------
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.repeat && e.code === 'Space') return;
|
||||||
|
switch (e.code) {
|
||||||
|
case 'ArrowLeft': e.preventDefault(); move(-1); break;
|
||||||
|
case 'ArrowRight': e.preventDefault(); move(1); break;
|
||||||
|
case 'ArrowDown': e.preventDefault(); softDrop(); break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'KeyX': e.preventDefault(); rotatePiece(); break;
|
||||||
|
case 'Space': e.preventDefault(); hardDrop(); break;
|
||||||
|
case 'KeyC': holdPiece(); break;
|
||||||
|
case 'KeyP': togglePause(); break;
|
||||||
|
case 'Enter':
|
||||||
|
if (!running || gameOver) startGame();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触屏按钮(支持按住连发)
|
||||||
|
document.querySelectorAll('.touch-controls button').forEach(btn => {
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
let timer = null;
|
||||||
|
const fire = () => {
|
||||||
|
if (act === 'left') move(-1);
|
||||||
|
else if (act === 'right') move(1);
|
||||||
|
else if (act === 'down') softDrop();
|
||||||
|
else if (act === 'rotate') rotatePiece();
|
||||||
|
else if (act === 'drop') hardDrop();
|
||||||
|
else if (act === 'hold') holdPiece();
|
||||||
|
else if (act === 'pause') togglePause();
|
||||||
|
else if (act === 'reset') startGame();
|
||||||
|
};
|
||||||
|
btn.addEventListener('pointerdown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
fire();
|
||||||
|
if (['left', 'right', 'down'].includes(act)) {
|
||||||
|
timer = setInterval(fire, act === 'down' ? 60 : 110);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev =>
|
||||||
|
btn.addEventListener(ev, () => { if (timer) { clearInterval(timer); timer = null; } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('startBtn').addEventListener('click', startGame);
|
||||||
|
document.getElementById('restartBtn').addEventListener('click', startGame);
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', startGame);
|
||||||
|
document.getElementById('resumeBtn').addEventListener('click', togglePause);
|
||||||
|
document.getElementById('pauseBtn').addEventListener('click', togglePause);
|
||||||
|
|
||||||
|
// 初始画面
|
||||||
|
resetGame();
|
||||||
|
running = false;
|
||||||
|
loop();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user