|
|
@@ -0,0 +1,765 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8">
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
|
+<title>俄罗斯方块</title>
|
|
|
+<style>
|
|
|
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
|
|
+ html, body { height: 100%; }
|
|
|
+ body {
|
|
|
+ background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
|
|
+ color: #e8e8f0;
|
|
|
+ font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ min-height: 100vh;
|
|
|
+ overflow: auto;
|
|
|
+ user-select: none;
|
|
|
+ -webkit-user-select: none;
|
|
|
+ touch-action: manipulation;
|
|
|
+ }
|
|
|
+ .app {
|
|
|
+ display: flex;
|
|
|
+ gap: 24px;
|
|
|
+ padding: 20px;
|
|
|
+ align-items: flex-start;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+ .board-wrap {
|
|
|
+ position: relative;
|
|
|
+ background: rgba(0,0,0,0.35);
|
|
|
+ border: 2px solid rgba(255,255,255,0.12);
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 12px;
|
|
|
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 0 30px rgba(0,0,0,0.4);
|
|
|
+ }
|
|
|
+ canvas#board {
|
|
|
+ display: block;
|
|
|
+ background: #0a0a18;
|
|
|
+ border-radius: 6px;
|
|
|
+ image-rendering: pixelated;
|
|
|
+ }
|
|
|
+ .side {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16px;
|
|
|
+ min-width: 180px;
|
|
|
+ }
|
|
|
+ .panel {
|
|
|
+ background: rgba(0,0,0,0.35);
|
|
|
+ border: 2px solid rgba(255,255,255,0.12);
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 14px 16px;
|
|
|
+ box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
|
|
+ }
|
|
|
+ .panel h3 {
|
|
|
+ font-size: 12px;
|
|
|
+ letter-spacing: 2px;
|
|
|
+ color: #8a8ab0;
|
|
|
+ text-transform: uppercase;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ }
|
|
|
+ .score {
|
|
|
+ font-size: 28px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #ffd86b;
|
|
|
+ text-shadow: 0 0 12px rgba(255,216,107,0.4);
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ }
|
|
|
+ .stats-row {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 14px;
|
|
|
+ margin-top: 8px;
|
|
|
+ }
|
|
|
+ .stats-row span:last-child {
|
|
|
+ color: #7ee8ff;
|
|
|
+ font-weight: 600;
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+ }
|
|
|
+ .next-canvas-wrap {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ background: #0a0a18;
|
|
|
+ border-radius: 6px;
|
|
|
+ height: 110px;
|
|
|
+ }
|
|
|
+ canvas#next {
|
|
|
+ background: #0a0a18;
|
|
|
+ border-radius: 6px;
|
|
|
+ }
|
|
|
+ .keys {
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.9;
|
|
|
+ }
|
|
|
+ .keys kbd {
|
|
|
+ display: inline-block;
|
|
|
+ min-width: 22px;
|
|
|
+ text-align: center;
|
|
|
+ background: rgba(255,255,255,0.08);
|
|
|
+ border: 1px solid rgba(255,255,255,0.18);
|
|
|
+ border-bottom-width: 2px;
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 1px 6px;
|
|
|
+ margin-right: 6px;
|
|
|
+ font-family: 'Consolas', 'Monaco', monospace;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+ .keys .row { display: flex; align-items: center; }
|
|
|
+ .overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 12px;
|
|
|
+ background: rgba(10, 10, 24, 0.85);
|
|
|
+ backdrop-filter: blur(4px);
|
|
|
+ border-radius: 6px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ text-align: center;
|
|
|
+ padding: 20px;
|
|
|
+ z-index: 10;
|
|
|
+ transition: opacity 0.2s;
|
|
|
+ }
|
|
|
+ .overlay.hidden { display: none; }
|
|
|
+ .overlay h2 {
|
|
|
+ font-size: 32px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+ letter-spacing: 4px;
|
|
|
+ }
|
|
|
+ .overlay p {
|
|
|
+ color: #b0b0c8;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ .overlay .final-score {
|
|
|
+ font-size: 40px;
|
|
|
+ color: #ffd86b;
|
|
|
+ font-weight: 700;
|
|
|
+ margin: 10px 0;
|
|
|
+ text-shadow: 0 0 20px rgba(255,216,107,0.5);
|
|
|
+ }
|
|
|
+ .btn {
|
|
|
+ background: linear-gradient(135deg, #7ee8ff, #5b8cff);
|
|
|
+ color: #0a0a18;
|
|
|
+ border: none;
|
|
|
+ padding: 10px 24px;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 700;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ margin-top: 14px;
|
|
|
+ letter-spacing: 1px;
|
|
|
+ box-shadow: 0 4px 14px rgba(91,140,255,0.4);
|
|
|
+ }
|
|
|
+ .btn:hover { transform: translateY(-1px); }
|
|
|
+ .btn:active { transform: translateY(1px); }
|
|
|
+ .touch {
|
|
|
+ display: none;
|
|
|
+ margin-top: 16px;
|
|
|
+ grid-template-columns: repeat(5, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 420px;
|
|
|
+ }
|
|
|
+ .touch button {
|
|
|
+ background: rgba(255,255,255,0.08);
|
|
|
+ border: 1px solid rgba(255,255,255,0.18);
|
|
|
+ color: #fff;
|
|
|
+ padding: 14px 0;
|
|
|
+ border-radius: 8px;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 700;
|
|
|
+ cursor: pointer;
|
|
|
+ min-height: 48px;
|
|
|
+ }
|
|
|
+ .touch button:active { background: rgba(126,232,255,0.25); }
|
|
|
+ .touch .wide { grid-column: span 2; }
|
|
|
+ .touch .full { grid-column: 1 / -1; }
|
|
|
+ .combo {
|
|
|
+ position: absolute;
|
|
|
+ pointer-events: none;
|
|
|
+ font-size: 22px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #ffd86b;
|
|
|
+ text-shadow: 0 0 10px rgba(255,216,107,0.6);
|
|
|
+ animation: float 1s ease-out forwards;
|
|
|
+ }
|
|
|
+ @keyframes float {
|
|
|
+ from { opacity: 1; transform: translateY(0); }
|
|
|
+ to { opacity: 0; transform: translateY(-40px); }
|
|
|
+ }
|
|
|
+ .title {
|
|
|
+ position: absolute;
|
|
|
+ top: -2px;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ background: linear-gradient(135deg, #7ee8ff, #ff7eb3);
|
|
|
+ -webkit-background-clip: text;
|
|
|
+ background-clip: text;
|
|
|
+ color: transparent;
|
|
|
+ font-weight: 800;
|
|
|
+ font-size: 18px;
|
|
|
+ letter-spacing: 6px;
|
|
|
+ padding: 0 14px;
|
|
|
+ }
|
|
|
+ .flash {
|
|
|
+ animation: flash 0.4s ease;
|
|
|
+ }
|
|
|
+ @keyframes flash {
|
|
|
+ 0%, 100% { background: #0a0a18; }
|
|
|
+ 50% { background: #2a2a4a; }
|
|
|
+ }
|
|
|
+ @media (max-width: 720px) {
|
|
|
+ .touch { display: grid; }
|
|
|
+ .side { min-width: 140px; }
|
|
|
+ .keys { display: none; }
|
|
|
+ }
|
|
|
+ @media (hover: none) and (pointer: coarse) {
|
|
|
+ .touch { display: grid; }
|
|
|
+ .keys { display: none; }
|
|
|
+ }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+
|
|
|
+<div class="app">
|
|
|
+ <div class="board-wrap">
|
|
|
+ <div class="title">TETRIS</div>
|
|
|
+ <canvas id="board" width="300" height="600"></canvas>
|
|
|
+ <div class="overlay hidden" id="overlay">
|
|
|
+ <h2 id="overlay-title">已暂停</h2>
|
|
|
+ <p id="overlay-text">按 空格 继续游戏</p>
|
|
|
+ <button class="btn" id="overlay-btn">继续</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="side">
|
|
|
+ <div class="panel">
|
|
|
+ <h3>分数</h3>
|
|
|
+ <div class="score" id="score">0</div>
|
|
|
+ <div class="stats-row"><span>等级</span><span id="level">1</span></div>
|
|
|
+ <div class="stats-row"><span>行数</span><span id="lines">0</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="panel">
|
|
|
+ <h3>下一个</h3>
|
|
|
+ <div class="next-canvas-wrap">
|
|
|
+ <canvas id="next" width="100" height="100"></canvas>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="panel keys">
|
|
|
+ <h3>操作</h3>
|
|
|
+ <div class="row"><kbd>←</kbd><kbd>→</kbd>移动</div>
|
|
|
+ <div class="row"><kbd>↓</kbd>快速下落</div>
|
|
|
+ <div class="row"><kbd>Enter</kbd>直接落地</div>
|
|
|
+ <div class="row"><kbd>Z</kbd><kbd>X</kbd>旋转</div>
|
|
|
+ <div class="row"><kbd>Space</kbd>暂停</div>
|
|
|
+ <div class="row"><kbd>Q</kbd>重开</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<div class="touch" id="touch">
|
|
|
+ <button data-act="left">◀</button>
|
|
|
+ <button data-act="rotateL">Z</button>
|
|
|
+ <button data-act="down">▼</button>
|
|
|
+ <button data-act="rotateR">X</button>
|
|
|
+ <button data-act="right">▶</button>
|
|
|
+ <button class="wide" data-act="drop">落地</button>
|
|
|
+ <button class="wide" data-act="pause">暂停</button>
|
|
|
+ <button class="full" data-act="restart">重新开始 (Q)</button>
|
|
|
+</div>
|
|
|
+
|
|
|
+<script>
|
|
|
+(() => {
|
|
|
+ // ====== Constants ======
|
|
|
+ const COLS = 10, ROWS = 20, CELL = 30;
|
|
|
+ const board = document.getElementById('board');
|
|
|
+ const ctx = board.getContext('2d');
|
|
|
+ const next = document.getElementById('next');
|
|
|
+ const nctx = next.getContext('2d');
|
|
|
+
|
|
|
+ // Piece definitions: each is a 4x4 matrix with rotation states
|
|
|
+ // Using a simpler representation: list of 4 rotation states, each is list of [x,y] cells
|
|
|
+ const SHAPES = {
|
|
|
+ I: {
|
|
|
+ color: '#00f0f0',
|
|
|
+ rotations: [
|
|
|
+ [[0,1],[1,1],[2,1],[3,1]],
|
|
|
+ [[2,0],[2,1],[2,2],[2,3]],
|
|
|
+ [[0,2],[1,2],[2,2],[3,2]],
|
|
|
+ [[1,0],[1,1],[1,2],[1,3]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ O: {
|
|
|
+ color: '#f0f000',
|
|
|
+ rotations: [
|
|
|
+ [[1,0],[2,0],[1,1],[2,1]],
|
|
|
+ [[1,0],[2,0],[1,1],[2,1]],
|
|
|
+ [[1,0],[2,0],[1,1],[2,1]],
|
|
|
+ [[1,0],[2,0],[1,1],[2,1]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ T: {
|
|
|
+ color: '#a000f0',
|
|
|
+ rotations: [
|
|
|
+ [[1,0],[0,1],[1,1],[2,1]],
|
|
|
+ [[1,0],[1,1],[2,1],[1,2]],
|
|
|
+ [[0,1],[1,1],[2,1],[1,2]],
|
|
|
+ [[1,0],[0,1],[1,1],[1,2]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ S: {
|
|
|
+ color: '#00f000',
|
|
|
+ rotations: [
|
|
|
+ [[1,0],[2,0],[0,1],[1,1]],
|
|
|
+ [[1,0],[1,1],[2,1],[2,2]],
|
|
|
+ [[1,1],[2,1],[0,2],[1,2]],
|
|
|
+ [[0,0],[0,1],[1,1],[1,2]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ Z: {
|
|
|
+ color: '#f00000',
|
|
|
+ rotations: [
|
|
|
+ [[0,0],[1,0],[1,1],[2,1]],
|
|
|
+ [[2,0],[1,1],[2,1],[1,2]],
|
|
|
+ [[0,1],[1,1],[1,2],[2,2]],
|
|
|
+ [[1,0],[0,1],[1,1],[0,2]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ J: {
|
|
|
+ color: '#0000f0',
|
|
|
+ rotations: [
|
|
|
+ [[0,0],[0,1],[1,1],[2,1]],
|
|
|
+ [[1,0],[2,0],[1,1],[1,2]],
|
|
|
+ [[0,1],[1,1],[2,1],[2,2]],
|
|
|
+ [[1,0],[1,1],[0,2],[1,2]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ L: {
|
|
|
+ color: '#f0a000',
|
|
|
+ rotations: [
|
|
|
+ [[2,0],[0,1],[1,1],[2,1]],
|
|
|
+ [[1,0],[1,1],[1,2],[2,2]],
|
|
|
+ [[0,1],[1,1],[2,1],[0,2]],
|
|
|
+ [[0,0],[1,0],[1,1],[1,2]],
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const TYPES = Object.keys(SHAPES);
|
|
|
+
|
|
|
+ // SRS-style wall kick data
|
|
|
+ const KICKS_JLSTZ = {
|
|
|
+ '0->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
|
|
|
+ '1->0': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
|
|
|
+ '1->2': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
|
|
|
+ '2->1': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
|
|
|
+ '2->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
|
|
|
+ '3->2': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
|
|
|
+ '3->0': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
|
|
|
+ '0->3': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
|
|
|
+ };
|
|
|
+ const KICKS_I = {
|
|
|
+ '0->1': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
|
|
|
+ '1->0': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
|
|
|
+ '1->2': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
|
|
|
+ '2->1': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
|
|
|
+ '2->3': [[0,0],[2,0],[-1,0],[2,1],[-1,-2]],
|
|
|
+ '3->2': [[0,0],[-2,0],[1,0],[-2,-1],[1,2]],
|
|
|
+ '3->0': [[0,0],[1,0],[-2,0],[1,-2],[-2,1]],
|
|
|
+ '0->3': [[0,0],[-1,0],[2,0],[-1,2],[2,-1]],
|
|
|
+ };
|
|
|
+
|
|
|
+ // ====== State ======
|
|
|
+ let grid, current, nextType, score, level, lines, dropInterval, dropCounter, lastTime, gameOver, paused;
|
|
|
+ const overlay = document.getElementById('overlay');
|
|
|
+ const overlayTitle = document.getElementById('overlay-title');
|
|
|
+ const overlayText = document.getElementById('overlay-text');
|
|
|
+ const overlayBtn = document.getElementById('overlay-btn');
|
|
|
+ const scoreEl = document.getElementById('score');
|
|
|
+ const levelEl = document.getElementById('level');
|
|
|
+ const linesEl = document.getElementById('lines');
|
|
|
+
|
|
|
+ function init() {
|
|
|
+ grid = Array.from({length: ROWS}, () => Array(COLS).fill(null));
|
|
|
+ score = 0; level = 1; lines = 0;
|
|
|
+ dropInterval = 1000; dropCounter = 0; lastTime = 0;
|
|
|
+ gameOver = false; paused = false;
|
|
|
+ nextType = randomType();
|
|
|
+ spawn();
|
|
|
+ hideOverlay();
|
|
|
+ updateUI();
|
|
|
+ draw();
|
|
|
+ drawNext();
|
|
|
+ }
|
|
|
+
|
|
|
+ function randomType() {
|
|
|
+ return TYPES[Math.floor(Math.random() * TYPES.length)];
|
|
|
+ }
|
|
|
+
|
|
|
+ function spawn() {
|
|
|
+ const type = nextType;
|
|
|
+ nextType = randomType();
|
|
|
+ current = {
|
|
|
+ type,
|
|
|
+ rotation: 0,
|
|
|
+ x: 3,
|
|
|
+ y: -1,
|
|
|
+ };
|
|
|
+ drawNext();
|
|
|
+ if (collides(current, 0, 0)) {
|
|
|
+ // game over
|
|
|
+ gameOver = true;
|
|
|
+ showOverlay('游戏结束', `最终得分: ${score}`, '重新开始');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function collides(piece, dx, dy, rot = piece.rotation) {
|
|
|
+ const cells = SHAPES[piece.type].rotations[rot];
|
|
|
+ for (const [cx, cy] of cells) {
|
|
|
+ const x = piece.x + cx + dx;
|
|
|
+ const y = piece.y + cy + dy;
|
|
|
+ if (x < 0 || x >= COLS || y >= ROWS) return true;
|
|
|
+ if (y >= 0 && grid[y][x]) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ function tryMove(dx, dy) {
|
|
|
+ if (collides(current, dx, dy)) return false;
|
|
|
+ current.x += dx;
|
|
|
+ current.y += dy;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function rotate(dir) {
|
|
|
+ if (current.type === 'O') return;
|
|
|
+ const from = current.rotation;
|
|
|
+ const to = (from + (dir > 0 ? 1 : 3)) % 4;
|
|
|
+ const kickTable = (current.type === 'I') ? KICKS_I : KICKS_JLSTZ;
|
|
|
+ const key = `${from}->${to}`;
|
|
|
+ const kicks = kickTable[key] || [[0,0]];
|
|
|
+ for (const [kx, ky] of kicks) {
|
|
|
+ // For I piece, kicks are applied as offsets; for others, also
|
|
|
+ if (!collides(current, kx, -ky, to)) {
|
|
|
+ current.x += kx;
|
|
|
+ current.y += -ky;
|
|
|
+ current.rotation = to;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function softDrop() {
|
|
|
+ if (tryMove(0, 1)) {
|
|
|
+ score += 1;
|
|
|
+ updateUI();
|
|
|
+ } else {
|
|
|
+ lock();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function hardDrop() {
|
|
|
+ let dist = 0;
|
|
|
+ while (tryMove(0, 1)) dist++;
|
|
|
+ score += dist * 2;
|
|
|
+ lock();
|
|
|
+ }
|
|
|
+
|
|
|
+ function lock() {
|
|
|
+ const cells = SHAPES[current.type].rotations[current.rotation];
|
|
|
+ for (const [cx, cy] of cells) {
|
|
|
+ const x = current.x + cx;
|
|
|
+ const y = current.y + cy;
|
|
|
+ if (y >= 0 && y < ROWS && x >= 0 && x < COLS) {
|
|
|
+ grid[y][x] = SHAPES[current.type].color;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const cleared = clearLines();
|
|
|
+ if (cleared > 0) {
|
|
|
+ const base = [0, 100, 300, 500, 800][cleared];
|
|
|
+ const gained = base * level;
|
|
|
+ score += gained;
|
|
|
+ lines += cleared;
|
|
|
+ showCombo(cleared, gained);
|
|
|
+ // Level up every 10 lines
|
|
|
+ const newLevel = Math.floor(lines / 10) + 1;
|
|
|
+ if (newLevel > level) {
|
|
|
+ level = newLevel;
|
|
|
+ dropInterval = Math.max(80, 1000 - (level - 1) * 80);
|
|
|
+ }
|
|
|
+ updateUI();
|
|
|
+ // Flash effect
|
|
|
+ board.classList.add('flash');
|
|
|
+ setTimeout(() => board.classList.remove('flash'), 400);
|
|
|
+ }
|
|
|
+ spawn();
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearLines() {
|
|
|
+ let count = 0;
|
|
|
+ for (let y = ROWS - 1; y >= 0; y--) {
|
|
|
+ if (grid[y].every(c => c !== null)) {
|
|
|
+ grid.splice(y, 1);
|
|
|
+ grid.unshift(Array(COLS).fill(null));
|
|
|
+ count++;
|
|
|
+ y++; // recheck this row
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ function showCombo(n, gained) {
|
|
|
+ const labels = {1: '单消!', 2: '双消!', 3: '三消!', 4: 'TETRIS!'};
|
|
|
+ const el = document.createElement('div');
|
|
|
+ el.className = 'combo';
|
|
|
+ el.textContent = `${labels[n] || ''} +${gained}`;
|
|
|
+ el.style.left = (board.width / 2 - 60) + 'px';
|
|
|
+ el.style.top = (board.height / 2) + 'px';
|
|
|
+ board.parentElement.appendChild(el);
|
|
|
+ setTimeout(() => el.remove(), 1000);
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateUI() {
|
|
|
+ scoreEl.textContent = score;
|
|
|
+ levelEl.textContent = level;
|
|
|
+ linesEl.textContent = lines;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====== Drawing ======
|
|
|
+ function drawCell(ctx, x, y, color, size = CELL) {
|
|
|
+ const px = x * size, py = y * size;
|
|
|
+ // Glow
|
|
|
+ ctx.fillStyle = color;
|
|
|
+ ctx.shadowColor = color;
|
|
|
+ ctx.shadowBlur = 8;
|
|
|
+ ctx.fillRect(px + 1, py + 1, size - 2, size - 2);
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
+ // Highlight
|
|
|
+ ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
|
|
+ ctx.fillRect(px + 2, py + 2, size - 4, 3);
|
|
|
+ ctx.fillRect(px + 2, py + 2, 3, size - 4);
|
|
|
+ // Shadow
|
|
|
+ ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
|
+ ctx.fillRect(px + 2, py + size - 5, size - 4, 3);
|
|
|
+ ctx.fillRect(px + size - 5, py + 2, 3, size - 4);
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawGrid() {
|
|
|
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
|
|
+ ctx.lineWidth = 1;
|
|
|
+ for (let x = 1; x < COLS; x++) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(x * CELL + 0.5, 0);
|
|
|
+ ctx.lineTo(x * CELL + 0.5, ROWS * CELL);
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ for (let y = 1; y < ROWS; y++) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.moveTo(0, y * CELL + 0.5);
|
|
|
+ ctx.lineTo(COLS * CELL, y * CELL + 0.5);
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawGhost() {
|
|
|
+ if (!current || gameOver) return;
|
|
|
+ const cells = SHAPES[current.type].rotations[current.rotation];
|
|
|
+ let gy = current.y;
|
|
|
+ while (!collides({...current, y: gy + 1}, 0, 0)) gy++;
|
|
|
+ ctx.fillStyle = 'rgba(255,255,255,0.12)';
|
|
|
+ for (const [cx, cy] of cells) {
|
|
|
+ const x = current.x + cx;
|
|
|
+ const y = gy + cy;
|
|
|
+ if (y >= 0) {
|
|
|
+ ctx.fillRect(x * CELL + 2, y * CELL + 2, CELL - 4, CELL - 4);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function draw() {
|
|
|
+ ctx.fillStyle = '#0a0a18';
|
|
|
+ ctx.fillRect(0, 0, board.width, board.height);
|
|
|
+ drawGrid();
|
|
|
+ for (let y = 0; y < ROWS; y++) {
|
|
|
+ for (let x = 0; x < COLS; x++) {
|
|
|
+ if (grid[y][x]) drawCell(ctx, x, y, grid[y][x]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (current && !gameOver) {
|
|
|
+ drawGhost();
|
|
|
+ const cells = SHAPES[current.type].rotations[current.rotation];
|
|
|
+ for (const [cx, cy] of cells) {
|
|
|
+ const x = current.x + cx;
|
|
|
+ const y = current.y + cy;
|
|
|
+ if (y >= 0) drawCell(ctx, x, y, SHAPES[current.type].color);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function drawNext() {
|
|
|
+ nctx.fillStyle = '#0a0a18';
|
|
|
+ nctx.fillRect(0, 0, next.width, next.height);
|
|
|
+ const cells = SHAPES[nextType].rotations[0];
|
|
|
+ const cs = 20;
|
|
|
+ // Find bounds to center
|
|
|
+ let minX = 4, maxX = 0, minY = 4, maxY = 0;
|
|
|
+ for (const [x, y] of cells) {
|
|
|
+ if (x < minX) minX = x;
|
|
|
+ if (x > maxX) maxX = x;
|
|
|
+ if (y < minY) minY = y;
|
|
|
+ if (y > maxY) maxY = y;
|
|
|
+ }
|
|
|
+ const w = (maxX - minX + 1) * cs;
|
|
|
+ const h = (maxY - minY + 1) * cs;
|
|
|
+ const ox = (next.width - w) / 2 - minX * cs;
|
|
|
+ const oy = (next.height - h) / 2 - minY * cs;
|
|
|
+ for (const [x, y] of cells) {
|
|
|
+ nctx.shadowColor = SHAPES[nextType].color;
|
|
|
+ nctx.shadowBlur = 8;
|
|
|
+ nctx.fillStyle = SHAPES[nextType].color;
|
|
|
+ nctx.fillRect(ox + x * cs + 1, oy + y * cs + 1, cs - 2, cs - 2);
|
|
|
+ }
|
|
|
+ nctx.shadowBlur = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====== Overlay ======
|
|
|
+ function showOverlay(title, text, btn) {
|
|
|
+ overlayTitle.textContent = title;
|
|
|
+ overlayText.textContent = text;
|
|
|
+ overlayBtn.textContent = btn;
|
|
|
+ overlay.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ function hideOverlay() {
|
|
|
+ overlay.classList.add('hidden');
|
|
|
+ }
|
|
|
+ overlayBtn.addEventListener('click', () => {
|
|
|
+ if (gameOver) {
|
|
|
+ init();
|
|
|
+ } else {
|
|
|
+ paused = false;
|
|
|
+ hideOverlay();
|
|
|
+ lastTime = performance.now();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ====== Input ======
|
|
|
+ function togglePause() {
|
|
|
+ if (gameOver) return;
|
|
|
+ paused = !paused;
|
|
|
+ if (paused) showOverlay('已暂停', '按 空格 继续', '继续');
|
|
|
+ else { hideOverlay(); lastTime = performance.now(); }
|
|
|
+ }
|
|
|
+
|
|
|
+ function restart() {
|
|
|
+ init();
|
|
|
+ }
|
|
|
+
|
|
|
+ document.addEventListener('keydown', (e) => {
|
|
|
+ if (gameOver && e.key.toLowerCase() !== 'q') return;
|
|
|
+ switch (e.key) {
|
|
|
+ case 'ArrowLeft':
|
|
|
+ if (!paused) tryMove(-1, 0);
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'ArrowRight':
|
|
|
+ if (!paused) tryMove(1, 0);
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'ArrowDown':
|
|
|
+ if (!paused) softDrop();
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'Enter':
|
|
|
+ if (!paused) hardDrop();
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'z': case 'Z':
|
|
|
+ if (!paused) rotate(-1);
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'x': case 'X':
|
|
|
+ if (!paused) rotate(1);
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case ' ':
|
|
|
+ togglePause();
|
|
|
+ e.preventDefault(); break;
|
|
|
+ case 'q': case 'Q':
|
|
|
+ restart();
|
|
|
+ e.preventDefault(); break;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Touch controls
|
|
|
+ const touchHold = { ArrowLeft: false, ArrowRight: false, ArrowDown: false };
|
|
|
+ let holdTimer = null;
|
|
|
+ function pressHold(key, start) {
|
|
|
+ if (gameOver || paused) return;
|
|
|
+ if (start) {
|
|
|
+ touchHold[key] = true;
|
|
|
+ if (holdTimer) clearInterval(holdTimer);
|
|
|
+ holdTimer = setInterval(() => {
|
|
|
+ if (touchHold.ArrowLeft) tryMove(-1, 0);
|
|
|
+ if (touchHold.ArrowRight) tryMove(1, 0);
|
|
|
+ if (touchHold.ArrowDown) softDrop();
|
|
|
+ }, 80);
|
|
|
+ } else {
|
|
|
+ touchHold[key] = false;
|
|
|
+ if (!touchHold.ArrowLeft && !touchHold.ArrowRight && !touchHold.ArrowDown) {
|
|
|
+ clearInterval(holdTimer); holdTimer = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ document.querySelectorAll('#touch button').forEach(btn => {
|
|
|
+ const act = btn.dataset.act;
|
|
|
+ const fire = (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ if (gameOver && act !== 'restart') return;
|
|
|
+ switch (act) {
|
|
|
+ case 'left': if (!paused) tryMove(-1, 0); pressHold('ArrowLeft', true); break;
|
|
|
+ case 'right': if (!paused) tryMove(1, 0); pressHold('ArrowRight', true); break;
|
|
|
+ case 'down': if (!paused) softDrop(); pressHold('ArrowDown', true); break;
|
|
|
+ case 'rotateL': if (!paused) rotate(-1); break;
|
|
|
+ case 'rotateR': if (!paused) rotate(1); break;
|
|
|
+ case 'drop': if (!paused) hardDrop(); break;
|
|
|
+ case 'pause': togglePause(); break;
|
|
|
+ case 'restart': restart(); break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ const release = (e) => {
|
|
|
+ e.preventDefault();
|
|
|
+ const act = btn.dataset.act;
|
|
|
+ if (act === 'left') pressHold('ArrowLeft', false);
|
|
|
+ if (act === 'right') pressHold('ArrowRight', false);
|
|
|
+ if (act === 'down') pressHold('ArrowDown', false);
|
|
|
+ };
|
|
|
+ btn.addEventListener('touchstart', fire, {passive: false});
|
|
|
+ btn.addEventListener('touchend', release, {passive: false});
|
|
|
+ btn.addEventListener('touchcancel', release, {passive: false});
|
|
|
+ btn.addEventListener('mousedown', fire);
|
|
|
+ btn.addEventListener('mouseup', release);
|
|
|
+ btn.addEventListener('mouseleave', release);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ====== Game loop ======
|
|
|
+ function loop(time = 0) {
|
|
|
+ if (!gameOver && !paused) {
|
|
|
+ const delta = time - lastTime;
|
|
|
+ dropCounter += delta;
|
|
|
+ if (dropCounter > dropInterval) {
|
|
|
+ if (!tryMove(0, 1)) lock();
|
|
|
+ dropCounter = 0;
|
|
|
+ }
|
|
|
+ lastTime = time;
|
|
|
+ draw();
|
|
|
+ }
|
|
|
+ requestAnimationFrame(loop);
|
|
|
+ }
|
|
|
+
|
|
|
+ init();
|
|
|
+ requestAnimationFrame(loop);
|
|
|
+})();
|
|
|
+</script>
|
|
|
+
|
|
|
+</body>
|
|
|
+</html>
|