Update templates/index.html

#1
by Match01 - opened
Files changed (1) hide show
  1. templates/index.html +3 -117
templates/index.html CHANGED
@@ -198,7 +198,7 @@
198
 
199
  <!-- Audio elements -->
200
  <audio id="eatSound" preload="auto">
201
- <source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAANIAqJWUEQAFO+gRc5TRJIkiRJEiL///////////8RERERERERVVVVVVVVVVVVVVJEREREREVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/jGMQJA/Aa1flBABBTpGX9hDGMYxw7/+MMYxd/4wxIiI9////jDEQ7/jdEiJERERBaIiIzMzMzIiIiP//MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM/+MYxB4AAANIAAAAADMzMzMzMzMzMzMzMzMzMzMzM" type="audio/mpeg">
202
  </audio>
203
  <audio id="gameOverSound" preload="auto">
204
  <source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAAKMFqZVQEwAhGKzc+FSIiIiIiIiIj4+Pj4+Pj4+Pj4+JIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jIMQNAAAP8AEAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jEMQQAAAP8AAAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIg==" type="audio/mpeg">
@@ -222,7 +222,6 @@
222
  import { EffectComposer } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js';
223
  import { RenderPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/RenderPass.js';
224
  import { UnrealBloomPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/UnrealBloomPass.js';
225
-
226
  // Game configuration
227
  const CONFIG = {
228
  GRID_SIZE: 25, // Number of units across/deep
@@ -242,7 +241,6 @@
242
  COMBO_TIMEOUT: 5000, // Milliseconds to get next food for combo
243
  HIGH_SCORES_COUNT: 5 // Number of high scores to save
244
  };
245
-
246
  // --- Particle System for Effects ---
247
  class ParticleSystem {
248
  constructor(scene) {
@@ -250,7 +248,6 @@
250
  this.particles = [];
251
  this.geometry = new THREE.BoxGeometry(0.2, 0.1, 0.2); // Shared geometry
252
  }
253
-
254
  createFoodEffect(position, color) {
255
  const count = 20;
256
  for (let i = 0; i < count; i++) {
@@ -275,7 +272,6 @@
275
  });
276
  }
277
  }
278
-
279
  update() {
280
  for (let i = this.particles.length - 1; i >= 0; i--) {
281
  const particle = this.particles[i];
@@ -283,7 +279,6 @@
283
  particle.velocity.y -= 0.005; // Gravity
284
  particle.life -= particle.decay;
285
  particle.mesh.material.opacity = particle.life;
286
-
287
  if (particle.life <= 0) {
288
  this.scene.remove(particle.mesh);
289
  particle.mesh.material.dispose();
@@ -292,7 +287,6 @@
292
  }
293
  }
294
  }
295
-
296
  clear() {
297
  for (const particle of this.particles) {
298
  this.scene.remove(particle.mesh);
@@ -303,7 +297,6 @@
303
  // as it might be needed again. Dispose it if ParticleSystem itself is destroyed.
304
  }
305
  }
306
-
307
  // Game state management
308
  const GameState = {
309
  MENU: 'menu',
@@ -311,7 +304,6 @@
311
  PAUSED: 'paused',
312
  GAME_OVER: 'gameOver',
313
  currentState: 'menu',
314
-
315
  changeState(newState) {
316
  this.currentState = newState;
317
  switch(newState) {
@@ -340,14 +332,12 @@
340
  }
341
  }
342
  };
343
-
344
  // --- Matrix Rain Background Effect ---
345
  class MatrixRain {
346
  constructor() {
347
  this.canvas = document.getElementById('matrixCanvas');
348
  this.ctx = this.canvas.getContext('2d');
349
  this.resize();
350
-
351
  this.fontSize = 14;
352
  this.columns = Math.floor(this.canvas.width / this.fontSize);
353
  this.drops = [];
@@ -359,44 +349,34 @@
359
  this.animate(); // Start animation
360
  window.addEventListener('resize', this.handleResize.bind(this));
361
  }
362
-
363
  handleResize() {
364
  this.resize();
365
  this.columns = Math.floor(this.canvas.width / this.fontSize);
366
  this.resetDrops();
367
  }
368
-
369
  resize() {
370
  this.canvas.width = window.innerWidth;
371
  this.canvas.height = window.innerHeight;
372
  }
373
-
374
  resetDrops() {
375
  this.drops = [];
376
  for(let i = 0; i < this.columns; i++) {
377
  this.drops[i] = Math.floor(Math.random() * -100); // Start off-screen
378
  }
379
  }
380
-
381
  animate() {
382
  // Show rain on all screens as per previous request, including PLAYING
383
  if (GameState.currentState === GameState.MENU ||
384
  GameState.currentState === GameState.PAUSED ||
385
  GameState.currentState === GameState.GAME_OVER ||
386
  GameState.currentState === GameState.PLAYING) {
387
-
388
- this.ctx.fillStyle = 'rgba(0, 0, 0, 0.03)'; // Fading effect
389
  this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
390
-
391
- this.ctx.fillStyle = '#00ff00'; // Matrix character color
392
- this.ctx.emissiveColor = '#76ff76';
393
- this.ctx.emissiveIntensity = '6';
394
  this.ctx.font = this.fontSize + 'px monospace';
395
-
396
  for(let i = 0; i < this.drops.length; i++) {
397
  const text = this.characters.charAt(Math.floor(Math.random() * this.characters.length));
398
  this.ctx.fillText(text, i * this.fontSize, this.drops[i] * this.fontSize);
399
-
400
  if(this.drops[i] * this.fontSize > this.canvas.height && Math.random() > 0.975) {
401
  this.drops[i] = 0; // Reset drop
402
  }
@@ -406,29 +386,24 @@
406
  requestAnimationFrame(this.animate);
407
  }
408
  } // <-- Make sure this curly brace properly closes the MatrixRain class
409
-
410
  // --- Object pooling for performance optimization ---
411
  class ObjectPool {
412
  constructor(createFunc, initialCount = 10) {
413
  this.pool = [];
414
  this.createFunc = createFunc;
415
-
416
  for (let i = 0; i < initialCount; i++) {
417
  this.pool.push(this.createFunc());
418
  }
419
  }
420
-
421
  get() {
422
  if (this.pool.length > 0) {
423
  return this.pool.pop();
424
  }
425
  return this.createFunc();
426
  }
427
-
428
  release(object) {
429
  this.pool.push(object);
430
  }
431
-
432
  clear() {
433
  this.pool = [];
434
  // Note: This doesn't dispose Three.js objects.
@@ -436,7 +411,6 @@
436
  // when objects are truly no longer needed by the scene.
437
  }
438
  }
439
-
440
  // --- Main Game Class ---
441
  class SnakeGame {
442
  constructor() {
@@ -463,7 +437,6 @@
463
  this.currentDifficulty = 'NORMAL';
464
  this.particleSystem = null;
465
  this.headLight = null;
466
-
467
  this.materials = {
468
  snakeHead: new THREE.MeshStandardMaterial({
469
  color: 0x39FF14, emissive: 0x39FF14, roughness: 0.1, metalness: 0.25
@@ -483,36 +456,30 @@
483
  foodTetrahedron: new THREE.TetrahedronGeometry(CONFIG.CELL_SIZE * 0.6, 0),
484
  obstacle: new THREE.BoxGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 1.5, CONFIG.CELL_SIZE * 0.9) // Taller obstacles
485
  };
486
-
487
  this.segmentPool = new ObjectPool(() => {
488
  const segment = new THREE.Mesh(this.geometries.segment, this.materials.snakeBody.clone());
489
  segment.castShadow = true; // If using shadows
490
  return segment;
491
  }, 20);
492
-
493
  this.obstaclePool = new ObjectPool(() => {
494
  const obstacle = new THREE.Mesh(this.geometries.obstacle, this.materials.obstacle.clone());
495
  obstacle.castShadow = true; // If using shadows
496
  return obstacle;
497
  }, CONFIG.MAX_OBSTACLE_COUNT * 1.5);
498
-
499
  this.init(); // Call init before matrixRain if matrixRain depends on game elements
500
  this.matrixRain = new MatrixRain(); // Initialize after main game setup if it interacts
501
  this.setupEventListeners();
502
  this.updateHighScoresTable();
503
  GameState.changeState(GameState.MENU); // Start in menu
504
  }
505
-
506
  init() {
507
  this.scene = new THREE.Scene();
508
  this.scene.background = null; // For Matrix rain to show through
509
  // Add fog for depth with brighter color
510
  this.scene.fog = new THREE.Fog(0x001100, CONFIG.GRID_SIZE * 0.8, CONFIG.GRID_SIZE * 2.5);
511
-
512
  this.camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
513
  this.camera.position.set(0, CONFIG.GRID_SIZE * 0.4, CONFIG.GRID_SIZE * 0.9);
514
  this.camera.lookAt(0, -CONFIG.GRID_SIZE * 0.1, 0);
515
-
516
  this.renderer = new THREE.WebGLRenderer({
517
  canvas: document.getElementById('gameCanvas'),
518
  antialias: true,
@@ -521,38 +488,30 @@
521
  this.renderer.setSize(window.innerWidth, window.innerHeight);
522
  this.renderer.setPixelRatio(window.devicePixelRatio);
523
  this.renderer.shadowMap.enabled = true; // If you add shadows
524
-
525
  // Post-processing for bloom
526
  const renderPass = new RenderPass(this.scene, this.camera);
527
  const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.8, 0.75);
528
  bloomPass.threshold = 0.0;
529
  bloomPass.strength = 1.3; // Play with these
530
  bloomPass.radius = 0.3;
531
-
532
  this.composer = new EffectComposer(this.renderer);
533
  this.composer.addPass(renderPass);
534
  this.composer.addPass(bloomPass);
535
-
536
  const gridHelper = new THREE.GridHelper(CONFIG.GRID_SIZE * CONFIG.CELL_SIZE, CONFIG.GRID_SIZE, 0x008800, 0x00FF00);
537
  gridHelper.position.y = -CONFIG.CELL_SIZE / 2; // Align with snake plane
538
  this.scene.add(gridHelper);
539
-
540
  const ambientLight = new THREE.AmbientLight(0x404060, 0.1); // Soft ambient
541
  this.scene.add(ambientLight);
542
-
543
  const directionalLight = new THREE.DirectionalLight(0xffffee, 1.6);
544
  directionalLight.position.set(75, 13, 10);
545
  directionalLight.castShadow = true;
546
  directionalLight.shadow.mapSize.width = 1024;
547
  directionalLight.shadow.mapSize.height = 1024;
548
  this.scene.add(directionalLight);
549
-
550
  this.headLight = new THREE.PointLight(0x39FF14, 2, CONFIG.CELL_SIZE * 5); // Brighter, green light
551
  this.headLight.castShadow = false; // Point lights can be expensive for shadows
552
  this.scene.add(this.headLight);
553
-
554
  this.particleSystem = new ParticleSystem(this.scene);
555
-
556
  window.addEventListener('resize', () => {
557
  this.camera.aspect = window.innerWidth / window.innerHeight;
558
  this.camera.updateProjectionMatrix();
@@ -560,38 +519,30 @@
560
  this.composer.setSize(window.innerWidth, window.innerHeight); // Resize composer too
561
  }, false);
562
  }
563
-
564
  placeFood() {
565
  let foodPos;
566
  let validPosition = false;
567
  let attempts = 0;
568
  const maxAttempts = 100;
569
-
570
  const numCells = CONFIG.GRID_SIZE;
571
-
572
  while (!validPosition && attempts < maxAttempts) {
573
  // Generate random indices within the grid
574
  const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
575
  const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
576
-
577
  foodPos = new THREE.Vector3(
578
  xIndex * CONFIG.CELL_SIZE,
579
  0, // Food on the grid plane
580
  zIndex * CONFIG.CELL_SIZE
581
  );
582
-
583
  let collisionWithSnake = this.snake.some(segment =>
584
  segment.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2 // Use distanceSquared
585
  );
586
-
587
  let collisionWithObstacle = this.obstacles.some(obstacle =>
588
  obstacle.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2
589
  );
590
-
591
  validPosition = !collisionWithSnake && !collisionWithObstacle;
592
  attempts++;
593
  }
594
-
595
  if (validPosition) {
596
  this.food.position.copy(foodPos);
597
  } else {
@@ -604,22 +555,18 @@
604
  );
605
  }
606
  }
607
-
608
  createObstacles() {
609
  this.obstacles.forEach(obstacle => {
610
  this.scene.remove(obstacle);
611
  this.obstaclePool.release(obstacle);
612
  });
613
  this.obstacles = [];
614
-
615
  const numCells = CONFIG.GRID_SIZE;
616
-
617
  for (let i = 0; i < this.obstacleCount; i++) {
618
  let obstaclePos;
619
  let validPosition = false;
620
  let attempts = 0;
621
  const maxAttempts = 50;
622
-
623
  while (!validPosition && attempts < maxAttempts) {
624
  const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
625
  const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
@@ -628,21 +575,16 @@
628
  0, // Obstacles on the grid plane, but mesh is taller
629
  zIndex * CONFIG.CELL_SIZE
630
  );
631
-
632
  let tooCloseToStart = obstaclePos.lengthSq() < (CONFIG.CELL_SIZE * 4)**2; // Check squared length
633
-
634
  let collisionWithSnake = this.snake.some(segment =>
635
  segment.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 2)**2
636
  );
637
-
638
  let collisionWithOtherObstacle = this.obstacles.some(existingObstacle =>
639
  existingObstacle.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 1.5)**2 // Ensure spacing
640
  );
641
-
642
  validPosition = !tooCloseToStart && !collisionWithSnake && !collisionWithOtherObstacle;
643
  attempts++;
644
  }
645
-
646
  if (validPosition) {
647
  const obstacle = this.obstaclePool.get();
648
  obstacle.position.copy(obstaclePos);
@@ -652,44 +594,35 @@
652
  }
653
  }
654
  }
655
-
656
  clearGameObjects() {
657
  this.snake.forEach(segment => {
658
  this.scene.remove(segment);
659
  this.segmentPool.release(segment);
660
  });
661
  this.snake = [];
662
-
663
  if (this.food) {
664
  this.scene.remove(this.food);
665
  // No pool for food as it's a single, changing object
666
  }
667
  this.food = null;
668
-
669
  this.obstacles.forEach(obstacle => {
670
  this.scene.remove(obstacle);
671
  this.obstaclePool.release(obstacle);
672
  });
673
  this.obstacles = [];
674
-
675
  this.particleSystem.clear();
676
  }
677
-
678
  update(time) {
679
  if (this.isPaused || this.isGameOver || GameState.currentState !== GameState.PLAYING) {
680
  return;
681
  }
682
-
683
  if (time - this.lastUpdateTime < this.gameSpeed) {
684
  return;
685
  }
686
  this.lastUpdateTime = time;
687
-
688
  this.direction.copy(this.nextDirection); // More robust copy
689
-
690
  const head = this.snake[0];
691
  const newHeadPos = head.position.clone().add(this.direction); // CELL_SIZE is incorporated in direction
692
-
693
  const halfGridWorld = (CONFIG.GRID_SIZE / 2) * CONFIG.CELL_SIZE;
694
  if (
695
  newHeadPos.x >= halfGridWorld || newHeadPos.x < -halfGridWorld ||
@@ -698,21 +631,18 @@
698
  this.triggerGameOver();
699
  return;
700
  }
701
-
702
  for (let i = 1; i < this.snake.length; i++) {
703
  if (newHeadPos.distanceToSquared(this.snake[i].position) < (CONFIG.CELL_SIZE * 0.1)**2) { // Tighter collision
704
  this.triggerGameOver();
705
  return;
706
  }
707
  }
708
-
709
  for (const obstacle of this.obstacles) {
710
  if (newHeadPos.distanceToSquared(obstacle.position) < (CONFIG.CELL_SIZE * 0.75)**2) { // Check collision with obstacle
711
  this.triggerGameOver();
712
  return;
713
  }
714
  }
715
-
716
  const newHead = this.segmentPool.get();
717
  newHead.position.copy(newHeadPos);
718
  newHead.material = this.materials.snakeHead; // Head material
@@ -722,11 +652,9 @@
722
  this.snake.unshift(newHead);
723
  this.scene.add(newHead);
724
  this.headLight.position.copy(newHeadPos); // Light follows head
725
-
726
  if (this.food && newHeadPos.distanceToSquared(this.food.position) < (CONFIG.CELL_SIZE * 0.75)**2) {
727
  const foodType = this.food.userData;
728
  const basePoints = foodType.points || 1;
729
-
730
  const currentTime = performance.now();
731
  if (currentTime - this.lastFoodTime < CONFIG.COMBO_TIMEOUT) {
732
  this.comboCount++;
@@ -734,27 +662,21 @@
734
  this.comboCount = 1;
735
  }
736
  this.lastFoodTime = currentTime;
737
-
738
  const points = basePoints * this.comboCount;
739
  this.score += points;
740
-
741
  if (this.comboCount > 1) {
742
  const comboElement = document.getElementById('combo');
743
  comboElement.textContent = `Combo x${this.comboCount}! +${points}`;
744
  comboElement.style.opacity = 1;
745
  setTimeout(() => { comboElement.style.opacity = 0; }, 2000);
746
  }
747
-
748
  document.getElementById('info').textContent = `Score: ${this.score} | High: ${Math.max(this.score, this.highScore)}`;
749
-
750
  if (foodType.speedEffect) {
751
  this.gameSpeed = Math.max(50, this.gameSpeed + foodType.speedEffect); // Note: special food had -10, rare had +10
752
  }
753
-
754
  document.getElementById('eatSound').currentTime = 0;
755
  document.getElementById('eatSound').play();
756
  this.particleSystem.createFoodEffect(this.food.position.clone(), new THREE.Color(foodType.color));
757
-
758
  this.chooseFoodType(); // Gets new food type, applies material
759
  this.placeFood(); // Places it
760
  } else {
@@ -762,28 +684,23 @@
762
  this.scene.remove(tail);
763
  this.segmentPool.release(tail);
764
  }
765
-
766
  this.particleSystem.update();
767
-
768
  for (let i = 0; i < this.snake.length; i++) {
769
  const segment = this.snake[i];
770
  // segment.rotation.y += Math.sin(time * 0.1 + i * 0.1) * 0.01; // Slower, more subtle
771
  // segment.position.y = Math.sin(time * 0.01 + i * 0.2) * 0.1; // Subtle bob
772
  // segment.position.y = Math.sin(time * 0.01 + i * 0.2) * 1.5; // Jumping
773
  }
774
-
775
  if (this.food) {
776
  this.food.rotation.x += 0.01;
777
  this.food.rotation.y += 0.02;
778
  this.food.position.y = Math.sin(time * 0.0055) * 0.25 + 0.1; // Bobbing food
779
  }
780
  }
781
-
782
  chooseFoodType() {
783
  const rand = Math.random();
784
  let foodTypeData;
785
  let geometry;
786
-
787
  if (rand < 0.05) { // 5% rare
788
  foodTypeData = CONFIG.FOOD_TYPES[2];
789
  geometry = this.geometries.foodTetrahedron;
@@ -794,7 +711,6 @@
794
  foodTypeData = CONFIG.FOOD_TYPES[0];
795
  geometry = this.geometries.foodBox;
796
  }
797
-
798
  const material = new THREE.MeshStandardMaterial({ // Use Standard for lighting
799
  color: foodTypeData.color,
800
  emissive: foodTypeData.color, // Make food glow
@@ -803,7 +719,6 @@
803
  metalness: 0.7,
804
  wireframe: true
805
  });
806
-
807
  if (!this.food) {
808
  this.food = new THREE.Mesh(geometry, material);
809
  this.scene.add(this.food);
@@ -815,17 +730,14 @@
815
  }
816
  this.food.userData = foodTypeData;
817
  }
818
-
819
  resetGame() {
820
  this.clearGameObjects();
821
-
822
  // Stop sounds
823
  ['eatSound', 'gameOverSound', 'bgMusic'].forEach(id => {
824
  const sound = document.getElementById(id);
825
  sound.pause();
826
  sound.currentTime = 0;
827
  });
828
-
829
  this.direction.set(CONFIG.CELL_SIZE, 0, 0);
830
  this.nextDirection.set(CONFIG.CELL_SIZE, 0, 0);
831
  this.score = 0;
@@ -835,27 +747,21 @@
835
  GameState.currentState = GameState.PLAYING; // Set this before calling things that depend on it.
836
  this.comboCount = 0;
837
  this.lastFoodTime = 0;
838
-
839
  this.obstacleCount = Math.floor(CONFIG.MAX_OBSTACLE_COUNT * CONFIG.DIFFICULTY_LEVELS[this.currentDifficulty].obstacleMultiplier);
840
-
841
  const startSegment = this.segmentPool.get();
842
  startSegment.position.set(0, 0, 0);
843
  startSegment.material = this.materials.snakeHead; // Start with head material
844
  this.snake.push(startSegment);
845
  this.scene.add(startSegment);
846
  this.headLight.position.copy(startSegment.position); // Initial light pos
847
-
848
  this.chooseFoodType();
849
  this.placeFood();
850
  this.createObstacles();
851
-
852
  document.getElementById('info').textContent = `Score: ${this.score} | High: ${this.highScore}`;
853
-
854
  const music = document.getElementById('bgMusic');
855
  music.volume = 0.2; // Quieter music
856
  music.play().catch(e => console.warn("Music play failed:", e)); // Catch promise
857
  }
858
-
859
  startGame() {
860
  this.resetGame(); // This sets GameState.PLAYING internally now
861
  GameState.changeState(GameState.PLAYING); // Ensure UI updates
@@ -863,12 +769,10 @@
863
  if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Clear old loop
864
  this.gameLoop();
865
  }
866
-
867
  triggerGameOver() {
868
  if (this.isGameOver) return; // Prevent multiple triggers
869
  this.isGameOver = true;
870
  document.getElementById('finalScore').textContent = `Final Score: ${this.score}`;
871
-
872
  const highScores = this.loadHighScores();
873
  if (this.score > 0) {
874
  highScores.push({ score: this.score, difficulty: this.currentDifficulty, date: new Date().toLocaleDateString() });
@@ -881,7 +785,6 @@
881
  document.getElementById('bgMusic').pause();
882
  GameState.changeState(GameState.GAME_OVER);
883
  }
884
-
885
  gameLoop(time) { // time is passed by requestAnimationFrame
886
  if (!this.isGameOver && GameState.currentState === GameState.PLAYING) {
887
  this.update(time || performance.now()); // Use performance.now if time is undefined initially
@@ -893,7 +796,6 @@
893
  if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Ensure loop stops
894
  }
895
  }
896
-
897
  render() {
898
  // this.renderer.render(this.scene, this.camera); // When using composer
899
  if (this.composer) {
@@ -902,26 +804,20 @@
902
  this.renderer.render(this.scene, this.camera);
903
  }
904
  }
905
-
906
  setupEventListeners() {
907
  document.addEventListener('keydown', this.handleKeyDown.bind(this));
908
-
909
  const touchControlsDiv = document.getElementById('touchControls');
910
  const preventDefaultAndStopPropagation = (e) => { e.preventDefault(); e.stopPropagation(); };
911
-
912
  document.getElementById('upBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, -CONFIG.CELL_SIZE); });
913
  document.getElementById('downBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, CONFIG.CELL_SIZE); });
914
  document.getElementById('leftBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(-CONFIG.CELL_SIZE, 0, 0); });
915
  document.getElementById('rightBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(CONFIG.CELL_SIZE, 0, 0); });
916
-
917
  const gameCanvas = document.getElementById('gameCanvas');
918
  gameCanvas.addEventListener('touchstart', preventDefaultAndStopPropagation, { passive: false });
919
  gameCanvas.addEventListener('touchmove', preventDefaultAndStopPropagation, { passive: false });
920
-
921
  if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
922
  touchControlsDiv.style.display = 'block';
923
  }
924
-
925
  document.getElementById('startBtn').addEventListener('click', () => this.startGame());
926
  document.getElementById('restartBtn').addEventListener('click', () => this.startGame());
927
  document.getElementById('menuBtn').addEventListener('click', () => GameState.changeState(GameState.MENU));
@@ -934,7 +830,6 @@
934
  GameState.changeState(GameState.MENU);
935
  });
936
  }
937
-
938
  cycleDifficulty() {
939
  const difficulties = Object.keys(CONFIG.DIFFICULTY_LEVELS);
940
  const currentIndex = difficulties.indexOf(this.currentDifficulty);
@@ -942,7 +837,6 @@
942
  this.currentDifficulty = difficulties[nextIndex];
943
  document.getElementById('difficultyBtn').textContent = `DIFFICULTY: ${this.currentDifficulty.toUpperCase()}`;
944
  }
945
-
946
  handleKeyDown(event) {
947
  if (GameState.currentState === GameState.PLAYING && !this.isPaused) {
948
  switch(event.key) {
@@ -958,7 +852,6 @@
958
  this.startGame(); event.preventDefault();
959
  }
960
  }
961
-
962
  handleDirectionChange(dx, dy, dz) {
963
  const newDir = new THREE.Vector3(dx, dy, dz);
964
  // Prevent 180-degree turns. Dot product will be negative.
@@ -968,10 +861,8 @@
968
  }
969
  this.nextDirection.copy(newDir);
970
  }
971
-
972
  togglePause() {
973
  if (GameState.currentState !== GameState.PLAYING && GameState.currentState !== GameState.PAUSED) return;
974
-
975
  this.isPaused = !this.isPaused;
976
  if (this.isPaused) {
977
  GameState.changeState(GameState.PAUSED);
@@ -984,17 +875,14 @@
984
  this.gameLoop(); // Resume game loop
985
  }
986
  }
987
-
988
  loadHighScores() {
989
  const scores = localStorage.getItem('snakeHighScores');
990
  return scores ? JSON.parse(scores) : [];
991
  }
992
-
993
  updateHighScoresTable() {
994
  const highScores = this.loadHighScores();
995
  const table = document.getElementById('scoresTable');
996
  while (table.rows.length > 1) { table.deleteRow(1); } // Clear existing
997
-
998
  highScores.forEach((entry, index) => {
999
  const row = table.insertRow(-1);
1000
  row.insertCell(0).textContent = index + 1;
@@ -1003,10 +891,8 @@
1003
  });
1004
  }
1005
  }
1006
-
1007
  // Create and start the game
1008
  const game = new SnakeGame();
1009
-
1010
  </script>
1011
  </body>
1012
  </html>
 
198
 
199
  <!-- Audio elements -->
200
  <audio id="eatSound" preload="auto">
201
+ <source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAANIAqJWUEQAFO+gRc5TRJIkiRJEiL///////////8RERERERERVVVVVVVVVVVVVVJEREREREVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/jGMQJA/Aa1flBABBTpGX9hDGMYxw7/+MMYxd/4wxIiI9////jDEQ7/jdEiJERERBaIiIzMzMzIiIiP//MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM/+MYxB4AAANIAAAAADMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM" type="audio/mpeg">
202
  </audio>
203
  <audio id="gameOverSound" preload="auto">
204
  <source src="data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA/+M4wAAAAAAAAAAAAEluZm8AAAAPAAAAAwAAAFgAVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAWMBaq2QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+MYxAAKMFqZVQEwAhGKzc+FSIiIiIiIiIj4+Pj4+Pj4+Pj4+JIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jIMQNAAAP8AEAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIv/jEMQQAAAP8AAAAAI+IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIg==" type="audio/mpeg">
 
222
  import { EffectComposer } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js';
223
  import { RenderPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/RenderPass.js';
224
  import { UnrealBloomPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/UnrealBloomPass.js';
 
225
  // Game configuration
226
  const CONFIG = {
227
  GRID_SIZE: 25, // Number of units across/deep
 
241
  COMBO_TIMEOUT: 5000, // Milliseconds to get next food for combo
242
  HIGH_SCORES_COUNT: 5 // Number of high scores to save
243
  };
 
244
  // --- Particle System for Effects ---
245
  class ParticleSystem {
246
  constructor(scene) {
 
248
  this.particles = [];
249
  this.geometry = new THREE.BoxGeometry(0.2, 0.1, 0.2); // Shared geometry
250
  }
 
251
  createFoodEffect(position, color) {
252
  const count = 20;
253
  for (let i = 0; i < count; i++) {
 
272
  });
273
  }
274
  }
 
275
  update() {
276
  for (let i = this.particles.length - 1; i >= 0; i--) {
277
  const particle = this.particles[i];
 
279
  particle.velocity.y -= 0.005; // Gravity
280
  particle.life -= particle.decay;
281
  particle.mesh.material.opacity = particle.life;
 
282
  if (particle.life <= 0) {
283
  this.scene.remove(particle.mesh);
284
  particle.mesh.material.dispose();
 
287
  }
288
  }
289
  }
 
290
  clear() {
291
  for (const particle of this.particles) {
292
  this.scene.remove(particle.mesh);
 
297
  // as it might be needed again. Dispose it if ParticleSystem itself is destroyed.
298
  }
299
  }
 
300
  // Game state management
301
  const GameState = {
302
  MENU: 'menu',
 
304
  PAUSED: 'paused',
305
  GAME_OVER: 'gameOver',
306
  currentState: 'menu',
 
307
  changeState(newState) {
308
  this.currentState = newState;
309
  switch(newState) {
 
332
  }
333
  }
334
  };
 
335
  // --- Matrix Rain Background Effect ---
336
  class MatrixRain {
337
  constructor() {
338
  this.canvas = document.getElementById('matrixCanvas');
339
  this.ctx = this.canvas.getContext('2d');
340
  this.resize();
 
341
  this.fontSize = 14;
342
  this.columns = Math.floor(this.canvas.width / this.fontSize);
343
  this.drops = [];
 
349
  this.animate(); // Start animation
350
  window.addEventListener('resize', this.handleResize.bind(this));
351
  }
 
352
  handleResize() {
353
  this.resize();
354
  this.columns = Math.floor(this.canvas.width / this.fontSize);
355
  this.resetDrops();
356
  }
 
357
  resize() {
358
  this.canvas.width = window.innerWidth;
359
  this.canvas.height = window.innerHeight;
360
  }
 
361
  resetDrops() {
362
  this.drops = [];
363
  for(let i = 0; i < this.columns; i++) {
364
  this.drops[i] = Math.floor(Math.random() * -100); // Start off-screen
365
  }
366
  }
 
367
  animate() {
368
  // Show rain on all screens as per previous request, including PLAYING
369
  if (GameState.currentState === GameState.MENU ||
370
  GameState.currentState === GameState.PAUSED ||
371
  GameState.currentState === GameState.GAME_OVER ||
372
  GameState.currentState === GameState.PLAYING) {
373
+ this.ctx.fillStyle = 'rgba(0, 0, 0, 0.005)'; // Even more Fading effect
 
374
  this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
375
+ this.ctx.fillStyle = '#00ccff'; // Matrix character color - now electric blue!
 
 
 
376
  this.ctx.font = this.fontSize + 'px monospace';
 
377
  for(let i = 0; i < this.drops.length; i++) {
378
  const text = this.characters.charAt(Math.floor(Math.random() * this.characters.length));
379
  this.ctx.fillText(text, i * this.fontSize, this.drops[i] * this.fontSize);
 
380
  if(this.drops[i] * this.fontSize > this.canvas.height && Math.random() > 0.975) {
381
  this.drops[i] = 0; // Reset drop
382
  }
 
386
  requestAnimationFrame(this.animate);
387
  }
388
  } // <-- Make sure this curly brace properly closes the MatrixRain class
 
389
  // --- Object pooling for performance optimization ---
390
  class ObjectPool {
391
  constructor(createFunc, initialCount = 10) {
392
  this.pool = [];
393
  this.createFunc = createFunc;
 
394
  for (let i = 0; i < initialCount; i++) {
395
  this.pool.push(this.createFunc());
396
  }
397
  }
 
398
  get() {
399
  if (this.pool.length > 0) {
400
  return this.pool.pop();
401
  }
402
  return this.createFunc();
403
  }
 
404
  release(object) {
405
  this.pool.push(object);
406
  }
 
407
  clear() {
408
  this.pool = [];
409
  // Note: This doesn't dispose Three.js objects.
 
411
  // when objects are truly no longer needed by the scene.
412
  }
413
  }
 
414
  // --- Main Game Class ---
415
  class SnakeGame {
416
  constructor() {
 
437
  this.currentDifficulty = 'NORMAL';
438
  this.particleSystem = null;
439
  this.headLight = null;
 
440
  this.materials = {
441
  snakeHead: new THREE.MeshStandardMaterial({
442
  color: 0x39FF14, emissive: 0x39FF14, roughness: 0.1, metalness: 0.25
 
456
  foodTetrahedron: new THREE.TetrahedronGeometry(CONFIG.CELL_SIZE * 0.6, 0),
457
  obstacle: new THREE.BoxGeometry(CONFIG.CELL_SIZE * 0.9, CONFIG.CELL_SIZE * 1.5, CONFIG.CELL_SIZE * 0.9) // Taller obstacles
458
  };
 
459
  this.segmentPool = new ObjectPool(() => {
460
  const segment = new THREE.Mesh(this.geometries.segment, this.materials.snakeBody.clone());
461
  segment.castShadow = true; // If using shadows
462
  return segment;
463
  }, 20);
 
464
  this.obstaclePool = new ObjectPool(() => {
465
  const obstacle = new THREE.Mesh(this.geometries.obstacle, this.materials.obstacle.clone());
466
  obstacle.castShadow = true; // If using shadows
467
  return obstacle;
468
  }, CONFIG.MAX_OBSTACLE_COUNT * 1.5);
 
469
  this.init(); // Call init before matrixRain if matrixRain depends on game elements
470
  this.matrixRain = new MatrixRain(); // Initialize after main game setup if it interacts
471
  this.setupEventListeners();
472
  this.updateHighScoresTable();
473
  GameState.changeState(GameState.MENU); // Start in menu
474
  }
 
475
  init() {
476
  this.scene = new THREE.Scene();
477
  this.scene.background = null; // For Matrix rain to show through
478
  // Add fog for depth with brighter color
479
  this.scene.fog = new THREE.Fog(0x001100, CONFIG.GRID_SIZE * 0.8, CONFIG.GRID_SIZE * 2.5);
 
480
  this.camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
481
  this.camera.position.set(0, CONFIG.GRID_SIZE * 0.4, CONFIG.GRID_SIZE * 0.9);
482
  this.camera.lookAt(0, -CONFIG.GRID_SIZE * 0.1, 0);
 
483
  this.renderer = new THREE.WebGLRenderer({
484
  canvas: document.getElementById('gameCanvas'),
485
  antialias: true,
 
488
  this.renderer.setSize(window.innerWidth, window.innerHeight);
489
  this.renderer.setPixelRatio(window.devicePixelRatio);
490
  this.renderer.shadowMap.enabled = true; // If you add shadows
 
491
  // Post-processing for bloom
492
  const renderPass = new RenderPass(this.scene, this.camera);
493
  const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.8, 0.75);
494
  bloomPass.threshold = 0.0;
495
  bloomPass.strength = 1.3; // Play with these
496
  bloomPass.radius = 0.3;
 
497
  this.composer = new EffectComposer(this.renderer);
498
  this.composer.addPass(renderPass);
499
  this.composer.addPass(bloomPass);
 
500
  const gridHelper = new THREE.GridHelper(CONFIG.GRID_SIZE * CONFIG.CELL_SIZE, CONFIG.GRID_SIZE, 0x008800, 0x00FF00);
501
  gridHelper.position.y = -CONFIG.CELL_SIZE / 2; // Align with snake plane
502
  this.scene.add(gridHelper);
 
503
  const ambientLight = new THREE.AmbientLight(0x404060, 0.1); // Soft ambient
504
  this.scene.add(ambientLight);
 
505
  const directionalLight = new THREE.DirectionalLight(0xffffee, 1.6);
506
  directionalLight.position.set(75, 13, 10);
507
  directionalLight.castShadow = true;
508
  directionalLight.shadow.mapSize.width = 1024;
509
  directionalLight.shadow.mapSize.height = 1024;
510
  this.scene.add(directionalLight);
 
511
  this.headLight = new THREE.PointLight(0x39FF14, 2, CONFIG.CELL_SIZE * 5); // Brighter, green light
512
  this.headLight.castShadow = false; // Point lights can be expensive for shadows
513
  this.scene.add(this.headLight);
 
514
  this.particleSystem = new ParticleSystem(this.scene);
 
515
  window.addEventListener('resize', () => {
516
  this.camera.aspect = window.innerWidth / window.innerHeight;
517
  this.camera.updateProjectionMatrix();
 
519
  this.composer.setSize(window.innerWidth, window.innerHeight); // Resize composer too
520
  }, false);
521
  }
 
522
  placeFood() {
523
  let foodPos;
524
  let validPosition = false;
525
  let attempts = 0;
526
  const maxAttempts = 100;
 
527
  const numCells = CONFIG.GRID_SIZE;
 
528
  while (!validPosition && attempts < maxAttempts) {
529
  // Generate random indices within the grid
530
  const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
531
  const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
 
532
  foodPos = new THREE.Vector3(
533
  xIndex * CONFIG.CELL_SIZE,
534
  0, // Food on the grid plane
535
  zIndex * CONFIG.CELL_SIZE
536
  );
 
537
  let collisionWithSnake = this.snake.some(segment =>
538
  segment.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2 // Use distanceSquared
539
  );
 
540
  let collisionWithObstacle = this.obstacles.some(obstacle =>
541
  obstacle.position.distanceToSquared(foodPos) < (CONFIG.CELL_SIZE * 0.9)**2
542
  );
 
543
  validPosition = !collisionWithSnake && !collisionWithObstacle;
544
  attempts++;
545
  }
 
546
  if (validPosition) {
547
  this.food.position.copy(foodPos);
548
  } else {
 
555
  );
556
  }
557
  }
 
558
  createObstacles() {
559
  this.obstacles.forEach(obstacle => {
560
  this.scene.remove(obstacle);
561
  this.obstaclePool.release(obstacle);
562
  });
563
  this.obstacles = [];
 
564
  const numCells = CONFIG.GRID_SIZE;
 
565
  for (let i = 0; i < this.obstacleCount; i++) {
566
  let obstaclePos;
567
  let validPosition = false;
568
  let attempts = 0;
569
  const maxAttempts = 50;
 
570
  while (!validPosition && attempts < maxAttempts) {
571
  const xIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
572
  const zIndex = Math.floor(Math.random() * numCells) - Math.floor((numCells - 1) / 2);
 
575
  0, // Obstacles on the grid plane, but mesh is taller
576
  zIndex * CONFIG.CELL_SIZE
577
  );
 
578
  let tooCloseToStart = obstaclePos.lengthSq() < (CONFIG.CELL_SIZE * 4)**2; // Check squared length
 
579
  let collisionWithSnake = this.snake.some(segment =>
580
  segment.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 2)**2
581
  );
 
582
  let collisionWithOtherObstacle = this.obstacles.some(existingObstacle =>
583
  existingObstacle.position.distanceToSquared(obstaclePos) < (CONFIG.CELL_SIZE * 1.5)**2 // Ensure spacing
584
  );
 
585
  validPosition = !tooCloseToStart && !collisionWithSnake && !collisionWithOtherObstacle;
586
  attempts++;
587
  }
 
588
  if (validPosition) {
589
  const obstacle = this.obstaclePool.get();
590
  obstacle.position.copy(obstaclePos);
 
594
  }
595
  }
596
  }
 
597
  clearGameObjects() {
598
  this.snake.forEach(segment => {
599
  this.scene.remove(segment);
600
  this.segmentPool.release(segment);
601
  });
602
  this.snake = [];
 
603
  if (this.food) {
604
  this.scene.remove(this.food);
605
  // No pool for food as it's a single, changing object
606
  }
607
  this.food = null;
 
608
  this.obstacles.forEach(obstacle => {
609
  this.scene.remove(obstacle);
610
  this.obstaclePool.release(obstacle);
611
  });
612
  this.obstacles = [];
 
613
  this.particleSystem.clear();
614
  }
 
615
  update(time) {
616
  if (this.isPaused || this.isGameOver || GameState.currentState !== GameState.PLAYING) {
617
  return;
618
  }
 
619
  if (time - this.lastUpdateTime < this.gameSpeed) {
620
  return;
621
  }
622
  this.lastUpdateTime = time;
 
623
  this.direction.copy(this.nextDirection); // More robust copy
 
624
  const head = this.snake[0];
625
  const newHeadPos = head.position.clone().add(this.direction); // CELL_SIZE is incorporated in direction
 
626
  const halfGridWorld = (CONFIG.GRID_SIZE / 2) * CONFIG.CELL_SIZE;
627
  if (
628
  newHeadPos.x >= halfGridWorld || newHeadPos.x < -halfGridWorld ||
 
631
  this.triggerGameOver();
632
  return;
633
  }
 
634
  for (let i = 1; i < this.snake.length; i++) {
635
  if (newHeadPos.distanceToSquared(this.snake[i].position) < (CONFIG.CELL_SIZE * 0.1)**2) { // Tighter collision
636
  this.triggerGameOver();
637
  return;
638
  }
639
  }
 
640
  for (const obstacle of this.obstacles) {
641
  if (newHeadPos.distanceToSquared(obstacle.position) < (CONFIG.CELL_SIZE * 0.75)**2) { // Check collision with obstacle
642
  this.triggerGameOver();
643
  return;
644
  }
645
  }
 
646
  const newHead = this.segmentPool.get();
647
  newHead.position.copy(newHeadPos);
648
  newHead.material = this.materials.snakeHead; // Head material
 
652
  this.snake.unshift(newHead);
653
  this.scene.add(newHead);
654
  this.headLight.position.copy(newHeadPos); // Light follows head
 
655
  if (this.food && newHeadPos.distanceToSquared(this.food.position) < (CONFIG.CELL_SIZE * 0.75)**2) {
656
  const foodType = this.food.userData;
657
  const basePoints = foodType.points || 1;
 
658
  const currentTime = performance.now();
659
  if (currentTime - this.lastFoodTime < CONFIG.COMBO_TIMEOUT) {
660
  this.comboCount++;
 
662
  this.comboCount = 1;
663
  }
664
  this.lastFoodTime = currentTime;
 
665
  const points = basePoints * this.comboCount;
666
  this.score += points;
 
667
  if (this.comboCount > 1) {
668
  const comboElement = document.getElementById('combo');
669
  comboElement.textContent = `Combo x${this.comboCount}! +${points}`;
670
  comboElement.style.opacity = 1;
671
  setTimeout(() => { comboElement.style.opacity = 0; }, 2000);
672
  }
 
673
  document.getElementById('info').textContent = `Score: ${this.score} | High: ${Math.max(this.score, this.highScore)}`;
 
674
  if (foodType.speedEffect) {
675
  this.gameSpeed = Math.max(50, this.gameSpeed + foodType.speedEffect); // Note: special food had -10, rare had +10
676
  }
 
677
  document.getElementById('eatSound').currentTime = 0;
678
  document.getElementById('eatSound').play();
679
  this.particleSystem.createFoodEffect(this.food.position.clone(), new THREE.Color(foodType.color));
 
680
  this.chooseFoodType(); // Gets new food type, applies material
681
  this.placeFood(); // Places it
682
  } else {
 
684
  this.scene.remove(tail);
685
  this.segmentPool.release(tail);
686
  }
 
687
  this.particleSystem.update();
 
688
  for (let i = 0; i < this.snake.length; i++) {
689
  const segment = this.snake[i];
690
  // segment.rotation.y += Math.sin(time * 0.1 + i * 0.1) * 0.01; // Slower, more subtle
691
  // segment.position.y = Math.sin(time * 0.01 + i * 0.2) * 0.1; // Subtle bob
692
  // segment.position.y = Math.sin(time * 0.01 + i * 0.2) * 1.5; // Jumping
693
  }
 
694
  if (this.food) {
695
  this.food.rotation.x += 0.01;
696
  this.food.rotation.y += 0.02;
697
  this.food.position.y = Math.sin(time * 0.0055) * 0.25 + 0.1; // Bobbing food
698
  }
699
  }
 
700
  chooseFoodType() {
701
  const rand = Math.random();
702
  let foodTypeData;
703
  let geometry;
 
704
  if (rand < 0.05) { // 5% rare
705
  foodTypeData = CONFIG.FOOD_TYPES[2];
706
  geometry = this.geometries.foodTetrahedron;
 
711
  foodTypeData = CONFIG.FOOD_TYPES[0];
712
  geometry = this.geometries.foodBox;
713
  }
 
714
  const material = new THREE.MeshStandardMaterial({ // Use Standard for lighting
715
  color: foodTypeData.color,
716
  emissive: foodTypeData.color, // Make food glow
 
719
  metalness: 0.7,
720
  wireframe: true
721
  });
 
722
  if (!this.food) {
723
  this.food = new THREE.Mesh(geometry, material);
724
  this.scene.add(this.food);
 
730
  }
731
  this.food.userData = foodTypeData;
732
  }
 
733
  resetGame() {
734
  this.clearGameObjects();
 
735
  // Stop sounds
736
  ['eatSound', 'gameOverSound', 'bgMusic'].forEach(id => {
737
  const sound = document.getElementById(id);
738
  sound.pause();
739
  sound.currentTime = 0;
740
  });
 
741
  this.direction.set(CONFIG.CELL_SIZE, 0, 0);
742
  this.nextDirection.set(CONFIG.CELL_SIZE, 0, 0);
743
  this.score = 0;
 
747
  GameState.currentState = GameState.PLAYING; // Set this before calling things that depend on it.
748
  this.comboCount = 0;
749
  this.lastFoodTime = 0;
 
750
  this.obstacleCount = Math.floor(CONFIG.MAX_OBSTACLE_COUNT * CONFIG.DIFFICULTY_LEVELS[this.currentDifficulty].obstacleMultiplier);
 
751
  const startSegment = this.segmentPool.get();
752
  startSegment.position.set(0, 0, 0);
753
  startSegment.material = this.materials.snakeHead; // Start with head material
754
  this.snake.push(startSegment);
755
  this.scene.add(startSegment);
756
  this.headLight.position.copy(startSegment.position); // Initial light pos
 
757
  this.chooseFoodType();
758
  this.placeFood();
759
  this.createObstacles();
 
760
  document.getElementById('info').textContent = `Score: ${this.score} | High: ${this.highScore}`;
 
761
  const music = document.getElementById('bgMusic');
762
  music.volume = 0.2; // Quieter music
763
  music.play().catch(e => console.warn("Music play failed:", e)); // Catch promise
764
  }
 
765
  startGame() {
766
  this.resetGame(); // This sets GameState.PLAYING internally now
767
  GameState.changeState(GameState.PLAYING); // Ensure UI updates
 
769
  if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Clear old loop
770
  this.gameLoop();
771
  }
 
772
  triggerGameOver() {
773
  if (this.isGameOver) return; // Prevent multiple triggers
774
  this.isGameOver = true;
775
  document.getElementById('finalScore').textContent = `Final Score: ${this.score}`;
 
776
  const highScores = this.loadHighScores();
777
  if (this.score > 0) {
778
  highScores.push({ score: this.score, difficulty: this.currentDifficulty, date: new Date().toLocaleDateString() });
 
785
  document.getElementById('bgMusic').pause();
786
  GameState.changeState(GameState.GAME_OVER);
787
  }
 
788
  gameLoop(time) { // time is passed by requestAnimationFrame
789
  if (!this.isGameOver && GameState.currentState === GameState.PLAYING) {
790
  this.update(time || performance.now()); // Use performance.now if time is undefined initially
 
796
  if (this.gameLoopId) cancelAnimationFrame(this.gameLoopId); // Ensure loop stops
797
  }
798
  }
 
799
  render() {
800
  // this.renderer.render(this.scene, this.camera); // When using composer
801
  if (this.composer) {
 
804
  this.renderer.render(this.scene, this.camera);
805
  }
806
  }
 
807
  setupEventListeners() {
808
  document.addEventListener('keydown', this.handleKeyDown.bind(this));
 
809
  const touchControlsDiv = document.getElementById('touchControls');
810
  const preventDefaultAndStopPropagation = (e) => { e.preventDefault(); e.stopPropagation(); };
 
811
  document.getElementById('upBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, -CONFIG.CELL_SIZE); });
812
  document.getElementById('downBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(0, 0, CONFIG.CELL_SIZE); });
813
  document.getElementById('leftBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(-CONFIG.CELL_SIZE, 0, 0); });
814
  document.getElementById('rightBtn').addEventListener('touchstart', (e) => { preventDefaultAndStopPropagation(e); this.handleDirectionChange(CONFIG.CELL_SIZE, 0, 0); });
 
815
  const gameCanvas = document.getElementById('gameCanvas');
816
  gameCanvas.addEventListener('touchstart', preventDefaultAndStopPropagation, { passive: false });
817
  gameCanvas.addEventListener('touchmove', preventDefaultAndStopPropagation, { passive: false });
 
818
  if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
819
  touchControlsDiv.style.display = 'block';
820
  }
 
821
  document.getElementById('startBtn').addEventListener('click', () => this.startGame());
822
  document.getElementById('restartBtn').addEventListener('click', () => this.startGame());
823
  document.getElementById('menuBtn').addEventListener('click', () => GameState.changeState(GameState.MENU));
 
830
  GameState.changeState(GameState.MENU);
831
  });
832
  }
 
833
  cycleDifficulty() {
834
  const difficulties = Object.keys(CONFIG.DIFFICULTY_LEVELS);
835
  const currentIndex = difficulties.indexOf(this.currentDifficulty);
 
837
  this.currentDifficulty = difficulties[nextIndex];
838
  document.getElementById('difficultyBtn').textContent = `DIFFICULTY: ${this.currentDifficulty.toUpperCase()}`;
839
  }
 
840
  handleKeyDown(event) {
841
  if (GameState.currentState === GameState.PLAYING && !this.isPaused) {
842
  switch(event.key) {
 
852
  this.startGame(); event.preventDefault();
853
  }
854
  }
 
855
  handleDirectionChange(dx, dy, dz) {
856
  const newDir = new THREE.Vector3(dx, dy, dz);
857
  // Prevent 180-degree turns. Dot product will be negative.
 
861
  }
862
  this.nextDirection.copy(newDir);
863
  }
 
864
  togglePause() {
865
  if (GameState.currentState !== GameState.PLAYING && GameState.currentState !== GameState.PAUSED) return;
 
866
  this.isPaused = !this.isPaused;
867
  if (this.isPaused) {
868
  GameState.changeState(GameState.PAUSED);
 
875
  this.gameLoop(); // Resume game loop
876
  }
877
  }
 
878
  loadHighScores() {
879
  const scores = localStorage.getItem('snakeHighScores');
880
  return scores ? JSON.parse(scores) : [];
881
  }
 
882
  updateHighScoresTable() {
883
  const highScores = this.loadHighScores();
884
  const table = document.getElementById('scoresTable');
885
  while (table.rows.length > 1) { table.deleteRow(1); } // Clear existing
 
886
  highScores.forEach((entry, index) => {
887
  const row = table.insertRow(-1);
888
  row.insertCell(0).textContent = index + 1;
 
891
  });
892
  }
893
  }
 
894
  // Create and start the game
895
  const game = new SnakeGame();
 
896
  </script>
897
  </body>
898
  </html>