562 lines
17 KiB
HTML
562 lines
17 KiB
HTML
<!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, viewport-fit=cover, shrink-to-fit=yes">
|
|
<title>泡泡射手 · 消除游戏</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
-webkit-tap-highlight-color: transparent;
|
|
user-select: none;
|
|
}
|
|
body {
|
|
background: linear-gradient(180deg, #1a2f4a 0%, #3c5f7c 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
padding: 10px;
|
|
margin: 0;
|
|
}
|
|
.game-container {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
background: radial-gradient(circle at 50% 30%, #5b8cbf, #1e3b4f);
|
|
border-radius: 48px;
|
|
box-shadow: 0 25px 50px rgba(0,0,0,0.6), inset 0 0 25px rgba(255,255,255,0.2);
|
|
padding: 16px 12px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 32px;
|
|
background: radial-gradient(ellipse at 50% 15%, #a3d0ff 0%, #32638a 80%);
|
|
box-shadow: inset 0 -10px 25px rgba(0,20,40,0.7), 0 12px 25px rgba(0,0,0,0.5);
|
|
touch-action: none;
|
|
}
|
|
.info-panel {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 14px;
|
|
padding: 0 10px;
|
|
color: white;
|
|
font-weight: bold;
|
|
text-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
|
font-size: 1.3rem;
|
|
}
|
|
.score-board {
|
|
background: rgba(20, 40, 60, 0.7);
|
|
backdrop-filter: blur(12px);
|
|
border-radius: 50px;
|
|
padding: 8px 22px;
|
|
letter-spacing: 1px;
|
|
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
|
border: 1px solid rgba(255,255,255,0.3);
|
|
}
|
|
.restart-btn {
|
|
background: #ff7b9c;
|
|
border: none;
|
|
color: white;
|
|
font-weight: bold;
|
|
font-size: 1.2rem;
|
|
padding: 10px 25px;
|
|
border-radius: 40px;
|
|
background: linear-gradient(180deg, #ffb3c6 0%, #e85d7a 100%);
|
|
box-shadow: 0 8px 0 #a8324e, 0 12px 22px rgba(0,0,0,0.4);
|
|
transition: all 0.08s ease;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
cursor: pointer;
|
|
border: 1px solid #ffd1dc;
|
|
touch-action: manipulation;
|
|
}
|
|
.restart-btn:active {
|
|
transform: translateY(4px);
|
|
box-shadow: 0 4px 0 #a8324e, 0 8px 14px rgba(0,0,0,0.5);
|
|
background: linear-gradient(180deg, #f595aa 0%, #c94a62 100%);
|
|
}
|
|
.hint {
|
|
color: rgba(255,255,255,0.85);
|
|
font-size: 0.8rem;
|
|
margin-top: 6px;
|
|
text-align: center;
|
|
font-weight: 500;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="game-container">
|
|
<canvas id="gameCanvas" width="360" height="600" aria-label="泡泡射手游戏画面"></canvas>
|
|
<div class="info-panel">
|
|
<div class="score-board" id="scoreDisplay">🫧 0</div>
|
|
<button class="restart-btn" id="restartButton">🔁 新一局</button>
|
|
</div>
|
|
<div class="hint">👆 点击屏幕发射泡泡 | 消除三个同色</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const canvas = document.getElementById('gameCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const scoreSpan = document.getElementById('scoreDisplay');
|
|
|
|
const CW = 360;
|
|
const CH = 600;
|
|
canvas.width = CW;
|
|
canvas.height = CH;
|
|
|
|
// ----- 游戏配置 -----
|
|
const ROWS = 7; // 泡泡网格行数
|
|
const COLS = 8; // 列数
|
|
const BUBBLE_RADIUS = 18; // 泡泡半径
|
|
const CELL_SIZE = BUBBLE_RADIUS * 2 + 2; // 间距
|
|
const GRID_OFFSET_X = (CW - COLS * CELL_SIZE) / 2 + BUBBLE_RADIUS;
|
|
const GRID_OFFSET_Y = 65;
|
|
|
|
// 发射器位置
|
|
const SHOOTER_Y = CH - 50;
|
|
let shooterX = CW / 2;
|
|
|
|
// 颜色池
|
|
const COLORS = ['#FF6B6B', '#4ECDC4', '#FFE66D', '#A37EF0', '#FF8A5C'];
|
|
|
|
// 游戏状态
|
|
let gameActive = true;
|
|
let score = 0;
|
|
let grid = []; // 二维数组存储泡泡颜色(null为空)
|
|
let currentBubbleColor = null;
|
|
let nextBubbleColor = null;
|
|
let projectile = null; // 飞行中的泡泡 { x, y, vx, vy, color }
|
|
|
|
// 触摸追踪
|
|
let aiming = false;
|
|
let aimStartX = 0;
|
|
let aimStartY = 0;
|
|
|
|
// ----- 初始化网格 -----
|
|
function createGrid() {
|
|
grid = [];
|
|
for (let r = 0; r < ROWS; r++) {
|
|
grid[r] = [];
|
|
for (let c = 0; c < COLS; c++) {
|
|
// 偏移行 (交错效果)
|
|
const colOffset = (r % 2 === 1) ? 0 : 0;
|
|
// 只填充前几行
|
|
if (r < 4) {
|
|
const colorIndex = Math.floor(Math.random() * COLORS.length);
|
|
grid[r][c] = COLORS[colorIndex];
|
|
} else {
|
|
grid[r][c] = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 随机颜色
|
|
function randomColor() {
|
|
return COLORS[Math.floor(Math.random() * COLORS.length)];
|
|
}
|
|
|
|
// 重置游戏
|
|
function restartGame() {
|
|
gameActive = true;
|
|
score = 0;
|
|
createGrid();
|
|
currentBubbleColor = randomColor();
|
|
nextBubbleColor = randomColor();
|
|
projectile = null;
|
|
shooterX = CW / 2;
|
|
updateScoreDisplay();
|
|
}
|
|
|
|
function endGame() {
|
|
if (!gameActive) return;
|
|
gameActive = false;
|
|
if (window.navigator && window.navigator.vibrate) {
|
|
window.navigator.vibrate(25);
|
|
}
|
|
}
|
|
|
|
function updateScoreDisplay() {
|
|
scoreSpan.textContent = `🫧 ${score}`;
|
|
}
|
|
|
|
// 根据行列计算泡泡中心坐标
|
|
function getBubblePos(row, col) {
|
|
const x = GRID_OFFSET_X + col * CELL_SIZE + (row % 2 === 1 ? CELL_SIZE/2 : 0);
|
|
const y = GRID_OFFSET_Y + row * CELL_SIZE * 0.88;
|
|
return { x, y };
|
|
}
|
|
|
|
// 查找相邻同色泡泡 (用于消除)
|
|
function findConnected(row, col, color, visited = new Set()) {
|
|
const key = `${row},${col}`;
|
|
if (row < 0 || row >= ROWS || col < 0 || col >= COLS || visited.has(key)) return [];
|
|
if (grid[row][col] !== color) return [];
|
|
|
|
visited.add(key);
|
|
const cluster = [{ row, col }];
|
|
|
|
// 六个方向 (考虑交错行)
|
|
const directionsEven = [
|
|
[-1, -1], [-1, 0], [0, -1], [0, 1], [1, -1], [1, 0]
|
|
];
|
|
const directionsOdd = [
|
|
[-1, 0], [-1, 1], [0, -1], [0, 1], [1, 0], [1, 1]
|
|
];
|
|
const dirs = row % 2 === 0 ? directionsEven : directionsOdd;
|
|
|
|
for (let [dr, dc] of dirs) {
|
|
const nr = row + dr;
|
|
const nc = col + dc;
|
|
cluster.push(...findConnected(nr, nc, color, visited));
|
|
}
|
|
return cluster;
|
|
}
|
|
|
|
// 移除泡泡并更新分数
|
|
function removeCluster(cluster) {
|
|
if (cluster.length < 3) return false;
|
|
for (let { row, col } of cluster) {
|
|
grid[row][col] = null;
|
|
}
|
|
score += cluster.length * 10;
|
|
updateScoreDisplay();
|
|
return true;
|
|
}
|
|
|
|
// 处理悬浮泡泡掉落 (没有连接到顶部的泡泡全部掉落)
|
|
function dropFloatingBubbles() {
|
|
const visited = new Set();
|
|
// 从顶行开始标记所有连接的泡泡
|
|
function markConnected(row, col) {
|
|
const key = `${row},${col}`;
|
|
if (row < 0 || row >= ROWS || col < 0 || col >= COLS || visited.has(key)) return;
|
|
if (grid[row][col] === null) return;
|
|
visited.add(key);
|
|
const dirsEven = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
|
|
const dirsOdd = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];
|
|
const dirs = row % 2 === 0 ? dirsEven : dirsOdd;
|
|
for (let [dr, dc] of dirs) {
|
|
markConnected(row + dr, col + dc);
|
|
}
|
|
}
|
|
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[0][c] !== null) markConnected(0, c);
|
|
}
|
|
|
|
let dropped = 0;
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[r][c] !== null && !visited.has(`${r},${c}`)) {
|
|
grid[r][c] = null;
|
|
dropped++;
|
|
}
|
|
}
|
|
}
|
|
if (dropped > 0) {
|
|
score += dropped * 15;
|
|
updateScoreDisplay();
|
|
}
|
|
}
|
|
|
|
// 检查游戏是否结束 (泡泡到达底部警戒线)
|
|
function checkGameOver() {
|
|
for (let r = ROWS - 1; r >= 0; r--) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[r][c] !== null) {
|
|
const pos = getBubblePos(r, c);
|
|
if (pos.y + BUBBLE_RADIUS > SHOOTER_Y - 15) {
|
|
endGame();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 发射泡泡
|
|
function shootBubble(targetX, targetY) {
|
|
if (!gameActive || projectile) return;
|
|
if (!currentBubbleColor) return;
|
|
|
|
const dx = targetX - shooterX;
|
|
const dy = targetY - SHOOTER_Y;
|
|
const length = Math.sqrt(dx * dx + dy * dy);
|
|
if (length < 5) return; // 太近不发射
|
|
|
|
const speed = 9.5;
|
|
const vx = (dx / length) * speed;
|
|
const vy = (dy / length) * speed;
|
|
|
|
projectile = {
|
|
x: shooterX,
|
|
y: SHOOTER_Y,
|
|
vx: vx,
|
|
vy: vy,
|
|
color: currentBubbleColor,
|
|
radius: BUBBLE_RADIUS
|
|
};
|
|
|
|
currentBubbleColor = nextBubbleColor;
|
|
nextBubbleColor = randomColor();
|
|
}
|
|
|
|
// 更新弹丸位置与碰撞
|
|
function updateProjectile() {
|
|
if (!projectile || !gameActive) return;
|
|
|
|
projectile.x += projectile.vx;
|
|
projectile.y += projectile.vy;
|
|
|
|
// 边界反弹
|
|
if (projectile.x - BUBBLE_RADIUS < 0) {
|
|
projectile.x = BUBBLE_RADIUS;
|
|
projectile.vx *= -0.9;
|
|
}
|
|
if (projectile.x + BUBBLE_RADIUS > CW) {
|
|
projectile.x = CW - BUBBLE_RADIUS;
|
|
projectile.vx *= -0.9;
|
|
}
|
|
|
|
// 顶部碰撞
|
|
if (projectile.y - BUBBLE_RADIUS < GRID_OFFSET_Y - BUBBLE_RADIUS) {
|
|
placeProjectile();
|
|
return;
|
|
}
|
|
|
|
// 与网格泡泡碰撞检测
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[r][c] === null) continue;
|
|
const pos = getBubblePos(r, c);
|
|
const dist = Math.hypot(projectile.x - pos.x, projectile.y - pos.y);
|
|
if (dist < BUBBLE_RADIUS * 1.9) {
|
|
placeProjectile();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 掉出底部 (失败情况,但不直接结束,只是移除弹丸)
|
|
if (projectile.y > CH + 50) {
|
|
projectile = null;
|
|
}
|
|
}
|
|
|
|
function placeProjectile() {
|
|
if (!projectile) return;
|
|
|
|
// 找到最近的网格位置
|
|
let bestRow = -1, bestCol = -1, bestDist = Infinity;
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[r][c] !== null) continue;
|
|
const pos = getBubblePos(r, c);
|
|
const dist = Math.hypot(projectile.x - pos.x, projectile.y - pos.y);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestRow = r;
|
|
bestCol = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestRow !== -1 && bestCol !== -1 && bestDist < CELL_SIZE * 1.2) {
|
|
grid[bestRow][bestCol] = projectile.color;
|
|
|
|
// 检查消除
|
|
const cluster = findConnected(bestRow, bestCol, projectile.color);
|
|
if (cluster.length >= 3) {
|
|
removeCluster(cluster);
|
|
dropFloatingBubbles();
|
|
}
|
|
|
|
checkGameOver();
|
|
}
|
|
|
|
projectile = null;
|
|
}
|
|
|
|
// ----- 绘制 -----
|
|
function draw() {
|
|
ctx.clearRect(0, 0, CW, CH);
|
|
|
|
// 绘制网格泡泡
|
|
for (let r = 0; r < ROWS; r++) {
|
|
for (let c = 0; c < COLS; c++) {
|
|
if (grid[r][c] === null) continue;
|
|
const { x, y } = getBubblePos(r, c);
|
|
drawBubble(x, y, grid[r][c]);
|
|
}
|
|
}
|
|
|
|
// 绘制飞行弹丸
|
|
if (projectile) {
|
|
drawBubble(projectile.x, projectile.y, projectile.color);
|
|
}
|
|
|
|
// 绘制发射器
|
|
ctx.save();
|
|
ctx.shadowColor = '#00000060';
|
|
ctx.shadowBlur = 12;
|
|
// 发射台
|
|
ctx.fillStyle = '#445566';
|
|
ctx.beginPath();
|
|
ctx.arc(shooterX, SHOOTER_Y, 14, 0, Math.PI*2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#2c3e50';
|
|
ctx.beginPath();
|
|
ctx.arc(shooterX, SHOOTER_Y, 10, 0, Math.PI*2);
|
|
ctx.fill();
|
|
|
|
// 当前泡泡预览
|
|
if (currentBubbleColor) {
|
|
drawBubble(shooterX, SHOOTER_Y - 2, currentBubbleColor);
|
|
}
|
|
|
|
// 下一个泡泡预览
|
|
if (nextBubbleColor) {
|
|
ctx.fillStyle = '#ffffff30';
|
|
ctx.beginPath();
|
|
ctx.arc(shooterX + 38, SHOOTER_Y - 8, 14, 0, Math.PI*2);
|
|
ctx.fill();
|
|
drawBubble(shooterX + 38, SHOOTER_Y - 8, nextBubbleColor, 12);
|
|
}
|
|
ctx.restore();
|
|
|
|
// 引导线
|
|
if (aiming && gameActive && !projectile) {
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#ffffff80';
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([6, 8]);
|
|
ctx.moveTo(shooterX, SHOOTER_Y);
|
|
ctx.lineTo(aimStartX, aimStartY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// 游戏结束
|
|
if (!gameActive) {
|
|
ctx.globalAlpha = 0.75;
|
|
ctx.fillStyle = '#0a1a2a';
|
|
ctx.fillRect(0, 0, CW, CH);
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = 'white';
|
|
ctx.font = 'bold 28px "Segoe UI"';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('🫧 结束', CW/2, CH/2-20);
|
|
}
|
|
}
|
|
|
|
function drawBubble(x, y, color, radius = BUBBLE_RADIUS) {
|
|
ctx.save();
|
|
ctx.shadowColor = '#00000040';
|
|
ctx.shadowBlur = 8;
|
|
const grad = ctx.createRadialGradient(x-4, y-4, radius*0.1, x, y, radius);
|
|
grad.addColorStop(0, '#ffffff');
|
|
grad.addColorStop(0.4, color);
|
|
grad.addColorStop(1, '#00000040');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI*2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#ffffff90';
|
|
ctx.beginPath();
|
|
ctx.arc(x-3, y-4, radius*0.3, 0, Math.PI*2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
// ----- 游戏循环 -----
|
|
function gameLoop() {
|
|
if (gameActive) {
|
|
updateProjectile();
|
|
}
|
|
draw();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
// ----- 事件处理 -----
|
|
function handleStart(e) {
|
|
e.preventDefault();
|
|
if (!gameActive) return;
|
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = CW / rect.width;
|
|
const scaleY = CH / rect.height;
|
|
const canvasX = (clientX - rect.left) * scaleX;
|
|
const canvasY = (clientY - rect.top) * scaleY;
|
|
|
|
// 如果点击靠近发射器区域,开始瞄准
|
|
if (Math.abs(canvasY - SHOOTER_Y) < 60 && !projectile) {
|
|
aiming = true;
|
|
aimStartX = canvasX;
|
|
aimStartY = canvasY;
|
|
}
|
|
}
|
|
|
|
function handleMove(e) {
|
|
e.preventDefault();
|
|
if (!aiming || !gameActive) return;
|
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = CW / rect.width;
|
|
const scaleY = CH / rect.height;
|
|
aimStartX = (clientX - rect.left) * scaleX;
|
|
aimStartY = (clientY - rect.top) * scaleY;
|
|
}
|
|
|
|
function handleEnd(e) {
|
|
e.preventDefault();
|
|
if (aiming && gameActive && !projectile) {
|
|
shootBubble(aimStartX, aimStartY);
|
|
}
|
|
aiming = false;
|
|
}
|
|
|
|
function bindEvents() {
|
|
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
|
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
|
canvas.addEventListener('touchend', handleEnd);
|
|
canvas.addEventListener('touchcancel', handleEnd);
|
|
canvas.addEventListener('mousedown', handleStart);
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (aiming && gameActive) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = CW / rect.width;
|
|
const scaleY = CH / rect.height;
|
|
aimStartX = (e.clientX - rect.left) * scaleX;
|
|
aimStartY = (e.clientY - rect.top) * scaleY;
|
|
}
|
|
});
|
|
window.addEventListener('mouseup', handleEnd);
|
|
document.getElementById('restartButton').addEventListener('click', restartGame);
|
|
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
restartGame();
|
|
});
|
|
}
|
|
|
|
bindEvents();
|
|
restartGame();
|
|
gameLoop();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html> |