8x12 棋盘合成小游戏,不同颜色方块代表不同等级 - 点击选中方块,点击相邻同等级方块合成升级 - 自动生成新方块,支持链式合成 - Cocos Creator 3.x 项目 + 独立 HTML 可玩版本 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
449 lines
12 KiB
HTML
449 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>合成大作战</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-user-select: none; }
|
|
body {
|
|
background: #191923;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100dvh;
|
|
font-family: 'Segoe UI', 'PingFang SC', sans-serif;
|
|
touch-action: manipulation;
|
|
}
|
|
#gameContainer {
|
|
position: relative;
|
|
width: 480px;
|
|
max-width: 100vw;
|
|
aspect-ratio: 480 / 860;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 12px;
|
|
background: #191923;
|
|
touch-action: none;
|
|
}
|
|
@media (max-width: 500px) {
|
|
#gameContainer { width: 100vw; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="gameContainer">
|
|
<canvas id="gameCanvas"></canvas>
|
|
</div>
|
|
<script>
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// ============ 配置 ============
|
|
const ROWS = 12;
|
|
const COLS = 8;
|
|
const CELL = 52;
|
|
const GAP = 6;
|
|
const PAD = 10;
|
|
const BOARD_W = COLS * (CELL + GAP) + GAP;
|
|
const BOARD_H = ROWS * (CELL + GAP) + GAP;
|
|
const UI_TOP = 80;
|
|
const CANVAS_W = 480;
|
|
const CANVAS_H = 860;
|
|
|
|
canvas.width = CANVAS_W;
|
|
canvas.height = CANVAS_H;
|
|
|
|
const BLOCK_COLORS = [
|
|
'#A8E6CF', // Lv1 草
|
|
'#4CAF50', // Lv2 木
|
|
'#2196F3', // Lv3 水
|
|
'#9C27B0', // Lv4 风
|
|
'#FF9800', // Lv5 火
|
|
'#F44336', // Lv6 雷
|
|
'#E91E63', // Lv7 光
|
|
'#FFD700', // Lv8 神
|
|
];
|
|
|
|
const LEVEL_NAMES = ['草','木','水','风','火','雷','光','神'];
|
|
const SPAWN_INTERVAL = 3500; // ms
|
|
const COLORS_TEXT = '#ffffff';
|
|
const COLORS_BG = '#191923';
|
|
|
|
// ============ 游戏状态 ============
|
|
let grid = [];
|
|
let score = 0;
|
|
let gameOver = false;
|
|
let selected = null; // {row, col}
|
|
let lastSpawn = 0;
|
|
let animating = false;
|
|
let boardX, boardY;
|
|
|
|
function initBoard() {
|
|
grid = [];
|
|
for (let r = 0; r < ROWS; r++) {
|
|
grid[r] = [];
|
|
for (let c = 0; c < COLS; c++) {
|
|
grid[r][c] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ 渲染 ============
|
|
function resize() {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
boardX = (CANVAS_W - BOARD_W) / 2;
|
|
boardY = UI_TOP;
|
|
}
|
|
|
|
function draw() {
|
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// 背景装饰
|
|
ctx.fillStyle = '#1a1a2e';
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// 顶栏背景
|
|
ctx.fillStyle = '#2a2a3e';
|
|
ctx.beginPath();
|
|
roundRect(ctx, boardX - 10, 8, BOARD_W + 20, 60, 10);
|
|
ctx.fill();
|
|
|
|
// 标题
|
|
ctx.fillStyle = '#888';
|
|
ctx.font = '16px "PingFang SC", sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('合成大作战', CANVAS_W / 2, 28);
|
|
|
|
// 分数
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 26px "PingFang SC", sans-serif';
|
|
ctx.fillText(`得分: ${score}`, CANVAS_W / 2, 56);
|
|
|
|
// 棋盘背景
|
|
ctx.fillStyle = '#22223a';
|
|
ctx.beginPath();
|
|
roundRect(ctx, boardX - 6, boardY - 6, BOARD_W + 12, BOARD_H + 12, 8);
|
|
ctx.fill();
|
|
|
|
// 格子背景
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
const x = boardX + GAP + c * (CELL + GAP);
|
|
const y = boardY + GAP + r * (CELL + GAP);
|
|
ctx.fillStyle = '#2a2a42';
|
|
ctx.beginPath();
|
|
roundRect(ctx, x, y, CELL, CELL, 6);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// 方块
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
const block = grid[r][c];
|
|
if (block) drawBlock(r, c, block);
|
|
}
|
|
}
|
|
|
|
// 选中高亮
|
|
if (selected) {
|
|
const { row, col } = selected;
|
|
const x = boardX + GAP + col * (CELL + GAP);
|
|
const y = boardY + GAP + row * (CELL + GAP);
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
roundRect(ctx, x - 2, y - 2, CELL + 4, CELL + 4, 8);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 游戏结束遮罩
|
|
if (gameOver) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
ctx.fillStyle = '#ffc832';
|
|
ctx.font = 'bold 40px "PingFang SC", sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('游戏结束', CANVAS_W / 2, CANVAS_H / 2 - 60);
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '28px "PingFang SC", sans-serif';
|
|
ctx.fillText(`最终得分: ${score}`, CANVAS_W / 2, CANVAS_H / 2);
|
|
|
|
// 重新开始按钮
|
|
const bx = CANVAS_W / 2 - 90, by = CANVAS_H / 2 + 50, bw = 180, bh = 54;
|
|
ctx.fillStyle = '#4CAF50';
|
|
ctx.beginPath();
|
|
roundRect(ctx, bx, by, bw, bh, 12);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '22px "PingFang SC", sans-serif';
|
|
ctx.fillText('重新开始', CANVAS_W / 2, by + bh / 2);
|
|
}
|
|
}
|
|
|
|
function roundRect(ctx, x, y, w, h, r) {
|
|
ctx.moveTo(x + r, y);
|
|
ctx.lineTo(x + w - r, y);
|
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
ctx.lineTo(x + w, y + h - r);
|
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
ctx.lineTo(x + r, y + h);
|
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
ctx.lineTo(x, y + r);
|
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
function drawBlock(r, c, block) {
|
|
const x = boardX + GAP + c * (CELL + GAP);
|
|
const y = boardY + GAP + r * (CELL + GAP);
|
|
const lvl = block.level;
|
|
const color = BLOCK_COLORS[Math.min(lvl - 1, BLOCK_COLORS.length - 1)];
|
|
|
|
// 方块主体
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
roundRect(ctx, x, y, CELL, CELL, 8);
|
|
ctx.fill();
|
|
|
|
// 高光
|
|
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
|
ctx.beginPath();
|
|
roundRect(ctx, x + 4, y + 4, CELL - 8, CELL / 3, 6);
|
|
ctx.fill();
|
|
|
|
// 等级文字
|
|
ctx.fillStyle = lvl >= 7 ? '#333' : '#fff';
|
|
ctx.font = `bold ${20 + lvl}px "PingFang SC", sans-serif`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(`${lvl}`, x + CELL / 2, y + CELL / 2 - 4);
|
|
|
|
// 等级名称小字
|
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
|
ctx.font = '10px "PingFang SC", sans-serif';
|
|
const name = LEVEL_NAMES[Math.min(lvl - 1, LEVEL_NAMES.length - 1)];
|
|
ctx.fillText(name, x + CELL / 2, y + CELL / 2 + 16);
|
|
}
|
|
|
|
// ============ 游戏逻辑 ============
|
|
function getRandomLevel() {
|
|
const roll = Math.random();
|
|
if (roll < 0.45) return 1;
|
|
if (roll < 0.75) return 2;
|
|
if (roll < 0.90) return 3;
|
|
if (roll < 0.97) return 4;
|
|
return 5;
|
|
}
|
|
|
|
function findEmptyCells() {
|
|
const cells = [];
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (!grid[r][c]) cells.push({row: r, col: c});
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
function spawnBlock() {
|
|
const empty = findEmptyCells();
|
|
if (empty.length === 0) {
|
|
gameOver = true;
|
|
return null;
|
|
}
|
|
const cell = empty[Math.floor(Math.random() * empty.length)];
|
|
grid[cell.row][cell.col] = { level: getRandomLevel() };
|
|
return cell;
|
|
}
|
|
|
|
function isAdjacent(r1, c1, r2, c2) {
|
|
const dr = Math.abs(r1 - r2);
|
|
const dc = Math.abs(c1 - c2);
|
|
return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
|
|
}
|
|
|
|
function getAdjacentBlocks(row, col) {
|
|
const result = [];
|
|
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
|
|
for (const [dr, dc] of dirs) {
|
|
const r = row + dr, c = col + dc;
|
|
if (r >= 0 && r < ROWS && c >= 0 && c < COLS && grid[r][c]) {
|
|
result.push({row: r, col: c, block: grid[r][c]});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function doMerge(r1, c1, r2, c2) {
|
|
const lvl = Math.min(grid[r1][c1].level + 1, 8);
|
|
score += lvl * 10;
|
|
grid[r2][c2] = null;
|
|
grid[r1][c1] = { level: lvl };
|
|
selected = null;
|
|
|
|
// 链式合并检测
|
|
setTimeout(() => {
|
|
const adj = getAdjacentBlocks(r1, c1);
|
|
for (const a of adj) {
|
|
if (a.block.level === lvl) {
|
|
doMerge(r1, c1, a.row, a.col);
|
|
return;
|
|
}
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
function tryMerge(r1, c1, r2, c2) {
|
|
if (!grid[r1][c1] || !grid[r2][c2]) return false;
|
|
if (grid[r1][c1].level !== grid[r2][c2].level) return false;
|
|
if (!isAdjacent(r1, c1, r2, c2)) return false;
|
|
doMerge(r1, c1, r2, c2);
|
|
return true;
|
|
}
|
|
|
|
function moveBlock(fromR, fromC, toR, toC) {
|
|
if (grid[toR][toC]) return false;
|
|
grid[toR][toC] = grid[fromR][fromC];
|
|
grid[fromR][fromC] = null;
|
|
return true;
|
|
}
|
|
|
|
// ============ 输入处理 ============
|
|
function getGridPos(clientX, clientY) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const mx = (clientX - rect.left) * scaleX;
|
|
const my = (clientY - rect.top) * scaleY;
|
|
|
|
const gx = mx - boardX;
|
|
const gy = my - boardY;
|
|
const col = Math.floor((gx - GAP) / (CELL + GAP));
|
|
const row = Math.floor((gy - GAP) / (CELL + GAP));
|
|
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
|
|
return {row, col};
|
|
}
|
|
|
|
function isRestartHit(clientX, clientY) {
|
|
if (!gameOver) return false;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const mx = (clientX - rect.left) * scaleX;
|
|
const my = (clientY - rect.top) * scaleY;
|
|
const bx = CANVAS_W / 2 - 90;
|
|
const by = CANVAS_H / 2 + 50;
|
|
const bw = 180, bh = 54;
|
|
return mx >= bx && mx <= bx + bw && my >= by && my <= by + bh;
|
|
}
|
|
|
|
function handleTap(clientX, clientY) {
|
|
if (gameOver) {
|
|
if (isRestartHit(clientX, clientY)) {
|
|
restartGame();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pos = getGridPos(clientX, clientY);
|
|
if (!pos) { selected = null; return; }
|
|
|
|
const {row, col} = pos;
|
|
const tapped = grid[row][col];
|
|
|
|
if (tapped) {
|
|
if (selected) {
|
|
const s = selected;
|
|
if (tryMerge(s.row, s.col, row, col)) return;
|
|
selected = {row, col};
|
|
} else {
|
|
selected = {row, col};
|
|
}
|
|
} else {
|
|
if (selected) {
|
|
const s = selected;
|
|
if (s.row !== row || s.col !== col) {
|
|
// 尝试移动
|
|
if (isAdjacent(s.row, s.col, row, col)) {
|
|
if (moveBlock(s.row, s.col, row, col)) {
|
|
selected = null;
|
|
// 检查移动后是否有合并
|
|
setTimeout(() => {
|
|
const adj = getAdjacentBlocks(row, col);
|
|
for (const a of adj) {
|
|
if (a.block.level === grid[row][col].level) {
|
|
tryMerge(row, col, a.row, a.col);
|
|
return;
|
|
}
|
|
}
|
|
}, 150);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
selected = null;
|
|
} else {
|
|
selected = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ 游戏循环 ============
|
|
let lastTime = 0;
|
|
|
|
function gameLoop(timestamp) {
|
|
if (!lastTime) lastTime = timestamp;
|
|
|
|
if (!gameOver) {
|
|
if (timestamp - lastSpawn > SPAWN_INTERVAL) {
|
|
lastSpawn = timestamp;
|
|
spawnBlock();
|
|
}
|
|
}
|
|
|
|
draw();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
function restartGame() {
|
|
initBoard();
|
|
score = 0;
|
|
gameOver = false;
|
|
selected = null;
|
|
lastSpawn = performance.now();
|
|
// 初始生成3个
|
|
for (let i = 0; i < 3; i++) spawnBlock();
|
|
}
|
|
|
|
// ============ 事件绑定 ============
|
|
canvas.addEventListener('click', (e) => {
|
|
handleTap(e.clientX, e.clientY);
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
const touch = e.changedTouches[0];
|
|
handleTap(touch.clientX, touch.clientY);
|
|
}, {passive: false});
|
|
|
|
// ============ 启动 ============
|
|
resize();
|
|
initBoard();
|
|
lastSpawn = performance.now();
|
|
for (let i = 0; i < 3; i++) spawnBlock();
|
|
requestAnimationFrame(gameLoop);
|
|
</script>
|
|
</body>
|
|
</html>
|