一笔画游戏html 开源
NEON PATH - 霓虹
一笔画游戏html 开源
NEON PATH - 霓虹一笔画
项目简介
NEON PATH 是一款基于递归回溯算法生成的一笔画无尽关卡网页游戏。游戏采用霓虹风格视觉效果,结合粒子特效和音效,提供沉浸式的游戏体验。玩家需要从起点出发,一笔画完所有格子,到达终点,随着关卡增加,难度逐渐提升。
功能特性
无限关卡生成:使用递归回溯算法自动生成可解的一笔画谜题
霓虹视觉效果:采用渐变色彩、粒子特效和发光效果
渐进难度:随着关卡增加,网格大小和障碍物数量逐渐增加
触摸/鼠标控制:支持触摸屏和鼠标操作
进度保存:自动保存游戏进度到本地存储
音效系统:移动、回溯、胜利等动作均有相应音效
响应式设计:适配各种屏幕尺寸
提示系统:显示下一步正确路径提示
游戏规则
从绿色起点开始,到红色终点结束
一笔画完所有格子,不重复、不遗漏
只能水平或垂直移动到相邻格子
避开障碍物(红色格子)
路径不可交叉
操作方法
鼠标/触摸:点击起点开始,拖动到相邻格子绘制路径
回退:点击已绘制的路径可回退到该点
提示按钮:高亮显示下一步正确路径
重置按钮:重置当前关卡
下一关按钮:完成当前关卡后进入下一关
技术实现
核心算法
递归回溯算法:生成哈密顿路径,确保每个关卡都有解
Warnsdorff规则:优化路径生成,提高成功率
连通性检查:确保网格在放置障碍物后仍然连通
技术栈
HTML5 Canvas:游戏界面渲染
原生JavaScript:游戏逻辑实现
CSS3:霓虹风格UI和动画效果
Web Audio API:音效系统
LocalStorage:进度保存
关键类
LevelGenerator - 关卡生成器
递归回溯生成哈密顿路径
智能障碍物放置
连通性验证
ParticleSystem - 粒子系统
路径绘制粒子特效
胜利粒子爆发效果
粒子生命周期管理
SoundManager - 音效管理器
Web Audio API音效生成
移动、回溯、胜利音效
音频上下文管理
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 # 说明文档开发说明
自定义设置
可以调整以下参数自定义游戏体验:
难度配置:在
LevelGenerator.getConfig()中修改关卡配置视觉效果:修改CSS中的颜色变量和动画参数
音效参数:调整
SoundManager类中的频率和时长网格参数:修改游戏初始化时的默认网格大小
算法优化
使用Warnsdorff规则优化路径搜索
限制生成尝试次数避免无限循环
障碍物放置算法确保连通性
缓存已生成关卡提高性能
性能特点
60fps流畅动画
内存高效管理
触摸事件防抖处理
响应式布局适配
离线可用
扩展可能性
关卡编辑器:允许玩家自定义关卡
成就系统:添加游戏成就和挑战
多人模式:添加计时赛或合作模式
社交分享:分享通关成绩
主题切换:提供多种视觉主题
技术挑战与解决方案
挑战
解决方案
无尽关卡生成
递归回溯算法 + 动态难度调整
触摸控制精度
网格坐标转换 + 防抖处理
视觉性能
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>
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)