Files
blackbird/static/public/games/mobile3.html
T
2026-07-23 11:30:26 +08:00

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>