mSnake / templates /indexOLD.html
zxciop's picture
Rename templates/index.html to templates/indexOLD.html
ad43c96 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- For loading alternate resource paths
<script type="module" src="{{ url_for('static', filename='game.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
-->
<title>Matrix Snake 3D - Enhanced</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000;
color: #0f0;
font-family: 'Courier New', Courier, monospace;
}
canvas {
display: block;
}
.game-ui {
position: absolute;
padding: 10px;
background-color: rgba(0, 20, 0, 0.8);
border: 1px solid #0f0;
border-radius: 5px;
font-size: 1.2em;
pointer-events: none;
}
#info {
top: 10px;
left: 10px;
}
#combo {
top: 10px;
right: 10px;
color: #0ff;
opacity: 0;
transition: opacity 0.3s;
}
#gameScreen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(0, 10, 0, 0.8);
z-index: 10;
}
#startScreen, #gameOverScreen {
padding: 30px;
background-color: rgba(0, 30, 0, 0.9);
border: 2px solid #0f0;
border-radius: 10px;
text-align: center;
max-width: 500px;
}
#gameOverScreen {
border-color: #f00;
}
.title {
font-size: 2.5em;
margin-bottom: 20px;
text-shadow: 0 0 10px #0f0;
}
.subtitle {
font-size: 1.2em;
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 10px 20px;
margin: 10px;
background-color: rgba(0, 80, 0, 0.8);
border: 1px solid #0f0;
border-radius: 5px;
color: #0f0;
cursor: pointer;
transition: all 0.2s;
pointer-events: auto;
}
.button:hover {
background-color: rgba(0, 120, 0, 0.9);
transform: scale(1.05);
}
.controls {
margin-top: 20px;
font-size: 0.9em;
opacity: 0.8;
}
#highScores {
margin-top: 20px;
text-align: left;
width: 100%;
}
#highScores table {
width: 100%;
border-collapse: collapse;
}
#highScores th, #highScores td {
padding: 5px;
border-bottom: 1px solid rgba(0, 255, 0, 0.5);
}
#touchControls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: none; /* Hidden by default, shown on mobile */
}
.touchBtn {
width: 60px;
height: 60px;
background-color: rgba(0, 50, 0, 0.5);
border: 1px solid #0f0;
border-radius: 50%;
margin: 5px;
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 20px;
cursor: pointer;
pointer-events: auto;
}
/* Matrix animation background */
#matrixCanvas {
position: fixed;
top: 0;
left: 0;
z-index: -1;
}
</style>
</head>
<body>
<!-- Matrix background -->
<canvas id="matrixCanvas"></canvas>
<!-- Game canvas -->
<canvas id="gameCanvas"></canvas>
<!-- Game UI -->
<div id="info" class="game-ui">Score: 0 | High: 0</div>
<div id="combo" class="game-ui">Combo x1!</div>
<!-- Touch controls for mobile -->
<div id="touchControls">
<div class="touchBtn" id="upBtn"></div>
<div style="display: flex;">
<div class="touchBtn" id="leftBtn"></div>
<div class="touchBtn" id="downBtn"></div>
<div class="touchBtn" id="rightBtn"></div>
</div>
</div>
<!-- Game screens -->
<div id="gameScreen">
<div id="startScreen">
<div class="title">MATRIX SNAKE 3D</div>
<div class="subtitle">Navigate the digital realm. Collect data packets. Avoid system firewalls.</div>
<div class="button" id="startBtn">START GAME</div>
<div class="button" id="difficultyBtn">DIFFICULTY: NORMAL</div>
<div class="controls">
Use Arrow Keys to change direction<br>
Press P to pause the game
</div>
<div id="highScores">
<h3>HIGH SCORES</h3>
<table id="scoresTable">
<tr><th>RANK</th><th>SCORE</th><th>DIFFICULTY</th></tr>
</table>
</div>
</div>
<div id="gameOverScreen" style="display: none;">
<div class="title" style="color: #f00;">SYSTEM FAILURE</div>
<div id="finalScore" class="subtitle">Final Score: 0</div>
<div class="button" id="restartBtn">RESTART</div>
<div class="button" id="menuBtn">MAIN MENU</div>
</div>
<div id="pauseScreen" style="display: none;">
<div class="title">PAUSED</div>
<div class="subtitle">Press P to resume</div>
<div class="button" id="resumeBtn">RESUME</div>
<div class="button" id="quitBtn">QUIT</div>
</div>
</div>
<!-- Audio elements -->
<audio id="eatSound" preload="auto">
<source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAANIAqJWUEQAFO+gRc5TRJIkiRJEiL///////////8RERERERERVVVVVVVVVVVVVVJEREREREVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/jGMQJA/Aa1flBABBTpGX9hDGMYxw7/+MMYxd/4wxIiI9////jDEQ7/jdEiJERERBaIiIzMzMzIiIiP//MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM/+MYxB4AAANIAAAAADMzMzMzMzMzMzMzMzMzMzMzM" type="audio/mpeg">
</audio>
<audio id="gameOverSound" preload="auto">
<source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAAKMFqZVQEwAhGKzc+FSIiIiIiIiIj4+Pj4+Pj4+Pj4+JIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jIMQNAAAP8AEAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jEMQQAAAP8AAAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIg==" type="audio/mpeg">
</audio>
<audio id="bgMusic" loop preload="auto">
<source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAAJcAKRWQEQAFNfQRc5znOc5znP/////////uc5znOc5znOc5znEREREREREREREMYxjGMY/+MYxBEJkFahX4wwAjGMYxjGMYxjGMYxERERERERESIiIiL//////////////+MYxBQG4AqlX8MQAu/////////////////////jIMQVBVwCqVfwBAC/////////////////" type="audio/mpeg">
</audio>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.163.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.163.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
import { EffectComposer } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/UnrealBloomPass.js';
// Game configuration
const CONFIG = {
GRID_SIZE: 25, // Number of units across/deep
CELL_SIZE: 1, // Size of each grid cell/snake segment
BASE_SPEED: 150, // Base milliseconds between updates
DIFFICULTY_LEVELS: {
'EASY': { speedMultiplier: 1.3, obstacleMultiplier: 0.5 },
'NORMAL': { speedMultiplier: 1.0, obstacleMultiplier: 1.0 },
'HARD': { speedMultiplier: 0.7, obstacleMultiplier: 1.5 }
},
MAX_OBSTACLE_COUNT: 10, // Maximum number of obstacles
FOOD_TYPES: [
{ type: 'regular', color: 0x00ff00, points: 1, speedEffect: 0 },
{ type: 'special', color: 0x00ffff, points: 5, speedEffect: -10 },
{ type: 'rare', color: 0xff00ff, points: 10, speedEffect: 10 }
],
COMBO_TIMEOUT: 5000, // Milliseconds to get next food for combo
HIGH_SCORES_COUNT: 5 // Number of high scores to save
};
// --- Particle System for Effects ---
class ParticleSystem {
constructor(scene) {
this.scene = scene;
this.particles = [];
this.geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2); // Shared geometry
}
createFoodEffect(position, color) {
const count = 20;
for (let i = 0; i < count; i++) {
const material = new THREE.MeshBasicMaterial({
color: color || 0x00ff00,
transparent: true,
opacity: 0.9
});
const particle = new THREE.Mesh(this.geometry, material);
particle.position.copy(position);
const velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.1,
(Math.random()) * 0.1,
(Math.random() - 0.5) * 0.1
);
this.scene.add(particle);
this.particles.push({
mesh: particle,
velocity: velocity,
life: 1.0,
decay: 0.02 + Math.random() * 0.03
});
}
}
update() {
for (let i = this.particles.length - 1; i >= 0; i--) {
const particle = this.particles[i];
particle.mesh.position.add(particle.velocity);
particle.velocity.y -= 0.003; // Gravity
particle.life -= particle.decay;
particle.mesh.material.opacity = particle.life;
if (particle.life <= 0) {
this.scene.remove(particle.mesh);
particle.mesh.material.dispose();
// particle.mesh.geometry.dispose(); // Geometry is shared, don't dispose here
this.particles.splice(i, 1);
}
}
}
clear() {
for (const particle of this.particles) {
this.scene.remove(particle.mesh);
particle.mesh.material.dispose();
}
this.particles = [];
// Note: Shared geometry (this.geometry) is not disposed here,
// as it might be needed again. Dispose it if ParticleSystem itself is destroyed.
}
}
// Game state management
const GameState = {
MENU: 'menu',
PLAYING: 'playing',
PAUSED: 'paused',
GAME_OVER: 'gameOver',
currentState: 'menu',
changeState(newState) {
this.currentState = newState;
switch(newState) {
case this.MENU:
document.getElementById('gameScreen').style.display = 'flex';
document.getElementById('startScreen').style.display = 'block';
document.getElementById('gameOverScreen').style.display = 'none';
document.getElementById('pauseScreen').style.display = 'none';
break;
case this.PLAYING:
document.getElementById('gameScreen').style.display = 'none';
break;
case this.PAUSED:
document.getElementById('gameScreen').style.display = 'flex';
document.getElementById('startScreen').style.display = 'none';
document.getElementById('gameOverScreen').style.display = 'none';
document.getElementById('pauseScreen').style.display = 'block';
break;
case this.GAME_OVER:
document.getElementById('gameScreen').style.display = 'flex';
document.getElementById('startScreen').style.display = 'none';
document.getElementById('gameOverScreen').style.display = 'block';
document.getElementById('pauseScreen').style.display = 'none';
document.getElementById('gameOverSound').play();
break;
}
}
};
// --- Matrix Rain Background Effect ---
class MatrixRain {
constructor() {
this.canvas = document.getElementById('matrixCanvas');
this.ctx = this.canvas.getContext('2d');
this.resize();
this.fontSize = 14;
this.columns = Math.floor(this.canvas.width / this.fontSize);
this.drops = [];
this.characters = '01アイウエオカキクケコサシスセソタチツテトナニヌネ<>{}[]()+-*/%=#@&?*:・゚✧ ≡ ░▒░▒░▒▓█║│·▓▒░█░▒▓█║│·▓▒░█░▒▓█║│·▓▒░█䷀ ▙⁞ ░▒▓█║│ ·▓▒░█▄▀■■▄▬▌▐ ⁞▏▄▀■■▄▬▌▐ ▄▀■■▄▬▌▐ . ▛ ⁞▏ ▏ ⁚⁝ .';
this.resetDrops();
this.animate = this.animate.bind(this);
this.animate(); // Start animation
window.addEventListener('resize', this.handleResize.bind(this));
}
handleResize() {
this.resize();
this.columns = Math.floor(this.canvas.width / this.fontSize);
this.resetDrops();
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
resetDrops() {
this.drops = [];
for(let i = 0; i < this.columns; i++) {
this.drops[i] = Math.floor(Math.random() * -100); // Start off-screen
}
}
animate() {
// Show rain on all screens as per previous request, including PLAYING
if (GameState.currentState === GameState.MENU ||
GameState.currentState === GameState.PAUSED ||
GameState.currentState === GameState.GAME_OVER ||
GameState.currentState === GameState.PLAYING) {
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; // Fading effect
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.fillStyle = '#0f0'; // Matrix character color
this.ctx.font = this.fontSize + 'px monospace';
for(let i = 0; i < this.drops.length; i++) {
const text = this.characters.charAt(Math.floor(Math.random() * this.characters.length));
this.ctx.fillText(text, i * this.fontSize, this.drops[i] * this.fontSize);
if(this.drops[i] * this.fontSize > this.canvas.height && Math.random() > 0.975) {
this.drops[i] = 0; // Reset drop
}
this.drops[i]++;
}
}
requestAnimationFrame(this.animate);
}
} // <-- Make sure this curly brace properly closes the MatrixRain class
// --- Object pooling for performance optimization ---
class ObjectPool {
constructor(createFunc, initialCount = 10) {
this.pool = [];
this.createFunc = createFunc;
for (let i = 0; i < initialCount; i++) {
this.pool.push(this.createFunc());
}
}
get() {
if (this.pool.length > 0) {
return this.pool.pop();
}
return this.createFunc();
}
release(object) {
this.pool.push(object);
}
clear() {
this.pool = [];
// Note: This doesn't dispose Three.js objects.
// Disposal should be handled by the code that uses the pool
// when objects are truly no longer needed by the scene.
}
}
// --- Main Game Class ---
class SnakeGame {
constructor() {
this.scene = null;
this.camera = null;
this.renderer = null;
this.composer = null; // For post-processing
this.snake = [];
this.food = null;
this.obstacles = [];
this.direction = new THREE.Vector3(CONFIG.CELL_SIZE, 0, 0);
this.nextDirection = new THREE.Vector3(CONFIG.CELL_SIZE, 0, 0);
this.score = 0;
this.highScore = this.loadHighScores()[0]?.score || 0;
this.gameSpeed = CONFIG.BASE_SPEED;
this.lastUpdateTime = 0;
this.isGameOver = false;
this.isPaused = false;
this.gameLoopId = null;
this.bounds = Math.floor(CONFIG.GRID_SIZE / 2) * CONFIG.CELL_SIZE;
this.obstacleCount = CONFIG.MAX_OBSTACLE_COUNT;
this.comboCount = 0;
this.lastFoodTime = 0;
this.currentDifficulty = 'NORMAL';
this.particleSystem = null;
this.headLight = null;
this.materials = {
snakeHead: new THREE.MeshStandardMaterial({
color: 0x39FF14, emissive: 0x39FF14, roughness: 0.8, metalness: 0.22
}),
snakeBody: new THREE.MeshStandardMaterial({
color: 0x00ff00, emissive: 0x005500, roughness: 0.3, metalness: 0.72
}),
obstacle: new THREE.MeshStandardMaterial({ // Changed to Standard for lighting
color: 0xff0000, emissive: 0x550000, roughness: 0.5, metalness: 0.1
}),
// Food materials defined per type in chooseFoodType
};
this.geometries = {
segment: new THREE.BoxGeometry(CONFIG.CELL_SIZE, CONFIG.CELL_SIZE, CONFIG.CELL_SIZE),
foodBox: new THREE.BoxGeometry(CONFIG.CELL_SIZE * 0.8, CONFIG.CELL_SIZE * 0.8, CONFIG.CELL_SIZE * 0.8),
foodSphere: new THREE.SphereGeometry(CONFIG.CELL_SIZE * 0.5, 16, 12),
foodTetrahedron: new THREE.TetrahedronGeometry(CONFIG.CELL_SIZE * 0.6, 0),
obstacle: new THREE.BoxGeometry(CONFIG.CELL_SIZE, CONFIG.CELL_SIZE * 1.5, CONFIG.CELL_SIZE) // Taller obstacles
};
this.segmentPool = new ObjectPool(() => {
const segment = new THREE.Mesh(this.geometries.segment, this.materials.snakeBody.clone());
// segment.castShadow = true; // If using shadows
return segment;
}, 20);
this.obstaclePool = new ObjectPool(() => {
const obstacle = new THREE.Mesh(this.geometries.obstacle, this.materials.obstacle.clone());
// obstacle.castShadow = true; // If using shadows
return obstacle;
}, CONFIG.MAX_OBSTACLE_COUNT * 1.5);
this.init(); // Call init before matrixRain if matrixRain depends on game elements
this.matrixRain = new MatrixRain(); // Initialize after main game setup if it interacts
this.setupEventListeners();
this.updateHighScoresTable();
GameState.changeState(GameState.MENU); // Start in menu
}
init() {
this.scene = new THREE.Scene();
this.scene.background = null; // For Matrix rain to show through
this.camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.set(0, CONFIG.GRID_SIZE * 0.8, CONFIG.GRID_SIZE * 0.9);
this.camera.lookAt(0, -CONFIG.GRID_SIZE * 0.1, 0);
this.renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('gameCanvas'),
antialias: true,
alpha: true // Crucial for transparency
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
// this.renderer.shadowMap.enabled = true; // If you add shadows
// Post-processing for bloom
const renderPass = new RenderPass(this.scene, this.camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.8, 0.75);
// bloomPass.threshold = 0;
// bloomPass.strength = 1.5; // Play with these
// bloomPass.radius = 0.5;
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(renderPass);
this.composer.addPass(bloomPass);
const gridHelper = new THREE.GridHelper(CONFIG.GRID_SIZE * CONFIG.CELL_SIZE, CONFIG.GRID_SIZE, 0x008800, 0x004400);
gridHelper.position.y = -CONFIG.CELL_SIZE / 2; // Align with snake plane
this.scene.add(gridHelper);
const ambientLight = new THREE.AmbientLight(0x404060, 1); // Soft ambient
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffee, 1.5);
directionalLight.position.set(8, 15, 10);
// directionalLight.castShadow = true;
// directionalLight.shadow.mapSize.width = 1024;
// directionalLight.shadow.mapSize.height = 1024;
this.scene.add(directionalLight);
this.headLight = new THREE.PointLight(0x39FF14, 2, CONFIG.CELL_SIZE * 5); // Brighter, green light
this.headLight.castShadow = false; // Point lights can be expensive for shadows
this.scene.add(this.headLight);
this.particleSystem = new ParticleSystem(this.scene);
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.composer.setSize(window.innerWidth, window.innerHeight); // Resize composer too
}, false);
}
placeFood() {
let foodPos;
let validPosition = false;
let attempts = 0;
const maxAttempts = 100;
const numCells = CONFIG.GRID_SIZE;
while (!validPosition && attempts < maxAttempts) {
// Generate random indices within the grid
const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
foodPos = new THREE.Vector3(
xIndex * CONFIG.CELL_SIZE,
0, // Food on the grid plane
zIndex * CONFIG.CELL_SIZE
);
let collisionWithSnake = this.snake.some(segment =>
segment.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2 // Use distanceSquared
);
let collisionWithObstacle = this.obstacles.some(obstacle =>
obstacle.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2
);
validPosition = !collisionWithSnake && !collisionWithObstacle;
attempts++;
}
if (validPosition) {
this.food.position.copy(foodPos);
} else {
console.warn("Could not find valid position for food. Placing at a default safe spot.");
// Fallback: try placing near origin, hoping it's clear
this.food.position.set(
(Math.floor(Math.random() * 3) - 1) * CONFIG.CELL_SIZE,
0,
(Math.floor(Math.random() * 3) - 1) * CONFIG.CELL_SIZE
);
}
}
createObstacles() {
this.obstacles.forEach(obstacle => {
this.scene.remove(obstacle);
this.obstaclePool.release(obstacle);
});
this.obstacles = [];
const numCells = CONFIG.GRID_SIZE;
for (let i = 0; i < this.obstacleCount; i++) {
let obstaclePos;
let validPosition = false;
let attempts = 0;
const maxAttempts = 50;
while (!validPosition && attempts < maxAttempts) {
const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
obstaclePos = new THREE.Vector3(
xIndex * CONFIG.CELL_SIZE,
0, // Obstacles on the grid plane, but mesh is taller
zIndex * CONFIG.CELL_SIZE
);
let tooCloseToStart = obstaclePos.lengthSq() < (CONFIG.CELL_SIZE * 4)**2; // Check squared length
let collisionWithSnake = this.snake.some(segment =>
segment.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 2)**2
);
let collisionWithOtherObstacle = this.obstacles.some(existingObstacle =>
existingObstacle.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 1.5)**2 // Ensure spacing
);
validPosition = !tooCloseToStart && !collisionWithSnake && !collisionWithOtherObstacle;
attempts++;
}
if (validPosition) {
const obstacle = this.obstaclePool.get();
obstacle.position.copy(obstaclePos);
obstacle.position.y = (CONFIG.CELL_SIZE * 1.5 - CONFIG.CELL_SIZE) / 2 - CONFIG.CELL_SIZE / 2; // Adjust Y to sit on grid
this.obstacles.push(obstacle);
this.scene.add(obstacle);
}
}
}
clearGameObjects() {
this.snake.forEach(segment => {
this.scene.remove(segment);
this.segmentPool.release(segment);
});
this.snake = [];
if (this.food) {
this.scene.remove(this.food);
// No pool for food as it's a single, changing object
}
this.food = null;
this.obstacles.forEach(obstacle => {
this.scene.remove(obstacle);
this.obstaclePool.release(obstacle);
});
this.obstacles = [];
this.particleSystem.clear();
}
update(time) {
if (this.isPaused || this.isGameOver || GameState.currentState !== GameState.PLAYING) {
return;
}
if (time - this.lastUpdateTime < this.gameSpeed) {
return;
}
this.lastUpdateTime = time;
this.direction.copy(this.nextDirection); // More robust copy
const head = this.snake[0];
const newHeadPos = head.position.clone().add(this.direction); // CELL_SIZE is incorporated in direction
const halfGridWorld = (CONFIG.GRID_SIZE / 2) * CONFIG.CELL_SIZE;
if (
newHeadPos.x >= halfGridWorld || newHeadPos.x < -halfGridWorld ||
newHeadPos.z >= halfGridWorld || newHeadPos.z < -halfGridWorld
) {
this.triggerGameOver();
return;
}
for (let i = 1; i < this.snake.length; i++) {
if (newHeadPos.distanceToSquared(this.snake[i].position) < (CONFIG.CELL_SIZE * 0.1)**2) { // Tighter collision
this.triggerGameOver();
return;
}
}
for (const obstacle of this.obstacles) {
if (newHeadPos.distanceToSquared(obstacle.position) < (CONFIG.CELL_SIZE * 0.75)**2) { // Check collision with obstacle
this.triggerGameOver();
return;
}
}
const newHead = this.segmentPool.get();
newHead.position.copy(newHeadPos);
newHead.material = this.materials.snakeHead; // Head material
if (this.snake.length > 0) {
this.snake[0].material = this.materials.snakeBody; // Old head becomes body
}
this.snake.unshift(newHead);
this.scene.add(newHead);
this.headLight.position.copy(newHeadPos); // Light follows head
if (this.food && newHeadPos.distanceToSquared(this.food.position) < (CONFIG.CELL_SIZE * 0.75)**2) {
const foodType = this.food.userData;
const basePoints = foodType.points || 1;
const currentTime = performance.now();
if (currentTime - this.lastFoodTime < CONFIG.COMBO_TIMEOUT) {
this.comboCount++;
} else {
this.comboCount = 1;
}
this.lastFoodTime = currentTime;
const points = basePoints * this.comboCount;
this.score += points;
if (this.comboCount > 1) {
const comboElement = document.getElementById('combo');
comboElement.textContent = `Combo x${this.comboCount}! +${points}`;
comboElement.style.opacity = 1;
setTimeout(() => { comboElement.style.opacity = 0; }, 2000);
}
document.getElementById('info').textContent = `Score: ${this.score} | High: ${Math.max(this.score, this.highScore)}`;
if (foodType.speedEffect) {
this.gameSpeed = Math.max(50, this.gameSpeed + foodType.speedEffect); // Note: special food had -10, rare had +10
}
document.getElementById('eatSound').currentTime = 0;
document.getElementById('eatSound').play();
this.particleSystem.createFoodEffect(this.food.position.clone(), new THREE.Color(foodType.color));
this.chooseFoodType(); // Gets new food type, applies material
this.placeFood(); // Places it
} else {
const tail = this.snake.pop();
this.scene.remove(tail);
this.segmentPool.release(tail);
}
this.particleSystem.update();
for (let i = 0; i < this.snake.length; i++) {
const segment = this.snake[i];
segment.rotation.y += Math.sin(time * 0.0001 + i * 0.1) * 0.01; // Slower, more subtle
segment.position.y = Math.sin(time * 0.002 + i * 0.2) * 0.15; // Subtle bob
}
if (this.food) {
this.food.rotation.x += 0.01;
this.food.rotation.y += 0.02;
this.food.position.y = Math.sin(time * 0.0025) * 0.25 + 0.1; // Bobbing food
}
}
chooseFoodType() {
const rand = Math.random();
let foodTypeData;
let geometry;
if (rand < 0.05) { // 5% rare
foodTypeData = CONFIG.FOOD_TYPES[2];
geometry = this.geometries.foodTetrahedron;
} else if (rand < 0.25) { // 20% special
foodTypeData = CONFIG.FOOD_TYPES[1];
geometry = this.geometries.foodSphere;
} else { // 75% regular
foodTypeData = CONFIG.FOOD_TYPES[0];
geometry = this.geometries.foodBox;
}
const material = new THREE.MeshStandardMaterial({ // Use Standard for lighting
color: foodTypeData.color,
emissive: foodTypeData.color, // Make food glow
emissiveIntensity: 0.8,
roughness: 0.4,
metalness: 0.1
});
if (!this.food) {
this.food = new THREE.Mesh(geometry, material);
this.scene.add(this.food);
} else {
this.food.geometry.dispose(); // Dispose old geometry
this.food.geometry = geometry;
this.food.material.dispose(); // Dispose old material
this.food.material = material;
}
this.food.userData = foodTypeData;
}
resetGame() {
this.clearGameObjects();
// Stop sounds
['eatSound', 'gameOverSound', 'bgMusic'].forEach(id => {
const sound = document.getElementById(id);
sound.pause();
sound.currentTime = 0;
});
this.direction.set(CONFIG.CELL_SIZE, 0, 0);
this.nextDirection.set(CONFIG.CELL_SIZE, 0, 0);
this.score = 0;
this.gameSpeed = CONFIG.BASE_SPEED * CONFIG.DIFFICULTY_LEVELS[this.currentDifficulty].speedMultiplier;
this.isGameOver = false;
this.isPaused = false;
GameState.currentState = GameState.PLAYING; // Set this before calling things that depend on it.
this.comboCount = 0;
this.lastFoodTime = 0;
this.obstacleCount = Math.floor(CONFIG.MAX_OBSTACLE_COUNT * CONFIG.DIFFICULTY_LEVELS[this.currentDifficulty].obstacleMultiplier);
const startSegment = this.segmentPool.get();
startSegment.position.set(0, 0, 0);
startSegment.material = this.materials.snakeHead; // Start with head material
this.snake.push(startSegment);
this.scene.add(startSegment);
this.headLight.position.copy(startSegment.position); // Initial light pos
this.chooseFoodType();
this.placeFood();
this.createObstacles();
document.getElementById('info').textContent = `Score: ${this.score} | High: ${this.highScore}`;
const music = document.getElementById('bgMusic');
music.volume = 0.2; // Quieter music
music.play().catch(e => console.warn("Music play failed:", e)); // Catch promise
}
startGame() {
this.resetGame(); // This sets GameState.PLAYING internally now
GameState.changeState(GameState.PLAYING); // Ensure UI updates
this.lastUpdateTime = performance.now(); // Initialize lastUpdateTime
if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Clear old loop
this.gameLoop();
}
triggerGameOver() {
if (this.isGameOver) return; // Prevent multiple triggers
this.isGameOver = true;
document.getElementById('finalScore').textContent = `Final Score: ${this.score}`;
const highScores = this.loadHighScores();
if (this.score > 0) {
highScores.push({ score: this.score, difficulty: this.currentDifficulty, date: new Date().toLocaleDateString() });
highScores.sort((a, b) => b.score - a.score);
const topScores = highScores.slice(0, CONFIG.HIGH_SCORES_COUNT);
localStorage.setItem('snakeHighScores', JSON.stringify(topScores));
this.highScore = Math.max(this.score, this.highScore); // Update current session high score
}
this.updateHighScoresTable();
document.getElementById('bgMusic').pause();
GameState.changeState(GameState.GAME_OVER);
}
gameLoop(time) { // time is passed by requestAnimationFrame
if (!this.isGameOver && GameState.currentState === GameState.PLAYING) {
this.update(time || performance.now()); // Use performance.now if time is undefined initially
}
this.render();
if (!this.isGameOver) { // Only loop if not game over
this.gameLoopId = requestAnimationFrame(this.gameLoop.bind(this));
} else {
if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Ensure loop stops
}
}
render() {
// this.renderer.render(this.scene, this.camera); // When using composer
if (this.composer) {
this.composer.render();
} else if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
}
setupEventListeners() {
document.addEventListener('keydown', this.handleKeyDown.bind(this));
const touchControlsDiv = document.getElementById('touchControls');
const preventDefaultAndStopPropagation = (e) => { e.preventDefault(); e.stopPropagation(); };
document.getElementById('upBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, -CONFIG.CELL_SIZE); });
document.getElementById('downBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, CONFIG.CELL_SIZE); });
document.getElementById('leftBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(-CONFIG.CELL_SIZE, 0, 0); });
document.getElementById('rightBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(CONFIG.CELL_SIZE, 0, 0); });
const gameCanvas = document.getElementById('gameCanvas');
gameCanvas.addEventListener('touchstart', preventDefaultAndStopPropagation, { passive: false });
gameCanvas.addEventListener('touchmove', preventDefaultAndStopPropagation, { passive: false });
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
touchControlsDiv.style.display = 'block';
}
document.getElementById('startBtn').addEventListener('click', () => this.startGame());
document.getElementById('restartBtn').addEventListener('click', () => this.startGame());
document.getElementById('menuBtn').addEventListener('click', () => GameState.changeState(GameState.MENU));
document.getElementById('difficultyBtn').addEventListener('click', () => this.cycleDifficulty());
document.getElementById('resumeBtn').addEventListener('click', () => this.togglePause());
document.getElementById('quitBtn').addEventListener('click', () => {
this.isPaused = false; // Ensure unpaused
if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Stop game loop
document.getElementById('bgMusic').pause();
GameState.changeState(GameState.MENU);
});
}
cycleDifficulty() {
const difficulties = Object.keys(CONFIG.DIFFICULTY_LEVELS);
const currentIndex = difficulties.indexOf(this.currentDifficulty);
const nextIndex = (currentIndex + 1) % difficulties.length;
this.currentDifficulty = difficulties[nextIndex];
document.getElementById('difficultyBtn').textContent = `DIFFICULTY: ${this.currentDifficulty.toUpperCase()}`;
}
handleKeyDown(event) {
if (GameState.currentState === GameState.PLAYING && !this.isPaused) {
switch(event.key) {
case 'ArrowUp': case 'w': case 'W': this.handleDirectionChange(0, 0, -CONFIG.CELL_SIZE); event.preventDefault(); break;
case 'ArrowDown': case 's': case 'S': this.handleDirectionChange(0, 0, CONFIG.CELL_SIZE); event.preventDefault(); break;
case 'ArrowLeft': case 'a': case 'A': this.handleDirectionChange(-CONFIG.CELL_SIZE, 0, 0); event.preventDefault(); break;
case 'ArrowRight': case 'd': case 'D': this.handleDirectionChange(CONFIG.CELL_SIZE, 0, 0); event.preventDefault(); break;
case 'p': case 'P': this.togglePause(); event.preventDefault(); break;
}
} else if (GameState.currentState === GameState.PAUSED && (event.key === 'p' || event.key === 'P')) {
this.togglePause(); event.preventDefault();
} else if ((GameState.currentState === GameState.GAME_OVER || GameState.currentState === GameState.MENU) && event.key === 'Enter') {
this.startGame(); event.preventDefault();
}
}
handleDirectionChange(dx, dy, dz) {
const newDir = new THREE.Vector3(dx, dy, dz);
// Prevent 180-degree turns. Dot product will be negative.
// direction is already scaled by CELL_SIZE
if (this.direction.dot(newDir) < - (CONFIG.CELL_SIZE * CONFIG.CELL_SIZE * 0.5) ) { // Compare against negative magnitude squared
return;
}
this.nextDirection.copy(newDir);
}
togglePause() {
if (GameState.currentState !== GameState.PLAYING && GameState.currentState !== GameState.PAUSED) return;
this.isPaused = !this.isPaused;
if (this.isPaused) {
GameState.changeState(GameState.PAUSED);
document.getElementById('bgMusic').pause();
if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Pause game loop
} else {
GameState.changeState(GameState.PLAYING);
document.getElementById('bgMusic').play().catch(e => console.warn("Music play failed:", e));
this.lastUpdateTime = performance.now(); // Reset update timer to prevent jump
this.gameLoop(); // Resume game loop
}
}
loadHighScores() {
const scores = localStorage.getItem('snakeHighScores');
return scores ? JSON.parse(scores) : [];
}
updateHighScoresTable() {
const highScores = this.loadHighScores();
const table = document.getElementById('scoresTable');
while (table.rows.length > 1) { table.deleteRow(1); } // Clear existing
highScores.forEach((entry, index) => {
const row = table.insertRow(-1);
row.insertCell(0).textContent = index + 1;
row.insertCell(1).textContent = entry.score;
row.insertCell(2).textContent = entry.difficulty;
});
}
}
// Create and start the game
const game = new SnakeGame(); // GameState.MENU is set in constructor now.
// window.addEventListener('load', () => {
// // GameState.changeState(GameState.MENU); // Already handled in constructor
// });
</script>
</body>
</html>