DeepSeek生成的HTML5小游戏 -- 黑8台球

黑8台球

🎯 核心功能

  1. 台球对战 - 玩家控制白球击打黑8目标球

  2. 瞄准系统 - 通过拖拽拉远调整力度和方向,松开击球

  3. 物理引擎 - 包含球体碰撞、边界反弹、摩擦力等真实物理效果

  4. 袋口检测 - 6个袋口,球落入即判定

🎮 游戏规则

  • 胜利条件:黑8落袋且白球未同时落袋

  • 杆数统计:记录每次击球次数

✨ 视觉特效

  • 胜利特效:彩色香花从上方飘落(雨降型粒子)

  • 黑8落袋特效:从袋口迸发彩色花瓣(迸发型粒子)

  • 瞄准辅助:显示力度环、瞄准线和碰撞预测点

🔊 音效系统

  • 背景音乐(循环播放)

  • 黑8落袋音效

  • 胜利音效

  • 支持静音切换

🎛️ 界面功能

  • 左上角"更多游戏"链接

  • 实时显示当前杆数

  • 力度条显示当前击球力度

  • 重开/继续下一局按钮

  • 白球落袋犯规提示

📱 交互优化

  • 支持鼠标和触摸操作

  • 响应式设计,适配移动端

  • 击球后自动等待球静止

  • 胜利后3秒自动进入下一局

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
    <title>Black 8 · One Shot Pocket</title>
    <style>
        * {
            box-sizing: border-box;
            -webkit-tap-highlight-color: transparent;
            user-select: none;
        }
        html, body {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
            overflow: hidden;
        }
        body {
            background: url('https://amitofoicu.github.io/home/lianchi6.jpg') no-repeat center center fixed;
            background-size: cover;
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
            position: relative;
        }
        /* loading overlay */
        .loading-overlay {
            position: fixed;
            top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(31, 34, 51, 0.95);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 9999;
            transition: opacity 0.5s ease;
            font-size: 4vmin;
            color: #ffd966;
            backdrop-filter: blur(5px);
        }
        .spinner {
            width: 10vmin;
            height: 10vmin;
            border: 1vmin solid rgba(255,209,102,0.3);
            border-top-color: #ffd166;
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin-bottom: 2vmin;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
        .loading-text {
            font-size: 4vmin;
            margin-top: 2vmin;
        }
        
        .game-container {
            background: rgba(61, 43, 26, 0.85);
            backdrop-filter: blur(5px);
            padding: 1.5vmin 2vmin 2vmin 2vmin;
            border-radius: 4vmin;
            box-shadow: 0 20px 30px rgba(0,0,0,0.6), inset 2px 2px 8px #b87c4b;
            border: 2px solid #aa6e3a;
            position: relative;
            width: 95%;
            max-width: 820px;
            margin: 0 auto;
        }
        .game-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 1vmin;
        }
        .header-logo {
            height: 7vmin;
            width: auto;
            border-radius: 1vmin;
            border: 0.2vmin solid rgba(255,209,102,0.4);
            background: rgba(0,0,0,0.2);
        }
        .game-title {
            text-align: center;
            color: #ffd966;
            font-size: 5vmin;
            font-weight: bold;
            text-shadow: 3px 3px 0 #4f3a1e;
            letter-spacing: 2px;
            flex: 1;
        }
        .more-link {
            color: #ffd966;
            font-size: 4vmin;
            font-weight: bold;
            text-decoration: none;
            padding: 1vmin 2vmin;
            background: rgba(0,0,0,0.3);
            border-radius: 2vmin;
            border: 0.2vmin solid #ffd966;
            white-space: nowrap;
            transition: all 0.2s ease;
        }
        .more-link:hover {
            background: rgba(255,209,102,0.2);
            transform: scale(1.05);
        }
        .more-link:active {
            transform: scale(0.95);
        }
        canvas {
            display: block;
            width: 100%;
            height: auto;
            border-radius: 2.5vmin;
            background: #1e3b2a;
            box-shadow: inset 0 0 0 2px #7b5a3c, 0 10px 15px rgba(0,0,0,0.5);
            touch-action: none;
            cursor: crosshair;
        }
        .status-bar {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 2vmin 1vmin 1vmin 1vmin;
            color: #f7e9c3;
            text-shadow: 2px 2px 0 #4f3a1e;
            font-weight: bold;
            font-size: 5vmin;
        }
        .stroke-box {
            background: #2f4d2e;
            padding: 1vmin 5vmin;
            border-radius: 10vmin;
            border: 2px solid #dbb062;
            box-shadow: inset 0 2px 5px #0f2b0e;
            letter-spacing: 2px;
        }
        .bottom-panel {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 1vmin 1vmin 0 1vmin;
            gap: 2vmin;
            flex-wrap: wrap;
        }
        .power-meter {
            background: #5b4330;
            padding: 1vmin 3vmin;
            border-radius: 8vmin;
            border: 2px solid #edc27a;
            display: flex;
            align-items: center;
            gap: 2vmin;
            color: #ffdd99;
            font-size: 4vmin;
            font-weight: bold;
            flex: 1;
            min-width: 200px;
        }
        .bar-bg {
            width: 100%;
            height: 4vmin;
            background: #2a1e12;
            border-radius: 2vmin;
            border: 1px solid #ac8b5b;
            overflow: hidden;
        }
        .bar-fill {
            width: 20%;
            height: 100%;
            background: linear-gradient(90deg, #f9b81b, #f55d3e);
            transition: width 0.03s;
        }
        .button-group {
            display: flex;
            align-items: center;
            gap: 1.5vmin;
            flex-shrink: 0;
        }
        .action-btn {
            background: #3d5c3a;
            border: 2px solid #e3b87c;
            color: #ffefc0;
            font-size: 3.5vmin;
            padding: 0.8vmin 2.5vmin;
            border-radius: 5vmin;
            font-weight: bold;
            box-shadow: 0 3px 0 #1d2e1b;
            cursor: pointer;
            white-space: nowrap;
            min-width: 10vmin;
            text-align: center;
            transition: all 0.1s ease;
        }
        .action-btn:active {
            transform: translateY(3px);
            box-shadow: 0 1px 0 #1d2e1b;
        }
        .action-btn.mute {
            background: #5b4330;
            border-color: #edc27a;
            min-width: 8vmin;
            padding: 0.8vmin 1.5vmin;
        }
        .action-btn.next {
            background: #4a7a4a;
            border-color: #ffd966;
        }
        .hint {
            color: #ffd966;
            font-size: 3.2vmin;
            padding: 1vmin;
            background: #2d4a2d;
            border-radius: 4vmin;
            margin: 1vmin 0;
            text-align: center;
            border: 1px solid #b88c4a;
        }
        .white-foul {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.8);
            color: #ff4444;
            font-size: 6vmin;
            font-weight: bold;
            padding: 2vmin 4vmin;
            border-radius: 4vmin;
            border: 2px solid #ffaa00;
            z-index: 10;
            text-shadow: 2px 2px 0 #000;
            box-shadow: 0 0 30px rgba(255,0,0,0.5);
            animation: fadeOut 1.5s forwards;
            pointer-events: none;
            white-space: nowrap;
        }
        @keyframes fadeOut {
            0% { opacity: 1; }
            70% { opacity: 1; }
            100% { opacity: 0; }
        }
        @media (max-width: 600px) {
            .status-bar { font-size: 6vmin; }
            .stroke-box { padding: 0.8vmin 4vmin; }
            .action-btn { font-size: 4vmin; padding: 0.6vmin 2vmin; }
            .bottom-panel { gap: 1vmin; }
            .power-meter { font-size: 3.5vmin; padding: 0.8vmin 2vmin; }
        }
    </style>
</head>
<body>

<!-- loading overlay -->
<div class="loading-overlay" id="loadingOverlay">
    <div class="spinner"></div>
    <div class="loading-text">Loading Game...</div>
</div>

<div class="game-container">
    <div class="game-header">
        <img src="https://amitofoicu.github.io/home/logo.jpg" alt="Logo" class="header-logo">
        <div class="game-title">🎱 Black 8 Billiard</div>
        <a href="https://amitofoicu.github.io/home/main.html" class="more-link" target="_blank" rel="noopener noreferrer">More..</a>
    </div>
    <canvas id="poolCanvas" width="800" height="450"></canvas>
    
    <div class="hint">
        👆 Drag cue ball · Pull back for power · Release to shoot
    </div>

    <div class="status-bar">
        <span>🎱 Strokes</span>
        <span class="stroke-box" id="strokeDisplay">0</span>
    </div>

    <div class="bottom-panel">
        <div class="power-meter">
            <span>💪</span>
            <div class="bar-bg"><div class="bar-fill" id="powerFill" style="width: 20%;"></div></div>
        </div>
        <div class="button-group">
            <button class="action-btn mute" id="muteBtn">🔊</button>
            <button class="action-btn" id="restartBtn">🔄 New</button>
            <button class="action-btn next" id="nextBtn" style="display: none;">✨ Next</button>
        </div>
    </div>
</div>

<!-- white ball foul message -->
<div id="foulMessage" style="display: none;" class="white-foul">⚪ CUE BALL FOUL ⚪</div>

<script>
    (function() {
        // ----- canvas and context -----
        const canvas = document.getElementById('poolCanvas');
        const ctx = canvas.getContext('2d');
        const strokeSpan = document.getElementById('strokeDisplay');
        const powerFill = document.getElementById('powerFill');
        const nextBtn = document.getElementById('nextBtn');
        const restartBtn = document.getElementById('restartBtn');
        const muteBtn = document.getElementById('muteBtn');
        const foulMessage = document.getElementById('foulMessage');
        const loadingOverlay = document.getElementById('loadingOverlay');

        // ----- audio management (fixed - plays after first shot) -----
        let isMuted = false;
        let userInteracted = false;
        let hasShot = false; // flag for first shot
        
        // audio elements
        let bgmAudio = null;
        let winAudio = null;
        let xiaochuAudio = null;

        // initialize audio
        function initAudio() {
            console.log('Initializing audio...');
            
            // background music - OGG format
            bgmAudio = new Audio('https://amitofoicu.github.io/home/beijing.ogg');
            bgmAudio.loop = true;
            bgmAudio.volume = 1.0;
            
            // iOS required attributes
            bgmAudio.setAttribute('playsinline', '');
            bgmAudio.setAttribute('webkit-playsinline', '');
            
            // preload but don't autoplay
            bgmAudio.load();
            
            // win sound effect
            winAudio = new Audio('https://amitofoicu.github.io/home/win.mp3');
            winAudio.volume = 0.7;
            winAudio.setAttribute('playsinline', '');
            winAudio.setAttribute('webkit-playsinline', '');
            winAudio.load();
            
            // clear sound effect
            xiaochuAudio = new Audio('https://amitofoicu.github.io/home/xiaochu.mp3');
            xiaochuAudio.volume = 1.0;
            xiaochuAudio.setAttribute('playsinline', '');
            xiaochuAudio.setAttribute('webkit-playsinline', '');
            xiaochuAudio.load();
            
            // set mute button initial state
            muteBtn.textContent = isMuted ? '🔇' : '🔊';
            
            // hide loading overlay
            setTimeout(() => {
                loadingOverlay.style.opacity = '0';
                setTimeout(() => {
                    loadingOverlay.style.display = 'none';
                }, 500);
            }, 1000);
            
            console.log('Audio initialized');
        }

        // play background music (only after first shot)
        function playBGM() {
            if (!hasShot || isMuted || !bgmAudio) return; // only play after first shot
            
            try {
                // don't restart if already playing
                if (!bgmAudio.paused) return;
                
                bgmAudio.currentTime = 0;
                const playPromise = bgmAudio.play();
                if (playPromise !== undefined) {
                    playPromise.catch(e => {
                        console.log('BGM play failed:', e);
                    });
                }
            } catch (e) {
                console.log('BGM error:', e);
            }
        }

        // stop background music
        function stopBGM() {
            if (!bgmAudio) return;
            try {
                bgmAudio.pause();
                bgmAudio.currentTime = 0;
            } catch (e) {
                console.log('Stop BGM error:', e);
            }
        }

        // play sound effect (clone to bypass iOS restrictions)
        function playSound(audio) {
            if (!userInteracted || isMuted || !audio) return;
            
            try {
                // clone node to allow multiple simultaneous sounds on iOS
                const clone = audio.cloneNode();
                clone.volume = audio.volume;
                
                const playPromise = clone.play();
                if (playPromise !== undefined) {
                    playPromise.catch(e => {
                        console.log('Sound play failed:', e);
                    });
                }
                
                // clean up clone
                setTimeout(() => {
                    clone.pause();
                    clone.src = '';
                }, 2000);
            } catch (e) {
                console.log('Sound error:', e);
            }
        }

        // play win sound
        function playWinSound() {
            playSound(winAudio);
        }

        // play elimination sound
        function playXiaochuSound() {
            playSound(xiaochuAudio);
        }

        // toggle mute
        function toggleMute() {
            isMuted = !isMuted;
            muteBtn.textContent = isMuted ? '🔇' : '🔊';
            
            if (isMuted) {
                stopBGM();
            } else {
                if (hasShot) { // only resume if already shot
                    playBGM();
                }
            }
        }

        // handle user interaction
        function handleUserInteraction() {
            if (!userInteracted) {
                userInteracted = true;
            }
        }

        // ----- constants -----
        const CW = 800;
        const CH = 450;
        const LEFT_WALL = 40;
        const RIGHT_WALL = 760;
        const TOP_WALL = 40;
        const BOTTOM_WALL = 410;
        const BALL_RADIUS = 14;
        
        const FRICTION = 0.98;
        const MAX_POWER_SPEED = 25;
        
        const pockets = [
            { x: LEFT_WALL, y: TOP_WALL },
            { x: RIGHT_WALL, y: TOP_WALL },
            { x: LEFT_WALL, y: BOTTOM_WALL },
            { x: RIGHT_WALL, y: BOTTOM_WALL },
            { x: (LEFT_WALL + RIGHT_WALL) / 2, y: TOP_WALL },
            { x: (LEFT_WALL + RIGHT_WALL) / 2, y: BOTTOM_WALL }
        ];
        const POCKET_RADIUS = 28;

        // ----- game state -----
        let white = { x: 200, y: 220, vx: 0, vy: 0 };
        let target = { x: 600, y: 220, vx: 0, vy: 0 };
        let targetExists = true;
        let strokes = 0;
        let gameOver = false;
        let winFlag = false;
        
        let blackPocketed = false;
        let checkWhiteAfterStop = false;
        
        let victoryTimer = null;
        let blackPocketPosition = null;

        // ----- aiming state -----
        let isDragging = false;
        let dragX = 0, dragY = 0;
        let angle = 0;
        let power = 0.2;

        // flag to track if mouse is outside canvas
        let isMouseOutside = false;

        // ----- particle system -----
        let particles = [];
        const PARTICLE_COLORS = [
            '#FF69B4', '#FFD700', '#FF4500', '#9370DB', '#00FF7F', 
            '#FF1493', '#FFA500', '#32CD32', '#FFB6C1', '#87CEEB',
            '#FF6346', '#FFFF00', '#FF00FF', '#00FFFF', '#FFDAB9'
        ];

        class Particle {
            constructor(x, y, type = 'explosion') {
                this.x = x;
                this.y = y;
                this.type = type;
                
                if (type === 'explosion') {
                    const angle = Math.random() * Math.PI * 2;
                    const speed = Math.random() * 8 + 5;
                    this.vx = Math.cos(angle) * speed;
                    this.vy = Math.sin(angle) * speed;
                    this.size = Math.random() * 10 + 5;
                    this.fadeSpeed = 0.01 + Math.random() * 0.01;
                } else {
                    this.vx = (Math.random() - 0.5) * 1.5;
                    this.vy = Math.random() * 2 + 1.5;
                    this.size = Math.random() * 8 + 4;
                    this.fadeSpeed = 0.001;
                }
                
                this.color = PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)];
                this.rotation = Math.random() * Math.PI * 2;
                this.rotationSpeed = (Math.random() - 0.5) * 0.05;
                this.gravity = 0.05;
                this.life = 1.0;
            }

            update() {
                this.x += this.vx;
                this.y += this.vy;
                
                if (this.type === 'rain') {
                    this.vy += this.gravity * 0.5;
                } else {
                    this.vy += this.gravity;
                }
                
                this.rotation += this.rotationSpeed;
                
                if (this.type === 'explosion') {
                    this.life -= this.fadeSpeed;
                }
                
                if (this.type === 'rain') {
                    if (this.y > CH + 30) {
                        this.y = -20;
                        this.x = Math.random() * CW;
                        this.vx = (Math.random() - 0.5) * 1.5;
                        this.vy = Math.random() * 2 + 1.5;
                    }
                    if (this.x < 0 || this.x > CW) {
                        this.vx *= -0.8;
                    }
                    return true;
                } else {
                    if (this.y > CH + 50) this.life = 0;
                    return this.life > 0;
                }
            }

            draw() {
                ctx.save();
                ctx.translate(this.x, this.y);
                ctx.rotate(this.rotation);
                ctx.globalAlpha = this.life;
                
                ctx.beginPath();
                for (let i = 0; i < 5; i++) {
                    let angle = (i / 5) * Math.PI * 2;
                    let petalLength = this.size;
                    let petalWidth = this.size * 0.6;
                    
                    let x = Math.cos(angle) * petalLength;
                    let y = Math.sin(angle) * petalLength;
                    
                    let cx1 = Math.cos(angle + 0.5) * petalWidth;
                    let cy1 = Math.sin(angle + 0.5) * petalWidth;
                    let cx2 = Math.cos(angle - 0.5) * petalWidth;
                    let cy2 = Math.sin(angle - 0.5) * petalWidth;
                    
                    ctx.moveTo(0, 0);
                    ctx.quadraticCurveTo(cx1, cy1, x, y);
                    ctx.quadraticCurveTo(cx2, cy2, 0, 0);
                }
                
                ctx.fillStyle = this.color;
                ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
                ctx.shadowBlur = 10;
                ctx.fill();
                
                ctx.restore();
            }
        }

        function explodeFlowersFromPocket(pocketX, pocketY, count = 15) {
            for (let i = 0; i < count; i++) {
                particles.push(new Particle(pocketX, pocketY, 'explosion'));
            }
        }

        function startRainFlowers(count = 40) {
            particles = particles.filter(p => p.type === 'rain');
            for (let i = 0; i < count; i++) {
                particles.push(new Particle(
                    Math.random() * CW,
                    Math.random() * CH - CH,
                    'rain'
                ));
            }
        }

        function stopRainFlowers() {
            particles = particles.filter(p => p.type !== 'rain');
        }

        function updateParticles() {
            particles = particles.filter(p => p.update());
        }

        function drawParticles() {
            for (let p of particles) {
                p.draw();
            }
            ctx.globalAlpha = 1.0;
        }

        // ----- helper functions -----
        function randomTargetPosition() {
            let overlap = true;
            let attempts = 0;
            let newX, newY;
            const minDist = BALL_RADIUS * 2 + 20;

            while (overlap && attempts < 1000) {
                newX = LEFT_WALL + Math.random() * (RIGHT_WALL - LEFT_WALL);
                newY = TOP_WALL + Math.random() * (BOTTOM_WALL - TOP_WALL);
                const dx = newX - white.x;
                const dy = newY - white.y;
                const dist = Math.sqrt(dx*dx + dy*dy);
                if (dist >= minDist) overlap = false;
                attempts++;
            }
            return { x: newX, y: newY };
        }

        function nextLevel() {
            if (victoryTimer) {
                clearTimeout(victoryTimer);
                victoryTimer = null;
            }
            
            white = { x: 200, y: 220, vx: 0, vy: 0 };
            const pos = randomTargetPosition();
            target = { x: pos.x, y: pos.y, vx: 0, vy: 0 };
            targetExists = true;
            gameOver = false;
            winFlag = false;
            blackPocketed = false;
            checkWhiteAfterStop = false;
            blackPocketPosition = null;
            strokes = 0;
            strokeSpan.innerText = strokes;
            isDragging = false;
            angle = 0;
            power = 0.2;
            powerFill.style.width = '20%';
            nextBtn.style.display = 'none';
            restartBtn.style.display = 'inline-block';
            
            stopRainFlowers();
        }

        function resetLevel() {
            nextLevel();
        }

        function checkPocket(ball) {
            for (let p of pockets) {
                const dx = ball.x - p.x;
                const dy = ball.y - p.y;
                if (Math.sqrt(dx*dx + dy*dy) < POCKET_RADIUS) {
                    return p;
                }
            }
            return null;
        }

        function checkWhitePocket() {
            for (let p of pockets) {
                const dx = white.x - p.x;
                const dy = white.y - p.y;
                if (Math.sqrt(dx*dx + dy*dy) < POCKET_RADIUS) return true;
            }
            return false;
        }

        function showFoulMessage() {
            foulMessage.style.display = 'block';
            setTimeout(() => {
                foulMessage.style.display = 'none';
            }, 1500);
        }

        function ballsAreStationary() {
            return (Math.abs(white.vx) < 0.1 && Math.abs(white.vy) < 0.1 &&
                    Math.abs(target.vx) < 0.1 && Math.abs(target.vy) < 0.1);
        }

        // ----- physics update -----
        function updatePhysics() {
            if (!gameOver || checkWhiteAfterStop) {
                white.x += white.vx;
                white.y += white.vy;
                target.x += target.vx;
                target.y += target.vy;

                [white, target].forEach(ball => {
                    if (ball.x - BALL_RADIUS < LEFT_WALL) {
                        ball.x = LEFT_WALL + BALL_RADIUS;
                        ball.vx = -ball.vx * 0.92;
                    }
                    if (ball.x + BALL_RADIUS > RIGHT_WALL) {
                        ball.x = RIGHT_WALL - BALL_RADIUS;
                        ball.vx = -ball.vx * 0.92;
                    }
                    if (ball.y - BALL_RADIUS < TOP_WALL) {
                        ball.y = TOP_WALL + BALL_RADIUS;
                        ball.vy = -ball.vy * 0.92;
                    }
                    if (ball.y + BALL_RADIUS > BOTTOM_WALL) {
                        ball.y = BOTTOM_WALL - BALL_RADIUS;
                        ball.vy = -ball.vy * 0.92;
                    }
                });

                const dx = target.x - white.x;
                const dy = target.y - white.y;
                const dist = Math.sqrt(dx*dx + dy*dy);
                if (dist < BALL_RADIUS * 2 && dist > 0.001) {
                    const nx = dx / dist;
                    const ny = dy / dist;
                    const vrelx = white.vx - target.vx;
                    const vrely = white.vy - target.vy;
                    const vn = vrelx * nx + vrely * ny;

                    if (vn > 0) {
                        const imp = (2 * vn) / 2 * 0.96;
                        white.vx -= imp * nx;
                        white.vy -= imp * ny;
                        target.vx += imp * nx;
                        target.vy += imp * ny;
                    }

                    const overlap = BALL_RADIUS * 2 - dist;
                    if (overlap > 0) {
                        const sepX = nx * overlap * 0.5;
                        const sepY = ny * overlap * 0.5;
                        white.x -= sepX;
                        white.y -= sepY;
                        target.x += sepX;
                        target.y += sepY;
                    }
                }

                white.vx *= FRICTION;
                white.vy *= FRICTION;
                target.vx *= FRICTION;
                target.vy *= FRICTION;

                if (Math.abs(white.vx) < 0.1) white.vx = 0;
                if (Math.abs(white.vy) < 0.1) white.vy = 0;
                if (Math.abs(target.vx) < 0.1) target.vx = 0;
                if (Math.abs(target.vy) < 0.1) target.vy = 0;

                if (targetExists && !blackPocketed) {
                    const pocket = checkPocket(target);
                    if (pocket) {
                        console.log('Black ball pocketed');
                        targetExists = false;
                        blackPocketed = true;
                        checkWhiteAfterStop = true;
                        blackPocketPosition = pocket;
                        
                        playWinSound();
                        explodeFlowersFromPocket(pocket.x, pocket.y, 15);
                    }
                }

                if (!blackPocketed && checkWhitePocket()) {
                    console.log('Cue ball foul');
                    showFoulMessage();
                    resetLevel();
                    return;
                }
            }
            
            if (checkWhiteAfterStop) {
                if (ballsAreStationary()) {
                    if (checkWhitePocket()) {
                        showFoulMessage();
                        resetLevel();
                    } else {
                        console.log('Cue ball stopped - victory');
                        playXiaochuSound();
                        
                        gameOver = true;
                        winFlag = true;
                        
                        startRainFlowers(50);
                        
                        if (victoryTimer) clearTimeout(victoryTimer);
                        victoryTimer = setTimeout(() => {
                            console.log('3s auto next round');
                            nextLevel();
                        }, 3000);
                        
                        nextBtn.style.display = 'inline-block';
                        restartBtn.style.display = 'none';
                    }
                    checkWhiteAfterStop = false;
                }
            }
            
            updateParticles();
        }

        // ----- shoot -----
        function shoot() {
            if (gameOver) {
                return;
            }
            if (white.vx !== 0 || white.vy !== 0) {
                return;
            }
            if (!targetExists && blackPocketed) {
                return;
            }
            if (!targetExists && !blackPocketed) {
                return;
            }

            // actual shot speed uses full power value (may exceed 1.0)
            const speed = power * MAX_POWER_SPEED;
            white.vx = Math.cos(angle) * speed;
            white.vy = Math.sin(angle) * speed;
            strokes++;
            strokeSpan.innerText = strokes;
            
            // mark first shot and attempt to play background music
            if (!hasShot) {
                hasShot = true;
                console.log('First shot! Starting BGM...');
                // delay slightly to let shot sound play first
                setTimeout(() => {
                    if (!isMuted) {
                        playBGM();
                    }
                }, 300);
            }
        }

        // ----- aim update (fixed power and angle calculation) -----
        function updateAim() {
            if (!isDragging) return;
            
            // vector from cue ball to drag point
            const dx = white.x - dragX;
            const dy = white.y - dragY;
            
            // calculate distance
            let dist = Math.sqrt(dx*dx + dy*dy);
            
            // if too close, keep current angle
            if (dist < 1) {
                return;
            }

            // calculate angle (shot direction is away from finger)
            angle = Math.atan2(dy, dx);

            // power calculation with smooth curve
            const MIN_POWER = 0.2;
            const MAX_POWER = 1.2;  // allow 20% overcharge
            const OPTIMAL_DIST = 180; // optimal distance for max visual power
            
            // base power (0-1.5 range)
            let rawPower = dist / OPTIMAL_DIST;
            
            // S-curve for natural power progression
            if (rawPower < 0.5) {
                // short distance: slow growth
                power = MIN_POWER + (rawPower / 0.5) * 0.3;
            } else if (rawPower < 1.2) {
                // medium distance: linear growth
                power = MIN_POWER + 0.3 + ((rawPower - 0.5) / 0.7) * 0.5;
            } else {
                // long distance: saturation with slight overcharge
                power = MIN_POWER + 0.8 + Math.min(rawPower - 1.2, 0.5) * 0.4;
            }
            
            // clamp power range
            power = Math.max(MIN_POWER, Math.min(MAX_POWER, power));
            
            // visual feedback power (clamped to 0-1)
            let visualPower = Math.min(power, 1.0);

            // update power bar (using visual power)
            powerFill.style.width = (visualPower * 100) + '%';
        }

        // ----- collision prediction -----
        function predictCollision() {
            const dirX = Math.cos(angle);
            const dirY = Math.sin(angle);

            const startX = white.x + dirX * BALL_RADIUS;
            const startY = white.y + dirY * BALL_RADIUS;
            
            const tx = target.x;
            const ty = target.y;
            
            const toTargetX = tx - startX;
            const toTargetY = ty - startY;
            
            const proj = toTargetX * dirX + toTargetY * dirY;
            
            if (proj < 0) return null;
            
            const closestX = startX + dirX * proj;
            const closestY = startY + dirY * proj;
            
            const perpDist = Math.hypot(tx - closestX, ty - closestY);
            
            if (perpDist > BALL_RADIUS * 2) return null;
            
            const hitDist = proj - Math.sqrt(Math.max(0, Math.pow(BALL_RADIUS * 2, 2) - perpDist * perpDist));
            
            if (hitDist < 0) return null;
            
            const hitX = startX + dirX * hitDist;
            const hitY = startY + dirY * hitDist;
            
            return { hitX, hitY };
        }

        // ----- draw -----
        function draw() {
            ctx.clearRect(0, 0, CW, CH);

            // table felt
            ctx.fillStyle = '#1e5a3a';
            ctx.fillRect(0, 0, CW, CH);

            // cushions
            ctx.strokeStyle = '#dbb06b';
            ctx.lineWidth = 3;
            ctx.strokeRect(LEFT_WALL - 2, TOP_WALL - 2, RIGHT_WALL - LEFT_WALL + 4, BOTTOM_WALL - TOP_WALL + 4);

            // pockets
            ctx.fillStyle = '#2a1f12';
            ctx.shadowColor = '#00000080';
            ctx.shadowBlur = 10;
            pockets.forEach(p => {
                ctx.beginPath();
                ctx.arc(p.x, p.y, POCKET_RADIUS - 4, 0, Math.PI * 2);
                ctx.fillStyle = '#1f140e';
                ctx.fill();
                ctx.shadowBlur = 5;
                ctx.fillStyle = '#4a3c2b';
                ctx.arc(p.x, p.y, POCKET_RADIUS - 8, 0, Math.PI * 2);
                ctx.fill();
            });
            ctx.shadowBlur = 0;

            // black 8 ball
            if (targetExists) {
                ctx.shadowColor = '#333333';
                ctx.shadowBlur = 15;
                ctx.beginPath();
                ctx.arc(target.x, target.y, BALL_RADIUS, 0, Math.PI * 2);
                const grad = ctx.createRadialGradient(target.x - 3, target.y - 3, 3, target.x, target.y, BALL_RADIUS + 2);
                grad.addColorStop(0, '#222222');
                grad.addColorStop(0.7, '#000000');
                ctx.fillStyle = grad;
                ctx.fill();

                ctx.shadowBlur = 5;
                ctx.font = 'bold 16px "Segoe UI", Arial';
                ctx.fillStyle = 'white';
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText('8', target.x, target.y);
                
                ctx.beginPath();
                ctx.arc(target.x - 3, target.y - 3, 4, 0, Math.PI * 2);
                ctx.fillStyle = '#fff9e6';
                ctx.globalAlpha = 0.3;
                ctx.fill();
                ctx.globalAlpha = 1;
            }

            // cue ball
            ctx.shadowColor = '#cccccc';
            ctx.shadowBlur = 18;
            ctx.beginPath();
            ctx.arc(white.x, white.y, BALL_RADIUS, 0, Math.PI * 2);
            const wgrad = ctx.createRadialGradient(white.x - 4, white.y - 4, 4, white.x, white.y, BALL_RADIUS + 2);
            wgrad.addColorStop(0, '#fafaf5');
            wgrad.addColorStop(0.8, '#c0c0c0');
            ctx.fillStyle = wgrad;
            ctx.fill();

            ctx.shadowBlur = 8;
            ctx.beginPath();
            ctx.arc(white.x - 1, white.y - 1, 5, 0, Math.PI * 2);
            ctx.fillStyle = '#22222260';
            ctx.fill();

            // aiming guide
            if (isDragging && white.vx === 0 && white.vy === 0 && targetExists && !gameOver) {
                // visual power (clamped to 0-1)
                let visualPower = Math.min(power, 1.0);
                
                // power ring (at finger position)
                ctx.beginPath();
                ctx.arc(dragX, dragY, 15 + visualPower * 20, 0, Math.PI * 2);
                ctx.strokeStyle = 'rgba(255, 200, 0, 0.5)';
                ctx.lineWidth = 3;
                ctx.stroke();

                // aim line (from cue ball in shot direction)
                ctx.setLineDash([8, 6]);
                ctx.beginPath();
                ctx.moveTo(white.x, white.y);
                ctx.lineTo(white.x + Math.cos(angle) * 500, white.y + Math.sin(angle) * 500);
                ctx.strokeStyle = 'white';
                ctx.stroke();
                ctx.setLineDash([]);

                // line from finger to cue ball
                ctx.beginPath();
                ctx.moveTo(dragX, dragY);
                ctx.lineTo(white.x, white.y);
                ctx.strokeStyle = 'rgba(255, 215, 0, 0.4)';
                ctx.lineWidth = 2;
                ctx.stroke();

                // show power percentage
                ctx.font = 'bold 14px Arial';
                ctx.fillStyle = 'white';
                ctx.shadowBlur = 4;
                ctx.shadowColor = 'black';
                ctx.fillText(Math.round(visualPower * 100) + '%', dragX - 15, dragY - 25);
                ctx.shadowBlur = 0;

                const hit = predictCollision();
                if (hit) {
                    ctx.beginPath();
                    ctx.arc(hit.hitX, hit.hitY, 6, 0, Math.PI * 2);
                    ctx.fillStyle = 'yellow';
                    ctx.fill();
                }
            }

            // draw all particles
            drawParticles();

            // victory screen
            if (winFlag) {
                ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
                ctx.fillRect(0, 0, CW, CH);
                
                ctx.shadowBlur = 30;
                ctx.font = 'bold 52px "Segoe UI", Verdana';
                ctx.fillStyle = '#FFD966';
                ctx.strokeStyle = '#8B4513';
                ctx.lineWidth = 6;
                ctx.textAlign = 'center';
                ctx.strokeText('🎉 VICTORY!', CW / 2, 150);
                ctx.fillText('🎉 VICTORY!', CW / 2, 150);
                
                ctx.font = 'bold 24px "Segoe UI", Verdana';
                ctx.fillStyle = '#FFFFFF';
                ctx.strokeStyle = '#000000';
                ctx.lineWidth = 2;
                ctx.strokeText('Next round in 3s', CW / 2, 280);
                ctx.fillText('Next round in 3s', CW / 2, 280);
            }
        }

        // ----- event handlers -----
        function getCanvasCoords(e) {
            const rect = canvas.getBoundingClientRect();
            const scaleX = canvas.width / rect.width;
            const scaleY = canvas.height / rect.height;

            let clientX, clientY;
            if (e.touches) {
                clientX = e.touches[0].clientX;
                clientY = e.touches[0].clientY;
            } else {
                clientX = e.clientX;
                clientY = e.clientY;
            }

            // convert screen to canvas coordinates - allow outside canvas
            let canvasX = (clientX - rect.left) * scaleX;
            let canvasY = (clientY - rect.top) * scaleY;

            return {
                x: canvasX,
                y: canvasY
            };
        }

        function onMouseDown(e) {
            e.preventDefault();
            if (e.button !== 0) return;
            
            handleUserInteraction();

            if (gameOver) return;
            if (white.vx !== 0 || white.vy !== 0) return;
            if (!targetExists) return;

            const coords = getCanvasCoords(e);
            dragX = coords.x;
            dragY = coords.y;
            isDragging = true;
            isMouseOutside = false;
        }

        function onMouseMove(e) {
            e.preventDefault();
            if (!isDragging) return;

            const coords = getCanvasCoords(e);
            dragX = coords.x;
            dragY = coords.y;
            updateAim();
        }

        function onMouseUp(e) {
            e.preventDefault();
            if (e.button !== 0) return;
            if (!isDragging) return;

            isDragging = false;
            isMouseOutside = false;
            shoot();
        }

        function onTouchStart(e) {
            e.preventDefault();
            
            handleUserInteraction();
            
            if (gameOver) return;
            if (white.vx !== 0 || white.vy !== 0) return;
            if (!targetExists) return;

            const coords = getCanvasCoords(e);
            dragX = coords.x;
            dragY = coords.y;
            isDragging = true;
        }

        function onTouchMove(e) {
            e.preventDefault();
            if (!isDragging) return;

            const coords = getCanvasCoords(e);
            dragX = coords.x;
            dragY = coords.y;
            updateAim();
        }

        function onTouchEnd(e) {
            e.preventDefault();
            if (!isDragging) return;

            isDragging = false;
            shoot();
        }

        canvas.addEventListener('mousedown', onMouseDown);
        canvas.addEventListener('mousemove', onMouseMove);
        canvas.addEventListener('mouseup', onMouseUp);
        
        // keep dragging when mouse leaves canvas
        canvas.addEventListener('mouseleave', () => {
            if (isDragging) {
                isMouseOutside = true;
                // don't cancel drag, continue updating with last coordinates
            }
        });

        canvas.addEventListener('mouseenter', () => {
            isMouseOutside = false;
        });

        // window level mousemove to continue updating outside canvas
        window.addEventListener('mousemove', (e) => {
            if (isDragging) {
                const coords = getCanvasCoords(e);
                dragX = coords.x;
                dragY = coords.y;
                updateAim();
            }
        });

        // window level mouseup to shoot even if released outside canvas
        window.addEventListener('mouseup', (e) => {
            if (isDragging) {
                isDragging = false;
                isMouseOutside = false;
                shoot();
            }
        });

        canvas.addEventListener('touchstart', onTouchStart, { passive: false });
        canvas.addEventListener('touchmove', onTouchMove, { passive: false });
        canvas.addEventListener('touchend', onTouchEnd);
        canvas.addEventListener('touchcancel', onTouchEnd);

        restartBtn.addEventListener('click', (e) => {
            e.preventDefault();
            handleUserInteraction();
            resetLevel();
        });

        nextBtn.addEventListener('click', (e) => {
            e.preventDefault();
            handleUserInteraction();
            nextLevel();
        });

        muteBtn.addEventListener('click', (e) => {
            e.preventDefault();
            handleUserInteraction();
            toggleMute();
        });

        // prevent double tap zoom
        let lastTouchEnd = 0;
        document.addEventListener('touchend', (e) => { 
            const now = Date.now(); 
            if (now - lastTouchEnd <= 300) e.preventDefault(); 
            lastTouchEnd = now; 
        }, false);
        
        document.addEventListener('contextmenu', e => e.preventDefault());
        document.body.addEventListener('touchmove', (e) => { 
            if (e.target === document.body) e.preventDefault(); 
        }, { passive: false });

        // initialize - audio first, then game
        (function init() {
            // initialize audio system
            initAudio();
            
            // initialize game
            const pos = randomTargetPosition();
            target.x = pos.x;
            target.y = pos.y;
            
            // attempt auto interaction
            setTimeout(() => {
                handleUserInteraction();
            }, 500);
        })();

        function animate() {
            updatePhysics();
            draw();
            requestAnimationFrame(animate);
        }
        animate();
    })();
</script>
</body>
</html>
相关推荐
阿珊和她的猫2 小时前
深入解析如何监听浏览器的页面关闭事件
前端·javascript·vue.js
敲代码的柯基2 小时前
一篇文章理解tsconfig.json和vue.config.js
javascript·vue.js·json
阿珊和她的猫2 小时前
Safari浏览器中监听页面关闭事件的技术探讨
前端·safari
昱宸星光2 小时前
spring cloud gateway内置路由断言工厂
java·开发语言·前端
getyefang2 小时前
react-native使用字体库如何在安卓显示
javascript·react native·react.js
摸鱼的春哥2 小时前
春哥的Agent通关秘籍11:本地RAG实战(中上)
前端·javascript·后端
Stewie121382 小时前
企业高性能web服务器——Nginx
服务器·前端·nginx
colicode3 小时前
安卓Android语音验证码接口API示例代码:Kotlin/Java版App验证开发
android·java·前端·前端框架·kotlin·语音识别
万物得其道者成11 小时前
前端大整数精度丢失:一次踩坑后的实战解决方案(`json-bigint`)
前端·json