NEON PATH - 霓虹

一笔画游戏html 开源

NEON PATH - 霓虹一笔画

项目简介

NEON PATH​ 是一款基于递归回溯算法生成的一笔画无尽关卡网页游戏。游戏采用霓虹风格视觉效果,结合粒子特效和音效,提供沉浸式的游戏体验。玩家需要从起点出发,一笔画完所有格子,到达终点,随着关卡增加,难度逐渐提升。

功能特性

  • 无限关卡生成:使用递归回溯算法自动生成可解的一笔画谜题

  • 霓虹视觉效果:采用渐变色彩、粒子特效和发光效果

  • 渐进难度:随着关卡增加,网格大小和障碍物数量逐渐增加

  • 触摸/鼠标控制:支持触摸屏和鼠标操作

  • 进度保存:自动保存游戏进度到本地存储

  • 音效系统:移动、回溯、胜利等动作均有相应音效

  • 响应式设计:适配各种屏幕尺寸

  • 提示系统:显示下一步正确路径提示

游戏规则

  1. 绿色起点开始,到红色终点结束

  2. 一笔画完所有格子,不重复、不遗漏

  3. 只能水平或垂直移动到相邻格子

  4. 避开障碍物(红色格子)

  5. 路径不可交叉

操作方法

  • 鼠标/触摸:点击起点开始,拖动到相邻格子绘制路径

  • 回退:点击已绘制的路径可回退到该点

  • 提示按钮:高亮显示下一步正确路径

  • 重置按钮:重置当前关卡

  • 下一关按钮:完成当前关卡后进入下一关

技术实现

核心算法

  • 递归回溯算法:生成哈密顿路径,确保每个关卡都有解

  • Warnsdorff规则:优化路径生成,提高成功率

  • 连通性检查:确保网格在放置障碍物后仍然连通

技术栈

  • HTML5 Canvas:游戏界面渲染

  • 原生JavaScript:游戏逻辑实现

  • CSS3:霓虹风格UI和动画效果

  • Web Audio API:音效系统

  • LocalStorage:进度保存

关键类

  1. LevelGenerator​ - 关卡生成器

    • 递归回溯生成哈密顿路径

    • 智能障碍物放置

    • 连通性验证

  2. ParticleSystem​ - 粒子系统

    • 路径绘制粒子特效

    • 胜利粒子爆发效果

    • 粒子生命周期管理

  3. SoundManager​ - 音效管理器

    • Web Audio API音效生成

    • 移动、回溯、胜利音效

    • 音频上下文管理

  4. Game​ - 游戏主类

    • 游戏状态管理

    • 用户输入处理

    • 渲染循环

安装与运行

无需安装,直接在浏览器中打开index.html即可运行。

支持现代浏览器:

  • Chrome 60+

  • Firefox 55+

  • Safari 11+

  • Edge 79+

游戏难度曲线

关卡范围

网格大小

障碍物数量

1-2

3×3

0

3-4

3×4

0

5-6

4×4

0

7-8

4×5

0

9-10

5×5

0

11-12

5×6

0

13-16

6×6

0-6

17-20+

6×7-7×7

逐渐增加

文件结构


 

 
neon-path/
├── index.html          # 主HTML文件
├── 代码实现            # 完整游戏实现
└── README.md           # 说明文档

开发说明

自定义设置

可以调整以下参数自定义游戏体验:

  1. 难度配置:在LevelGenerator.getConfig()中修改关卡配置

  2. 视觉效果:修改CSS中的颜色变量和动画参数

  3. 音效参数:调整SoundManager类中的频率和时长

  4. 网格参数:修改游戏初始化时的默认网格大小

算法优化

  • 使用Warnsdorff规则优化路径搜索

  • 限制生成尝试次数避免无限循环

  • 障碍物放置算法确保连通性

  • 缓存已生成关卡提高性能

性能特点

  • 60fps流畅动画

  • 内存高效管理

  • 触摸事件防抖处理

  • 响应式布局适配

  • 离线可用

扩展可能性

  1. 关卡编辑器:允许玩家自定义关卡

  2. 成就系统:添加游戏成就和挑战

  3. 多人模式:添加计时赛或合作模式

  4. 社交分享:分享通关成绩

  5. 主题切换:提供多种视觉主题

技术挑战与解决方案

挑战

解决方案

无尽关卡生成

递归回溯算法 + 动态难度调整

触摸控制精度

网格坐标转换 + 防抖处理

视觉性能

Canvas分层渲染 + 粒子系统优化

跨浏览器兼容

特性检测 + 渐进增强

移动端适配

响应式布局 + 触摸事件优化

许可证

本项目仅供学习和演示使用,可自由修改和分发。

作者

腾讯元宝

版本历史

  • v1.0.0 (2025-05-07):初始版本发布

    • 基础游戏功能

    • 霓虹视觉效果

    • 递归回溯算法

    • 无尽关卡系统

<!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>NEON PATH - 霓虹一笔画</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            -webkit-tap-highlight-color: transparent;
            user-select: none;
        }

        body {
            background: #050508;
            color: #e0e0ff;
            font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
            overflow: hidden;
            touch-action: none;
            width: 100vw;
            height: 100vh;
            position: relative;
        }

        .bg-grid {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-image: 
                linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
                linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
            background-size: 50px 50px;
            pointer-events: none;
            z-index: 0;
        }

        .bg-glow {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 80vw;
            height: 80vh;
            background: radial-gradient(circle, rgba(120, 0, 255, 0.08) 0%, transparent 70%);
            pointer-events: none;
            z-index: 0;
            animation: pulseGlow 4s ease-in-out infinite;
        }

        @keyframes pulseGlow {
            0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
            50% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); }
        }

        #app {
            position: relative;
            z-index: 1;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: space-between;
            padding: 10px;
        }

        header {
            text-align: center;
            padding: 10px 0;
            width: 100%;
            max-width: 600px;
        }

        h1 {
            font-size: clamp(1.5rem, 5vw, 2.5rem);
            font-weight: 800;
            letter-spacing: 4px;
            background: linear-gradient(135deg, #00d4ff, #b829dd, #ff3366);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
            text-shadow: 0 0 30px rgba(0, 212, 255, 0.3);
            margin-bottom: 8px;
        }

        .subtitle {
            font-size: 0.75rem;
            color: rgba(200, 200, 255, 0.5);
            letter-spacing: 2px;
            text-transform: uppercase;
        }

        .status-bar {
            display: flex;
            justify-content: space-around;
            align-items: center;
            width: 100%;
            max-width: 500px;
            margin: 5px 0;
            padding: 12px 20px;
            background: rgba(255, 255, 255, 0.03);
            border: 1px solid rgba(255, 255, 255, 0.08);
            border-radius: 16px;
            backdrop-filter: blur(10px);
        }

        .stat {
            text-align: center;
        }

        .stat-value {
            font-size: 1.4rem;
            font-weight: 700;
            color: #00d4ff;
            text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
            transition: all 0.3s;
        }

        .stat-label {
            font-size: 0.65rem;
            color: rgba(200, 200, 255, 0.4);
            margin-top: 2px;
            letter-spacing: 1px;
        }

        .progress-container {
            width: 100%;
            max-width: 500px;
            height: 4px;
            background: rgba(255, 255, 255, 0.05);
            border-radius: 2px;
            margin: 8px 0;
            overflow: hidden;
        }

        .progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #00d4ff, #b829dd);
            border-radius: 2px;
            transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
            box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
        }

        #game-container {
            position: relative;
            flex: 1;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
            max-width: 600px;
            min-height: 200px;
        }

        canvas {
            display: block;
            border-radius: 12px;
            box-shadow: 0 0 40px rgba(0, 212, 255, 0.1), inset 0 0 40px rgba(0, 0, 0, 0.5);
        }

        .generating {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 1rem;
            color: #00d4ff;
            text-shadow: 0 0 20px rgba(0, 212, 255, 0.8);
            animation: blink 1s infinite;
            pointer-events: none;
            opacity: 0;
            white-space: nowrap;
        }

        .generating.active {
            opacity: 1;
        }

        @keyframes blink {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.3; }
        }

        .controls {
            display: flex;
            gap: 12px;
            padding: 15px 0;
            width: 100%;
            max-width: 500px;
            justify-content: center;
        }

        button {
            flex: 1;
            max-width: 140px;
            padding: 14px 20px;
            border: none;
            border-radius: 12px;
            font-size: 0.9rem;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            position: relative;
            overflow: hidden;
            letter-spacing: 1px;
        }

        button::before {
            content: '';
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
            transition: left 0.5s;
        }

        button:hover::before {
            left: 100%;
        }

        button:active {
            transform: scale(0.95);
        }

        .btn-hint {
            background: rgba(0, 212, 255, 0.1);
            color: #00d4ff;
            border: 1px solid rgba(0, 212, 255, 0.3);
            box-shadow: 0 0 15px rgba(0, 212, 255, 0.1);
        }

        .btn-hint:hover {
            background: rgba(0, 212, 255, 0.2);
            box-shadow: 0 0 25px rgba(0, 212, 255, 0.3);
        }

        .btn-reset {
            background: rgba(255, 51, 102, 0.1);
            color: #ff3366;
            border: 1px solid rgba(255, 51, 102, 0.3);
            box-shadow: 0 0 15px rgba(255, 51, 102, 0.1);
        }

        .btn-reset:hover {
            background: rgba(255, 51, 102, 0.2);
            box-shadow: 0 0 25px rgba(255, 51, 102, 0.3);
        }

        .btn-next {
            background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(184, 41, 221, 0.2));
            color: #fff;
            border: 1px solid rgba(255, 255, 255, 0.2);
            box-shadow: 0 0 20px rgba(184, 41, 221, 0.2);
            display: none;
        }

        .btn-next.show {
            display: block;
            animation: slideUp 0.5s ease;
        }

        @keyframes slideUp {
            from { opacity: 0; transform: translateY(20px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .btn-next:hover {
            box-shadow: 0 0 30px rgba(184, 41, 221, 0.4);
        }

        .modal-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.8);
            backdrop-filter: blur(10px);
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 100;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.5s;
        }

        .modal-overlay.show {
            opacity: 1;
            pointer-events: all;
        }

        .modal {
            background: rgba(20, 20, 35, 0.95);
            border: 1px solid rgba(0, 212, 255, 0.2);
            border-radius: 24px;
            padding: 40px;
            text-align: center;
            max-width: 90%;
            width: 400px;
            box-shadow: 0 0 60px rgba(0, 212, 255, 0.15);
            transform: scale(0.8);
            transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
        }

        .modal-overlay.show .modal {
            transform: scale(1);
        }

        .modal h2 {
            font-size: 2rem;
            margin-bottom: 10px;
            background: linear-gradient(135deg, #00ff88, #00d4ff);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }

        .modal p {
            color: rgba(200, 200, 255, 0.7);
            margin-bottom: 25px;
            font-size: 1rem;
        }

        .modal-stats {
            display: flex;
            justify-content: space-around;
            margin-bottom: 25px;
            padding: 15px;
            background: rgba(255, 255, 255, 0.03);
            border-radius: 12px;
        }

        .modal-btn {
            width: 100%;
            padding: 16px;
            background: linear-gradient(135deg, #00d4ff, #b829dd);
            color: white;
            border: none;
            border-radius: 12px;
            font-size: 1.1rem;
            font-weight: 700;
            cursor: pointer;
            transition: all 0.3s;
            box-shadow: 0 0 30px rgba(0, 212, 255, 0.3);
        }

        .modal-btn:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 40px rgba(0, 212, 255, 0.5);
        }

        .hint-text {
            position: fixed;
            bottom: 100px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(0, 0, 0, 0.8);
            color: #ffd700;
            padding: 10px 20px;
            border-radius: 20px;
            font-size: 0.85rem;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.3s;
            z-index: 50;
            border: 1px solid rgba(255, 215, 0, 0.3);
            white-space: nowrap;
        }

        .hint-text.show {
            opacity: 1;
        }

        @media (max-width: 480px) {
            .status-bar {
                padding: 8px 12px;
            }
            .stat-value {
                font-size: 1.1rem;
            }
            button {
                padding: 12px 16px;
                font-size: 0.8rem;
            }
            .modal {
                padding: 30px 20px;
            }
        }
    </style>
<base target="_blank">
</head>
<body>
    <div class="bg-grid"></div>
    <div class="bg-glow"></div>

    <div id="app">
        <header>
            <h1>NEON PATH</h1>
            <div class="subtitle">一笔画 · 无尽挑战</div>
        </header>

        <div class="status-bar">
            <div class="stat">
                <div class="stat-value" id="levelDisplay">1</div>
                <div class="stat-label">关卡</div>
            </div>
            <div class="stat">
                <div class="stat-value" id="gridDisplay">3×3</div>
                <div class="stat-label">网格</div>
            </div>
            <div class="stat">
                <div class="stat-value" id="progressDisplay">0%</div>
                <div class="stat-label">进度</div>
            </div>
            <div class="stat">
                <div class="stat-value" id="timeDisplay">00:00</div>
                <div class="stat-label">时间</div>
            </div>
        </div>

        <div class="progress-container">
            <div class="progress-fill" id="progressBar" style="width: 0%"></div>
        </div>

        <div id="game-container">
            <canvas id="gameCanvas"></canvas>
            <div class="generating" id="generating"></div>
        </div>

        <div class="controls">
            <button class="btn-hint" id="hintBtn" onclick="game.toggleHint()">💡 提示</button>
            <button class="btn-reset" id="resetBtn" onclick="game.reset()">↺ 重置</button>
            <button class="btn-next" id="nextBtn" onclick="game.nextLevel()">下一关 →</button>
        </div>
    </div>

    <div class="hint-text" id="hintText">显示路径提示中...</div>

    <div class="modal-overlay" id="victoryModal">
        <div class="modal">
            <h2>🎉 完美通关</h2>
            <p>你完成了一笔画的挑战!</p>
            <div class="modal-stats">
                <div class="stat">
                    <div class="stat-value" id="modalLevel">1</div>
                    <div class="stat-label">关卡</div>
                </div>
                <div class="stat">
                    <div class="stat-value" id="modalTime">00:00</div>
                    <div class="stat-label">用时</div>
                </div>
                <div class="stat">
                    <div class="stat-value" id="modalSteps">0</div>
                    <div class="stat-label">步数</div>
                </div>
            </div>
            <button class="modal-btn" onclick="game.nextLevel()">继续挑战 →</button>
        </div>
    </div>

    <script>
        // ==================== 音效系统 ====================
        class SoundManager {
            constructor() {
                this.ctx = null;
                this.enabled = true;
                this.init();
            }

            init() {
                try {
                    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
                } catch(e) {
                    this.enabled = false;
                }
            }

            resume() {
                if (this.ctx && this.ctx.state === 'suspended') {
                    this.ctx.resume();
                }
            }

            playMove() {
                if (!this.enabled || !this.ctx) return;
                this.resume();
                const osc = this.ctx.createOscillator();
                const gain = this.ctx.createGain();
                osc.connect(gain);
                gain.connect(this.ctx.destination);
                
                osc.type = 'sine';
                osc.frequency.setValueAtTime(600, this.ctx.currentTime);
                osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.05);
                
                gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
                gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.1);
                
                osc.start();
                osc.stop(this.ctx.currentTime + 0.1);
            }

            playWin() {
                if (!this.enabled || !this.ctx) return;
                this.resume();
                const notes = [523.25, 659.25, 783.99, 1046.50];
                notes.forEach((freq, i) => {
                    const osc = this.ctx.createOscillator();
                    const gain = this.ctx.createGain();
                    osc.connect(gain);
                    gain.connect(this.ctx.destination);
                    
                    osc.type = 'sine';
                    osc.frequency.value = freq;
                    
                    gain.gain.setValueAtTime(0, this.ctx.currentTime + i * 0.08);
                    gain.gain.linearRampToValueAtTime(0.1, this.ctx.currentTime + i * 0.08 + 0.02);
                    gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + i * 0.08 + 0.4);
                    
                    osc.start(this.ctx.currentTime + i * 0.08);
                    osc.stop(this.ctx.currentTime + i * 0.08 + 0.4);
                });
            }

            playBack() {
                if (!this.enabled || !this.ctx) return;
                this.resume();
                const osc = this.ctx.createOscillator();
                const gain = this.ctx.createGain();
                osc.connect(gain);
                gain.connect(this.ctx.destination);
                
                osc.type = 'sine';
                osc.frequency.setValueAtTime(400, this.ctx.currentTime);
                osc.frequency.exponentialRampToValueAtTime(200, this.ctx.currentTime + 0.08);
                
                gain.gain.setValueAtTime(0.06, this.ctx.currentTime);
                gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.08);
                
                osc.start();
                osc.stop(this.ctx.currentTime + 0.08);
            }
        }

        // ==================== 粒子系统 ====================
        class ParticleSystem {
            constructor() {
                this.particles = [];
            }

            emit(x, y, color, count = 10) {
                for (let i = 0; i < count; i++) {
                    const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
                    const speed = 2 + Math.random() * 4;
                    this.particles.push({
                        x, y,
                        vx: Math.cos(angle) * speed,
                        vy: Math.sin(angle) * speed,
                        life: 1,
                        decay: 0.01 + Math.random() * 0.02,
                        color,
                        size: 2 + Math.random() * 3
                    });
                }
            }

            burst(x, y) {
                const colors = ['#00d4ff', '#b829dd', '#ff3366', '#00ff88', '#ffd700'];
                for (let i = 0; i < 50; i++) {
                    const angle = Math.random() * Math.PI * 2;
                    const speed = 3 + Math.random() * 6;
                    this.particles.push({
                        x, y,
                        vx: Math.cos(angle) * speed,
                        vy: Math.sin(angle) * speed,
                        life: 1,
                        decay: 0.005 + Math.random() * 0.015,
                        color: colors[Math.floor(Math.random() * colors.length)],
                        size: 2 + Math.random() * 4
                    });
                }
            }

            update() {
                for (let i = this.particles.length - 1; i >= 0; i--) {
                    const p = this.particles[i];
                    p.x += p.vx;
                    p.y += p.vy;
                    p.vx *= 0.98;
                    p.vy *= 0.98;
                    p.life -= p.decay;
                    if (p.life <= 0) {
                        this.particles.splice(i, 1);
                    }
                }
            }

            draw(ctx) {
                this.particles.forEach(p => {
                    ctx.globalAlpha = p.life;
                    ctx.fillStyle = p.color;
                    ctx.shadowBlur = 10;
                    ctx.shadowColor = p.color;
                    ctx.beginPath();
                    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
                    ctx.fill();
                });
                ctx.globalAlpha = 1;
                ctx.shadowBlur = 0;
            }
        }

        // ==================== 关卡生成器 ====================
        class LevelGenerator {
            static getConfig(level) {
                const configs = [
                    {rows: 3, cols: 3, obs: 0},
                    {rows: 3, cols: 3, obs: 0},
                    {rows: 3, cols: 4, obs: 0},
                    {rows: 3, cols: 4, obs: 0},
                    {rows: 4, cols: 4, obs: 0},
                    {rows: 4, cols: 4, obs: 0},
                    {rows: 4, cols: 5, obs: 0},
                    {rows: 4, cols: 5, obs: 0},
                    {rows: 5, cols: 5, obs: 0},
                    {rows: 5, cols: 5, obs: 0},
                    {rows: 5, cols: 6, obs: 0},
                    {rows: 5, cols: 6, obs: 0},
                    {rows: 6, cols: 6, obs: 0},
                    {rows: 6, cols: 6, obs: 2},
                    {rows: 6, cols: 6, obs: 4},
                    {rows: 6, cols: 6, obs: 6},
                    {rows: 6, cols: 7, obs: 0},
                    {rows: 6, cols: 7, obs: 3},
                    {rows: 7, cols: 7, obs: 0},
                    {rows: 7, cols: 7, obs: 4},
                ];
                
                if (level <= configs.length) return configs[level - 1];
                
                const extra = level - configs.length;
                return {
                    rows: 7, 
                    cols: 7, 
                    obs: Math.min(18, 5 + Math.floor(extra / 2))
                };
            }

            static generate(rows, cols, obstacleCount) {
                let attempts = 0;
                while (attempts < 50) {
                    const obstacles = new Set();
                    
                    if (obstacleCount > 0) {
                        this.placeObstacles(rows, cols, obstacleCount, obstacles);
                        if (!this.isConnected(rows, cols, obstacles)) {
                            attempts++;
                            continue;
                        }
                    }
                    
                    const path = this.findHamiltonianPath(rows, cols, obstacles);
                    if (path) {
                        return {
                            path,
                            obstacles,
                            start: path[0],
                            end: path[path.length - 1],
                            rows,
                            cols
                        };
                    }
                    attempts++;
                }
                
                if (obstacleCount > 0) {
                    return this.generate(rows, cols, obstacleCount - 1);
                }
                return this.generate(rows, cols, 0);
            }

            static placeObstacles(rows, cols, count, obstacles) {
                const cells = [];
                for (let r = 0; r < rows; r++) {
                    for (let c = 0; c < cols; c++) {
                        const isCorner = (r === 0 || r === rows - 1) && (c === 0 || c === cols - 1);
                        if (!isCorner) cells.push({r, c});
                    }
                }
                
                for (let i = cells.length - 1; i > 0; i--) {
                    const j = Math.floor(Math.random() * (i + 1));
                    [cells[i], cells[j]] = [cells[j], cells[i]];
                }
                
                for (let i = 0; i < Math.min(count, cells.length); i++) {
                    obstacles.add(`${cells[i].r},${cells[i].c}`);
                }
            }

            static isConnected(rows, cols, obstacles) {
                const visited = new Set();
                let start = null;
                
                for (let r = 0; r < rows; r++) {
                    for (let c = 0; c < cols; c++) {
                        if (!obstacles.has(`${r},${c}`)) {
                            start = {r, c};
                            break;
                        }
                    }
                    if (start) break;
                }
                
                if (!start) return false;
                
                const queue = [start];
                visited.add(`${start.r},${start.c}`);
                const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
                
                while (queue.length > 0) {
                    const {r, c} = queue.shift();
                    for (const [dr, dc] of dirs) {
                        const nr = r + dr, nc = c + dc;
                        const key = `${nr},${nc}`;
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && 
                            !obstacles.has(key) && !visited.has(key)) {
                            visited.add(key);
                            queue.push({r: nr, c: nc});
                        }
                    }
                }
                
                let totalCells = 0;
                for (let r = 0; r < rows; r++) {
                    for (let c = 0; c < cols; c++) {
                        if (!obstacles.has(`${r},${c}`)) totalCells++;
                    }
                }
                
                return visited.size === totalCells;
            }

            static findHamiltonianPath(rows, cols, obstacles) {
                const totalCells = rows * cols - obstacles.size;
                const visited = Array(rows).fill().map(() => Array(cols).fill(false));
                const path = [];
                const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
                
                let startR, startC;
                do {
                    startR = Math.floor(Math.random() * rows);
                    startC = Math.floor(Math.random() * cols);
                } while (obstacles.has(`${startR},${startC}`));
                
                function countUnvisitedNeighbors(r, c) {
                    let count = 0;
                    for (const [dr, dc] of dirs) {
                        const nr = r + dr, nc = c + dc;
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && 
                            !visited[nr][nc] && !obstacles.has(`${nr},${nc}`)) {
                            count++;
                        }
                    }
                    return count;
                }
                
                function getNeighbors(r, c) {
                    const neighbors = [];
                    for (const [dr, dc] of dirs) {
                        const nr = r + dr, nc = c + dc;
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && 
                            !visited[nr][nc] && !obstacles.has(`${nr},${nc}`)) {
                            neighbors.push([nr, nc]);
                        }
                    }
                    neighbors.sort((a, b) => {
                        return countUnvisitedNeighbors(a[0], a[1]) - countUnvisitedNeighbors(b[0], b[1]);
                    });
                    return neighbors;
                }
                
                function dfs(r, c, depth) {
                    visited[r][c] = true;
                    path.push({r, c});
                    
                    if (depth === totalCells) {
                        return true;
                    }
                    
                    const neighbors = getNeighbors(r, c);
                    for (const [nr, nc] of neighbors) {
                        if (dfs(nr, nc, depth + 1)) {
                            return true;
                        }
                    }
                    
                    visited[r][c] = false;
                    path.pop();
                    return false;
                }
                
                if (dfs(startR, startC, 1)) {
                    return path;
                }
                return null;
            }
        }

        // ==================== 游戏主类 ====================
        class Game {
            constructor() {
                this.canvas = document.getElementById('gameCanvas');
                this.ctx = this.canvas.getContext('2d');
                this.particles = new ParticleSystem();
                this.sound = new SoundManager();
                
                this.level = parseInt(localStorage.getItem('neonPath_level')) || 1;
                this.rows = 3;
                this.cols = 3;
                this.cellSize = 50;
                this.offsetX = 0;
                this.offsetY = 0;
                this.padding = 20;
                
                this.solutionPath = [];
                this.obstacles = new Set();
                this.userPath = [];
                this.visited = [];
                this.start = {r: 0, c: 0};
                this.end = {r: 0, c: 0};
                
                this.isDrawing = false;
                this.showHint = false;
                this.gameWon = false;
                this.startTime = 0;
                this.elapsedTime = 0;
                this.timerInterval = null;
                
                this.animOffset = 0;
                this.pulsePhase = 0;
                
                this.setupCanvas();
                this.setupEvents();
                this.loadLevel(this.level);
                this.loop();
            }

            setupCanvas() {
                const container = document.getElementById('game-container');
                const dpr = window.devicePixelRatio || 1;
                const rect = container.getBoundingClientRect();
                
                const size = Math.max(200, Math.min(rect.width, rect.height, 500));
                this.canvas.style.width = size + 'px';
                this.canvas.style.height = size + 'px';
                this.canvas.width = size * dpr;
                this.canvas.height = size * dpr;
                
                this.ctx.scale(dpr, dpr);
                this.canvasSize = size;
            }

            setupEvents() {
                const getPos = (e) => {
                    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
                    const clientY = e.touches ? e.touches[0].clientY : e.clientY;
                    return {clientX, clientY};
                };

                const handleStart = (e) => {
                    if (this.gameWon) return;
                    const {clientX, clientY} = getPos(e);
                    const pos = this.getGridPos(clientX, clientY);
                    if (!pos.valid) return;
                    
                    if (pos.r === this.start.r && pos.c === this.start.c && this.userPath.length === 0) {
                        this.isDrawing = true;
                        this.userPath.push({r: pos.r, c: pos.c});
                        this.visited[pos.r][pos.c] = true;
                        this.sound.playMove();
                        this.particles.emit(
                            this.offsetX + pos.c * this.cellSize + this.cellSize/2,
                            this.offsetY + pos.r * this.cellSize + this.cellSize/2,
                            '#00ff88', 8
                        );
                        this.updateUI();
                    }
                    else if (this.userPath.length > 0) {
                        const idx = this.userPath.findIndex(p => p.r === pos.r && p.c === pos.c);
                        if (idx !== -1 && idx < this.userPath.length - 1) {
                            for (let i = idx + 1; i < this.userPath.length; i++) {
                                this.visited[this.userPath[i].r][this.userPath[i].c] = false;
                            }
                            this.userPath.length = idx + 1;
                            this.sound.playBack();
                            this.updateUI();
                        }
                    }
                };

                const handleMove = (e) => {
                    if (!this.isDrawing || this.gameWon) return;
                    if (e.cancelable) e.preventDefault();
                    const {clientX, clientY} = getPos(e);
                    const pos = this.getGridPos(clientX, clientY);
                    if (!pos.valid) return;
                    
                    const last = this.userPath[this.userPath.length - 1];
                    if (pos.r === last.r && pos.c === last.c) return;
                    
                    const dr = Math.abs(pos.r - last.r);
                    const dc = Math.abs(pos.c - last.c);
                    if (dr + dc !== 1) return;
                    
                    if (this.userPath.length >= 2) {
                        const prev = this.userPath[this.userPath.length - 2];
                        if (pos.r === prev.r && pos.c === prev.c) {
                            this.visited[last.r][last.c] = false;
                            this.userPath.pop();
                            this.sound.playBack();
                            this.updateUI();
                            return;
                        }
                    }
                    
                    if (this.visited[pos.r][pos.c]) return;
                    if (this.obstacles.has(`${pos.r},${pos.c}`)) return;
                    
                    this.userPath.push({r: pos.r, c: pos.c});
                    this.visited[pos.r][pos.c] = true;
                    this.sound.playMove();
                    
                    const px = this.offsetX + pos.c * this.cellSize + this.cellSize/2;
                    const py = this.offsetY + pos.r * this.cellSize + this.cellSize/2;
                    this.particles.emit(px, py, '#00d4ff', 5);
                    
                    this.updateUI();
                    this.checkWin();
                };

                const handleEnd = () => {
                    // 保持绘制状态,允许抬起后继续滑动
                };

                this.canvas.addEventListener('mousedown', handleStart);
                this.canvas.addEventListener('mousemove', handleMove);
                this.canvas.addEventListener('mouseup', handleEnd);
                this.canvas.addEventListener('mouseleave', handleEnd);
                this.canvas.addEventListener('touchstart', handleStart, {passive: false});
                this.canvas.addEventListener('touchmove', handleMove, {passive: false});
                this.canvas.addEventListener('touchend', handleEnd);

                window.addEventListener('resize', () => {
                    this.setupCanvas();
                    this.calculateLayout();
                });
            }

            getGridPos(clientX, clientY) {
                const rect = this.canvas.getBoundingClientRect();
                const scaleX = this.canvasSize / rect.width;
                const scaleY = this.canvasSize / rect.height;
                const x = (clientX - rect.left) * scaleX;
                const y = (clientY - rect.top) * scaleY;
                const c = Math.floor((x - this.offsetX) / this.cellSize);
                const r = Math.floor((y - this.offsetY) / this.cellSize);
                
                return {
                    r, c,
                    valid: r >= 0 && r < this.rows && c >= 0 && c < this.cols
                };
            }

            calculateLayout() {
                const availableWidth = this.canvasSize - this.padding * 2;
                const availableHeight = this.canvasSize - this.padding * 2;
                
                this.cellSize = Math.min(
                    availableWidth / this.cols,
                    availableHeight / this.rows
                );
                
                const gridWidth = this.cols * this.cellSize;
                const gridHeight = this.rows * this.cellSize;
                
                this.offsetX = (this.canvasSize - gridWidth) / 2;
                this.offsetY = (this.canvasSize - gridHeight) / 2;
            }

            loadLevel(level) {
                document.getElementById('generating').classList.add('active');
                document.getElementById('nextBtn').classList.remove('show');
                
                // 使用 requestAnimationFrame 确保 UI 先刷新显示 loading
                requestAnimationFrame(() => {
                    const config = LevelGenerator.getConfig(level);
                    this.rows = config.rows;
                    this.cols = config.cols;
                    
                    const result = LevelGenerator.generate(config.rows, config.cols, config.obs);
                    this.solutionPath = result.path;
                    this.obstacles = result.obstacles;
                    this.start = result.start;
                    this.end = result.end;
                    
                    this.userPath = [];
                    this.visited = Array(this.rows).fill().map(() => Array(this.cols).fill(false));
                    this.gameWon = false;
                    this.showHint = false;
                    this.isDrawing = false;
                    
                    this.calculateLayout();
                    this.startTimer();
                    this.updateUI();
                    
                    document.getElementById('generating').classList.remove('active');
                });
            }

            startTimer() {
                if (this.timerInterval) clearInterval(this.timerInterval);
                this.startTime = Date.now();
                this.elapsedTime = 0;
                this.timerInterval = setInterval(() => {
                    if (!this.gameWon) {
                        this.elapsedTime = Math.floor((Date.now() - this.startTime) / 1000);
                        this.updateUI();
                    }
                }, 1000);
            }

            checkWin() {
                const totalCells = this.rows * this.cols - this.obstacles.size;
                const last = this.userPath[this.userPath.length - 1];
                
                if (this.userPath.length === totalCells && 
                    last.r === this.end.r && last.c === this.end.c) {
                    this.gameWon = true;
                    this.isDrawing = false;
                    this.sound.playWin();
                    
                    const endX = this.offsetX + this.end.c * this.cellSize + this.cellSize/2;
                    const endY = this.offsetY + this.end.r * this.cellSize + this.cellSize/2;
                    this.particles.burst(endX, endY);
                    
                    setTimeout(() => this.showVictory(), 800);
                }
            }

            showVictory() {
                document.getElementById('modalLevel').textContent = this.level;
                document.getElementById('modalTime').textContent = this.formatTime(this.elapsedTime);
                document.getElementById('modalSteps').textContent = Math.max(0, this.userPath.length - 1);
                document.getElementById('victoryModal').classList.add('show');
                document.getElementById('nextBtn').classList.add('show');
                
                localStorage.setItem('neonPath_level', this.level + 1);
            }

            nextLevel() {
                document.getElementById('victoryModal').classList.remove('show');
                this.level++;
                this.loadLevel(this.level);
            }

            reset() {
                this.userPath = [];
                this.visited = Array(this.rows).fill().map(() => Array(this.cols).fill(false));
                this.isDrawing = false;
                this.showHint = false;
                this.startTimer();
                this.updateUI();
            }

            toggleHint() {
                this.showHint = !this.showHint;
                const hintText = document.getElementById('hintText');
                if (this.showHint) {
                    hintText.textContent = '💡 提示:虚线显示正确路径';
                    hintText.classList.add('show');
                } else {
                    hintText.classList.remove('show');
                }
                setTimeout(() => hintText.classList.remove('show'), 2000);
            }

            formatTime(seconds) {
                const m = Math.floor(seconds / 60).toString().padStart(2, '0');
                const s = (seconds % 60).toString().padStart(2, '0');
                return `${m}:${s}`;
            }

            updateUI() {
                document.getElementById('levelDisplay').textContent = this.level;
                document.getElementById('gridDisplay').textContent = `${this.rows}×${this.cols}`;
                document.getElementById('timeDisplay').textContent = this.formatTime(this.elapsedTime);
                
                const totalCells = this.rows * this.cols - this.obstacles.size;
                const progress = totalCells > 0 ? (this.userPath.length / totalCells * 100) : 0;
                document.getElementById('progressDisplay').textContent = Math.floor(progress) + '%';
                document.getElementById('progressBar').style.width = progress + '%';
            }

            draw() {
                if (!this.start || !this.end) return;
                
                const ctx = this.ctx;
                const cs = this.canvasSize;
                
                ctx.clearRect(0, 0, cs, cs);
                
                this.drawBackground(ctx);
                this.drawGrid(ctx);
                
                if (this.showHint && !this.gameWon) {
                    this.drawHintPath(ctx);
                }
                
                this.drawVisited(ctx);
                this.drawUserPath(ctx);
                this.drawEndpoints(ctx);
                
                this.particles.update();
                this.particles.draw(ctx);
            }

            drawBackground(ctx) {
                const gradient = ctx.createRadialGradient(
                    this.canvasSize/2, this.canvasSize/2, 0,
                    this.canvasSize/2, this.canvasSize/2, this.canvasSize
                );
                gradient.addColorStop(0, 'rgba(0, 212, 255, 0.02)');
                gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
                ctx.fillStyle = gradient;
                ctx.fillRect(0, 0, this.canvasSize, this.canvasSize);
            }

            drawGrid(ctx) {
                const ox = this.offsetX;
                const oy = this.offsetY;
                const s = this.cellSize;
                
                ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
                ctx.lineWidth = 1;
                
                for (let r = 0; r <= this.rows; r++) {
                    ctx.beginPath();
                    ctx.moveTo(ox, oy + r * s);
                    ctx.lineTo(ox + this.cols * s, oy + r * s);
                    ctx.stroke();
                }
                
                for (let c = 0; c <= this.cols; c++) {
                    ctx.beginPath();
                    ctx.moveTo(ox + c * s, oy);
                    ctx.lineTo(ox + c * s, oy + this.rows * s);
                    ctx.stroke();
                }
                
                ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
                for (let r = 0; r < this.rows; r++) {
                    for (let c = 0; c < this.cols; c++) {
                        if (this.obstacles.has(`${r},${c}`)) {
                            const x = ox + c * s;
                            const y = oy + r * s;
                            
                            ctx.fillRect(x + 2, y + 2, s - 4, s - 4);
                            
                            ctx.strokeStyle = 'rgba(255, 50, 50, 0.3)';
                            ctx.lineWidth = 2;
                            ctx.beginPath();
                            ctx.moveTo(x + s*0.3, y + s*0.3);
                            ctx.lineTo(x + s*0.7, y + s*0.7);
                            ctx.moveTo(x + s*0.7, y + s*0.3);
                            ctx.lineTo(x + s*0.3, y + s*0.7);
                            ctx.stroke();
                        }
                    }
                }
            }

            drawVisited(ctx) {
                const ox = this.offsetX;
                const oy = this.offsetY;
                const s = this.cellSize;
                
                for (let i = 0; i < this.userPath.length; i++) {
                    const {r, c} = this.userPath[i];
                    const x = ox + c * s;
                    const y = oy + r * s;
                    const cx = x + s/2;
                    const cy = y + s/2;
                    
                    const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, s/2);
                    gradient.addColorStop(0, 'rgba(0, 212, 255, 0.15)');
                    gradient.addColorStop(1, 'rgba(0, 212, 255, 0.02)');
                    ctx.fillStyle = gradient;
                    ctx.fillRect(x + 1, y + 1, s - 2, s - 2);
                    
                    ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
                    ctx.lineWidth = 1.5;
                    ctx.strokeRect(x + 2, y + 2, s - 4, s - 4);
                }
            }

            drawUserPath(ctx) {
                if (this.userPath.length < 2) return;
                
                const ox = this.offsetX;
                const oy = this.offsetY;
                const s = this.cellSize;
                
                ctx.save();
                ctx.lineCap = 'round';
                ctx.lineJoin = 'round';
                
                ctx.shadowBlur = 20;
                ctx.shadowColor = '#00d4ff';
                ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
                ctx.lineWidth = s * 0.35;
                
                ctx.beginPath();
                const first = this.userPath[0];
                ctx.moveTo(ox + first.c * s + s/2, oy + first.r * s + s/2);
                
                for (let i = 1; i < this.userPath.length; i++) {
                    const p = this.userPath[i];
                    ctx.lineTo(ox + p.c * s + s/2, oy + p.r * s + s/2);
                }
                ctx.stroke();
                
                ctx.shadowBlur = 10;
                ctx.shadowColor = '#00d4ff';
                ctx.strokeStyle = '#00d4ff';
                ctx.lineWidth = s * 0.2;
                
                ctx.setLineDash([s * 0.3, s * 0.5]);
                ctx.lineDashOffset = -this.animOffset;
                
                ctx.beginPath();
                ctx.moveTo(ox + first.c * s + s/2, oy + first.r * s + s/2);
                for (let i = 1; i < this.userPath.length; i++) {
                    const p = this.userPath[i];
                    ctx.lineTo(ox + p.c * s + s/2, oy + p.r * s + s/2);
                }
                ctx.stroke();
                
                ctx.restore();
            }

            drawHintPath(ctx) {
                if (this.userPath.length === 0 || !this.solutionPath || this.solutionPath.length === 0) return;
                
                const current = this.userPath[this.userPath.length - 1];
                const currentIdx = this.solutionPath.findIndex(p => p.r === current.r && p.c === current.c);
                if (currentIdx === -1 || currentIdx >= this.solutionPath.length - 1) return;
                
                const ox = this.offsetX;
                const oy = this.offsetY;
                const s = this.cellSize;
                
                ctx.save();
                ctx.lineCap = 'round';
                ctx.lineJoin = 'round';
                ctx.setLineDash([4, 4]);
                ctx.lineDashOffset = this.animOffset * 0.5;
                
                ctx.strokeStyle = 'rgba(255, 215, 0, 0.4)';
                ctx.lineWidth = s * 0.12;
                ctx.shadowBlur = 5;
                ctx.shadowColor = 'rgba(255, 215, 0, 0.3)';
                
                ctx.beginPath();
                const start = this.solutionPath[currentIdx];
                ctx.moveTo(ox + start.c * s + s/2, oy + start.r * s + s/2);
                
                for (let i = currentIdx + 1; i < this.solutionPath.length; i++) {
                    const p = this.solutionPath[i];
                    ctx.lineTo(ox + p.c * s + s/2, oy + p.r * s + s/2);
                }
                ctx.stroke();
                ctx.restore();
            }

            drawEndpoints(ctx) {
                const ox = this.offsetX;
                const oy = this.offsetY;
                const s = this.cellSize;
                const pulse = Math.sin(this.pulsePhase) * 0.15 + 1;
                
                if (!this.start || !this.end) return;
                
                // 起点
                const sx = ox + this.start.c * s + s/2;
                const sy = oy + this.start.r * s + s/2;
                
                ctx.save();
                ctx.shadowBlur = 30;
                ctx.shadowColor = '#00ff88';
                
                ctx.fillStyle = 'rgba(0, 255, 136, 0.2)';
                ctx.beginPath();
                ctx.arc(sx, sy, s * 0.4 * pulse, 0, Math.PI * 2);
                ctx.fill();
                
                ctx.fillStyle = '#00ff88';
                ctx.beginPath();
                ctx.arc(sx, sy, s * 0.18, 0, Math.PI * 2);
                ctx.fill();
                
                ctx.shadowBlur = 0;
                ctx.fillStyle = '#000';
                ctx.font = `bold ${s * 0.2}px sans-serif`;
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText('起', sx, sy);
                ctx.restore();
                
                // 终点
                const ex = ox + this.end.c * s + s/2;
                const ey = oy + this.end.r * s + s/2;
                
                ctx.save();
                ctx.shadowBlur = 30;
                ctx.shadowColor = '#ff3366';
                
                ctx.fillStyle = 'rgba(255, 51, 102, 0.2)';
                ctx.beginPath();
                ctx.arc(ex, ey, s * 0.4 * pulse, 0, Math.PI * 2);
                ctx.fill();
                
                ctx.fillStyle = '#ff3366';
                ctx.beginPath();
                ctx.arc(ex, ey, s * 0.18, 0, Math.PI * 2);
                ctx.fill();
                
                ctx.shadowBlur = 0;
                ctx.fillStyle = '#fff';
                ctx.font = `bold ${s * 0.2}px sans-serif`;
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText('终', ex, ey);
                ctx.restore();
            }

            loop() {
                this.animOffset += 2;
                this.pulsePhase += 0.05;
                this.draw();
                requestAnimationFrame(() => this.loop());
            }
        }

        let game;
        window.addEventListener('DOMContentLoaded', () => {
            game = new Game();
        });
    </script>
</body>
</html>

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐