Skip to content

Latest commit

 

History

History
230 lines (140 loc) · 176 KB

codebase.md

File metadata and controls

230 lines (140 loc) · 176 KB

.gitignore

# Dependencies /node_modules # Build output /dist # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # OS generated files .DS_Store .vscode/ # Vite specific .vite/

docs/code_summary.md

# Neon Serpent Code Summary This document provides a summary of the main TypeScript files and their roles in the Neon Serpent WebGL game. ## Core Logic & Orchestration ### `src/main.ts` * **Purpose:** The main entry point of the application. Initializes the PixiJS rendering engine, loads assets, sets up the game loop (`PIXI.Ticker`), creates the `InputHandler` and `Game` instances, and manages browser events like resize and visibility changes. * **Key Functions:** `initializeGame()`, `setupGame()`, `handleResize()`, `handleVisibilityChange()`, `handleKeyDown()`. ### `src/Game.ts` * **Class:** `Game` * **Purpose:** Central orchestrator for the game. Manages the game state machine (Loading, Menu, Playing, Paused, Controls, Game Over), the main game loop (`update`), entity management (via `EntityManager`), collision detection (via `CollisionSystem`), camera simulation, background effects (video zoom/parallax), UI interaction (via `UIManager`), and input handling (via `InputHandler`). * **Key Methods:** `constructor()`, `init()`, `changeState()`, `startGame()`, `pauseGame()`, `resumeGame()`, `gameOver()`, `update()`, `generateOrbs()`, `checkOrbCollisions()`, `killSnake()`, `absorb()`, `handleUIAction()`, `handleKeyDown()`. ## Entity Management ### `src/EntityManager.ts` * **Class:** `EntityManager` * **Purpose:** Creates, stores, and provides access to all active game entities (Player, AI Serpents, Orbs). Acts as a central registry. * **Key Methods:** `constructor()`, `spawnPlayer()`, `spawnAI()`, `spawnSingleAI()`, `getAllSnakes()`, `removeAISerpent()`, `removeOrb()`. ## Game Entities ### `src/Serpent.ts` * **Class:** `Serpent` (Base Class) * **Purpose:** Defines the fundamental properties and behaviors common to all snake entities (Player and AI). Includes segment management, core movement physics (velocity, speed interpolation, segment following), basic growth logic, scoring, and PixiJS object handling. * **Key Methods:** `constructor()`, `initPixi()`, `destroyPixi()`, `syncPixi()` (basic rendering, meant to be overridden), `update()` (core physics/growth), `setDirection()`, `eatOrb()` (base logic), `attemptTurn()` (base logic). ### `src/PlayerSerpent.ts` * **Class:** `PlayerSerpent` (extends `Serpent`) * **Purpose:** Represents the player-controlled serpent. Adds player-specific rendering effects (glow, pulse, safe neck, mouth), input-driven turning logic with self-collision avoidance, dynamic base speed based on length, and specific effects upon eating orbs (speed boost, glow). * **Key Methods:** `constructor()`, `syncPixi()` (override for advanced visuals), `attemptTurn()` (override with player constraints), `calculateSkipSegments()`, `willHitTail()`, `eatOrb()` (override for player effects), `update()` (override for dynamic speed). ### `src/AISerpent.ts` * **Class:** `AISerpent` (extends `Serpent`) * **Purpose:** Represents AI-controlled opponents. Includes an `AIController` for decision-making. Overrides base methods for AI-specific behavior and rendering. * **Key Methods:** `constructor()`, `updateAI()` (uses `AIController` to decide direction), `attemptTurn()` (override to prevent 180s), `update()` (override for AI base speed calc), `eatOrb()` (override for AI effects), `syncPixi()` (override for AI visuals - similar to player but simpler). ### `src/Orb.ts` * **Class:** `Orb` * **Purpose:** Represents the static, collectible orbs. Stores position, radius, tier, value, and manages its `PIXI.Sprite`. * **Key Methods:** `constructor()`, `initPixi()`, `syncPixi()`, `destroyPixi()`. ## Systems & Controllers ### `src/AIController.ts` * **Class:** `AIController` * **Purpose:** Encapsulates the decision-making logic for an `AISerpent`. Determines the AI's state (Gather, Hunt, Evade) based on nearby orbs and snakes, calculates a target point, adds noise for unpredictability, and returns a steering vector. * **Key Methods:** `constructor()`, `decide()` (core logic), `pickSnakeTarget()`, `pickBestOrb()`, `vecTo()`, `normalize()`, `noise()`. ### `src/CollisionSystem.ts` * **Purpose:** Contains the logic for detecting and resolving collisions between snakes (head-vs-body, head-vs-head), using toroidal distance checks and optionally a `SpatialHashGrid`. Uses callbacks provided by `Game` to enact consequences (killing, absorbing). * **Key Function:** `resolveCollisions()`. ### `src/InputHandler.ts` * **Class:** `InputHandler` * **Purpose:** Captures and processes user input (keyboard WASD/Arrows, touch for virtual joystick, fire button). Translates inputs into directional commands and action states for the `Game`. Manages the visual joystick graphics. * **Key Methods:** `constructor()`, `addEventListeners()`, `removeEventListeners()`, `handleKeyDown()`, `handleKeyUp()`, `handleTouchStart()`, `handleTouchMove()`, `handleTouchEnd()`, `initJoystickGraphics()`, `updateJoystickGraphics()`, `getKeyboardDirection()`, `getJoystickDirection()`, `isFireButtonPressed()`. ### `src/UI.ts` * **Class:** `UIManager` * **Purpose:** Manages the HTML-based UI overlay. Controls visibility of menus, updates leaderboards, attaches listeners to buttons, and uses a callback to notify the `Game` of UI actions. * **Key Methods:** `constructor()`, `init()`, `hideAllMenus()`, `showMainMenu()`, `showControlsMenu()`, etc., `updatePauseLeaderboard()`, `updateMiniLeaderboard()`, `updatePauseButton()`. ### `src/SpatialHashGrid.ts` * **Class:** `SpatialHashGrid` * **Purpose:** Provides a spatial hashing data structure to optimize collision detection and proximity queries in the toroidal world by dividing the space into cells. * **Key Methods:** `constructor()`, `clear()`, `insert()`, `query()`. ## Utilities & Types ### `src/utils.ts` * **Purpose:** A collection of standalone utility functions for math (distance, lerp), toroidal coordinate manipulation (wrap, distance, delta, moveTowards), and color interpolation. * **Key Functions:** `dist()`, `distTorus()`, `wrap()`, `torusDelta()`, `moveTowardsTorus()`, `lerpColor()`, `lerp()`. ### `src/types.ts` (Not explicitly read, but referenced) * **Purpose:** Defines shared TypeScript interfaces and types (like `Point`, `Segment`, `Velocity`, `SnakeState`, `OrbTier`) and potentially constants used across multiple modules. ### `src/constants.ts` / `src/aiConstants.ts` (Not explicitly read, but referenced) * **Purpose:** Likely define game-wide or AI-specific constants (like world size factors, speeds, counts, behavior parameters).

docs/Game_Layout.md

## Neon Serpent: Gameplay Overview **Concept:** Neon Serpent thrusts you into a vibrant, endlessly wrapping world as a glowing serpent. It's a modern evolution of the classic snake game, challenging your reflexes against AI opponents in a visually rich environment. **Objective:** Your primary goal is survival and growth. Navigate the expansive world, consuming glowing orbs to increase your serpent's length and accumulate score. The longer you survive and the more you consume, the higher your score climbs. **The World:** * **Expansive & Wrapping:** The game takes place in a very large, square arena. There are no walls; moving off one edge instantly transports you to the opposite side ([`wrap()` in `src/utils.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/utils.ts), used in [`Serpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L197)), creating a seamless, continuous space. * **2.5D Parallax Background:** A dynamic video plays in the background ([`index.html`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/index.html#L15)), featuring a scene like a futuristic cave city. This background scrolls at a different rate than the foreground gameplay (parallax effect), creating a sense of depth. **Controlling Your Serpent:** * **Continuous Movement:** Your serpent is always in motion ([`Game.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L707) calls [`player.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L337)), gliding smoothly in its current direction. * **Keyboard:** Use `WASD` or the `Arrow Keys` to dictate the direction you want your serpent to turn towards (handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts)). * **Touch (Virtual Joystick):** On touch devices, tap and hold to activate a virtual joystick. Drag to set the desired heading. Releasing maintains the last direction (handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts) and [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts)). A small deadzone prevents micro-movements. **Core Mechanics:** * **Turning:** * You can't instantly reverse (180 degrees) direction (checked in [`PlayerSerpent.attemptTurn()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts)). * There's a brief cooldown period after each turn ([`KEYBOARD_TURN_COOLDOWN_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts), [`JOYSTICK_TURN_COOLDOWN_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) preventing overly rapid movements. * **Smart Collision Avoidance (Safe Neck):** The game prevents turns that would cause immediate self-collision. A number of segments behind the head ([`MAX_NECK_SKIP_SEGMENTS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) are ignored during collision checks ([`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts)). The player serpent visually renders these segments differently ([`safeNeckColor`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L77) in [`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L151)). AI has a similar, though not visually distinct, safe zone. * **Growth:** * **Orbs:** Consume scattered orbs ([`Orb.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Orb.ts)) to grow longer and score points. Orbs have different tiers ([`ORB_TIER_CONFIG`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) providing varying growth ([`PLAYER_LENGTH_PER_ORB`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) and score. AI growth per orb is simpler ([`AISerpent.eatOrb()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts)). * **Defeating AI:** Eliminating an AI opponent rewards the player with growth and score (handled in [`Game.absorbSnake()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts) called by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts)). * **Speed:** * Your serpent starts at a base speed ([`PLAYER_INITIAL_SPEED`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) and gets faster as it grows longer ([`PlayerSerpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L337) adjusts [`baseSpeed`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) based on length). * AI speed also scales with length ([`AISerpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L84)). * Eating orbs grants a temporary speed boost ([`PLAYER_EAT_SPEED_BOOST`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts), [`PLAYER_EAT_SPEED_BOOST_DURATION_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) managed by [`targetSpeed`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) and [`speedBoostTimer`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) in [`Serpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L224). **AI Opponents:** * **Multiple Rivals:** You share the world with several AI-controlled serpents ([`AI_COUNT`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/aiConstants.ts), spawned in [`EntityManager.spawnAI()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/EntityManager.ts#L8)). * **Varied Behaviors:** AI serpents exhibit different behaviors controlled by states like `GATHER`, `HUNT`, `EVADE` ([`AIController.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AIController.ts#L105)). * **Skill Levels:** AI uses noise ([`AI_NOISE`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/aiConstants.ts)) for less predictable movement and has logic to avoid collisions ([`AIController.avoidCollisions()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AIController.ts)). **Collisions & Death:** * **Your Demise:** Your game ends if your serpent's head collides with: * Any part of *your own* body (excluding the safe neck segments). * Any part of an *enemy* serpent's body. (Handled by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts#L123) calling [`killCb`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L991)). * **AI Demise:** An AI serpent is destroyed if its head collides with *your* serpent's body or another AI's body (Handled by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts#L123)). * **Removal:** Destroyed AI serpents are removed from the game ([`Game.killSnake()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L991) calls [`EntityManager.removeAISerpent()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/EntityManager.ts)). (Note: Respawning logic is not currently detailed in the provided codebase). **Visuals & Feedback:** * **Neon Aesthetic:** Bright, glowing visuals for serpents ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L60), [`AISerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L122)), orbs, and effects against a darker background. * **Serpent Appearance:** Rendered as smooth, glowing lines with rounded heads and eyes indicating direction ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L224), [`AISerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L264)). * **Eating Effects:** Consuming an orb triggers a brief glow/flash ([`glowFrames`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) used in `syncPixi`) and a visual pulse effect travels down the body ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L103) calculates pulse based on `eatQueue`). Particle effects might also occur (implementation details not fully shown). * **Death Effects:** Dying serpents are removed visually ([`Serpent.destroyPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L54)). Particle effects might accompany this. **Game Flow & UI:** * **States:** The game likely cycles through states like `Menu`, `Playing`, `Paused`, `Game Over` (managed within [`Game.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts)). * **Menu:** Handled by [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). * **Pause:** Triggered by `Space`/`Escape` or UI button, handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts) and [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). * **Game Over:** Triggered on player death ([`Game.gameOver()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts)), displays score via [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). \`\`\`// ## Neon Serpent: Gameplay Overview **Concept:** Neon Serpent thrusts you into a vibrant, endlessly wrapping world as a glowing serpent. It's a modern evolution of the classic snake game, challenging your reflexes against AI opponents in a visually rich environment. **Objective:** Your primary goal is survival and growth. Navigate the expansive world, consuming glowing orbs to increase your serpent's length and accumulate score. The longer you survive and the more you consume, the higher your score climbs. **The World:** * **Expansive & Wrapping:** The game takes place in a very large, square arena. There are no walls; moving off one edge instantly transports you to the opposite side ([`wrap()` in `src/utils.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/utils.ts), used in [`Serpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L197)), creating a seamless, continuous space. * **2.5D Parallax Background:** A dynamic video plays in the background ([`index.html`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/index.html#L15)), featuring a scene like a futuristic cave city. This background scrolls at a different rate than the foreground gameplay (parallax effect), creating a sense of depth. **Controlling Your Serpent:** * **Continuous Movement:** Your serpent is always in motion ([`Game.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L707) calls [`player.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L337)), gliding smoothly in its current direction. * **Keyboard:** Use `WASD` or the `Arrow Keys` to dictate the direction you want your serpent to turn towards (handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts)). * **Touch (Virtual Joystick):** On touch devices, tap and hold to activate a virtual joystick. Drag to set the desired heading. Releasing maintains the last direction (handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts) and [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts)). A small deadzone prevents micro-movements. **Core Mechanics:** * **Turning:** * You can't instantly reverse (180 degrees) direction (checked in [`PlayerSerpent.attemptTurn()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts)). * There's a brief cooldown period after each turn ([`KEYBOARD_TURN_COOLDOWN_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts), [`JOYSTICK_TURN_COOLDOWN_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) preventing overly rapid movements. * **Smart Collision Avoidance (Safe Neck):** The game prevents turns that would cause immediate self-collision. A number of segments behind the head ([`MAX_NECK_SKIP_SEGMENTS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) are ignored during collision checks ([`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts)). The player serpent visually renders these segments differently ([`safeNeckColor`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L77) in [`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L151)). AI has a similar, though not visually distinct, safe zone. * **Growth:** * **Orbs:** Consume scattered orbs ([`Orb.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Orb.ts)) to grow longer and score points. Orbs have different tiers ([`ORB_TIER_CONFIG`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) providing varying growth ([`PLAYER_LENGTH_PER_ORB`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) and score. AI growth per orb is simpler ([`AISerpent.eatOrb()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts)). * **Defeating AI:** Eliminating an AI opponent rewards the player with growth and score (handled in [`Game.absorbSnake()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts) called by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts)). * **Speed:** * Your serpent starts at a base speed ([`PLAYER_INITIAL_SPEED`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) and gets faster as it grows longer ([`PlayerSerpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L337) adjusts [`baseSpeed`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) based on length). * AI speed also scales with length ([`AISerpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L84)). * Eating orbs grants a temporary speed boost ([`PLAYER_EAT_SPEED_BOOST`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts), [`PLAYER_EAT_SPEED_BOOST_DURATION_MS`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/types.ts)) managed by [`targetSpeed`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) and [`speedBoostTimer`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) in [`Serpent.update()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L224). **AI Opponents:** * **Multiple Rivals:** You share the world with several AI-controlled serpents ([`AI_COUNT`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/aiConstants.ts), spawned in [`EntityManager.spawnAI()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/EntityManager.ts#L8)). * **Varied Behaviors:** AI serpents exhibit different behaviors controlled by states like `GATHER`, `HUNT`, `EVADE` ([`AIController.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AIController.ts#L105)). * **Skill Levels:** AI uses noise ([`AI_NOISE`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/aiConstants.ts)) for less predictable movement and has logic to avoid collisions ([`AIController.avoidCollisions()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AIController.ts)). **Collisions & Death:** * **Your Demise:** Your game ends if your serpent's head collides with: * Any part of *your own* body (excluding the safe neck segments). * Any part of an *enemy* serpent's body. (Handled by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts#L123) calling [`killCb`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L991)). * **AI Demise:** An AI serpent is destroyed if its head collides with *your* serpent's body or another AI's body (Handled by [`resolveCollisions`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/CollisionSystem.ts#L123)). * **Removal:** Destroyed AI serpents are removed from the game ([`Game.killSnake()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts#L991) calls [`EntityManager.removeAISerpent()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/EntityManager.ts)). (Note: Respawning logic is not currently detailed in the provided codebase). **Visuals & Feedback:** * **Neon Aesthetic:** Bright, glowing visuals for serpents ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L60), [`AISerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L122)), orbs, and effects against a darker background. * **Serpent Appearance:** Rendered as smooth, glowing lines with rounded heads and eyes indicating direction ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L224), [`AISerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/AISerpent.ts#L264)). * **Eating Effects:** Consuming an orb triggers a brief glow/flash ([`glowFrames`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L13) used in `syncPixi`) and a visual pulse effect travels down the body ([`PlayerSerpent.syncPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/PlayerSerpent.ts#L103) calculates pulse based on `eatQueue`). Particle effects might also occur (implementation details not fully shown). * **Death Effects:** Dying serpents are removed visually ([`Serpent.destroyPixi()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Serpent.ts#L54)). Particle effects might accompany this. **Game Flow & UI:** * **States:** The game likely cycles through states like `Menu`, `Playing`, `Paused`, `Game Over` (managed within [`Game.ts`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts)). * **Menu:** Handled by [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). * **Pause:** Triggered by `Space`/`Escape` or UI button, handled by [`InputHandler`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/InputHandler.ts) and [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). * **Game Over:** Triggered on player death ([`Game.gameOver()`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/Game.ts)), displays score via [`UIManager`](/Users/kosta/Documents/ProjectsCode/NeonSerpent_WebGL/src/UI.ts). \`\`\`

index.html

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Neon Serpent</title> <style> body { margin: 0; overflow: hidden; background-color: black; } #pixi-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; /* Above video, below UI */ /* pointer-events: auto; Let Pixi handle events on its canvas */ /* Note: PixiJS canvas inside will handle pointer events */ } #backgroundWrap{ position:fixed;inset:0;overflow:hidden;z-index:-1; transition:transform .5s ease; /* zoom ONLY */ will-change:transform; } #parallaxWrap{ position:absolute;inset:0; will-change:transform; /* translate ONLY */ } #backgroundVideo{ width:100%;height:100%;object-fit:cover; /* Removed position, top, left, transform-origin, transition */ } /* --- UI Menu Styles --- */ .menu-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); /* Semi-transparent black */ color: #00ffcc; /* Neon cyan text */ font-family: 'Courier New', Courier, monospace; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 50; /* Above canvas, below loading screen */ text-align: center; border: 2px solid #00ffcc; box-shadow: 0 0 15px #00ffcc; /* Keep border/shadow for other menus */ } /* Specific adjustments for Controls overlay */ #controlsMenu { border: none; /* Remove border from the full overlay */ box-shadow: none; /* Remove shadow from the full overlay */ background-color: rgba(0, 0, 0, 0.3); /* Make overlay slightly darker for contrast */ } /* Style for the inner content panel */ #controlsContent { background-color: rgba(10, 25, 30, 0.85); /* Dark, slightly transparent teal background */ padding: 30px 50px; border-radius: 10px; border: 1px solid #00ffcc; box-shadow: 0 0 20px rgba(0, 255, 204, 0.5); max-width: 600px; /* Limit width of the panel */ } /* Class to apply backdrop blur */ .menu-overlay.backdrop-blur { backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); /* Safari support */ } .menu-overlay.hidden { display: none; } .menu-overlay h2 { margin-bottom: 30px; font-size: 2.5em; text-shadow: 0 0 10px #00ffcc; } .menu-overlay button { background-color: transparent; border: 2px solid #00ffcc; color: #00ffcc; padding: 15px 30px; margin: 10px; font-size: 1.5em; font-family: inherit; cursor: pointer; transition: background-color 0.2s, color 0.2s, box-shadow 0.2s; min-width: 250px; /* Ensure buttons have a decent width */ } .menu-overlay button:hover, .menu-overlay button:focus { background-color: #00ffcc; color: black; box-shadow: 0 0 15px #00ffcc; outline: none; } #controlsMenu p { margin: 5px 0; font-size: 1.2em; } #controlsMenu .controls-section { margin-bottom: 20px; } #gameOverMenu .score { font-size: 1.8em; margin-bottom: 20px; color: #ff00ff; /* Neon pink for score */ text-shadow: 0 0 10px #ff00ff; } /* In-game leaderboard styles */ #pauseLeaderboard { margin-top: 20px; /* Space above leaderboard */ width: 80%; max-width: 350px; max-height: 40vh; /* Limit height */ overflow-y: auto; border: 1px solid rgba(0, 255, 204, 0.5); /* Fainter border */ padding: 10px; background-color: rgba(0, 0, 0, 0.2); /* Slightly darker background */ } #pauseLeaderboard h3 { margin-top: 0; margin-bottom: 10px; font-size: 1.4em; color: #ff00ff; /* Neon pink title */ text-shadow: 0 0 8px #ff00ff; } #pauseLeaderboard ol { list-style: none; /* Remove default numbering */ padding: 0; margin: 0; text-align: left; } #pauseLeaderboard li { display: flex; justify-content: space-between; padding: 3px 5px; font-size: 1.1em; border-bottom: 1px dashed rgba(0, 255, 204, 0.3); } #pauseLeaderboard li:last-child { border-bottom: none; } #pauseLeaderboard .leaderboard-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 70%; /* Prevent long names from overlapping score */ } #pauseLeaderboard .leaderboard-score { font-weight: bold; color: #ffffff; /* White score */ } #pauseLeaderboard .player-score { /* Highlight player score */ color: #00ffcc; /* Neon cyan */ font-weight: bold; } /* Mini leaderboard HUD */ #inGameLeaderboard{ position:fixed;top:40px;right:10px; font-family:"Courier New",monospace; font-size:14px;line-height:1.2; color:#ff00ff;text-align:right; background:rgba(0,0,0,.25); padding:6px 10px;border:1px solid rgba(0,255,204,.4); border-radius:6px; max-width:180px;pointer-events:none; z-index:45; } #inGameLeaderboard .mini-row.player{color:#00ffcc;font-weight:bold;} /* Hide on narrow screens (<768 px) */ @media (max-width:767px){#inGameLeaderboard{display:none;}} /* Touchscreen Fire Button Styles */ #fire-button { position: fixed; bottom: 20px; left: 20px; /* Changed from right to left */ width: 70px; /* Adjust size as needed */ height: 70px; background-color: rgba(255, 0, 0, 0.4); /* Semi-transparent red */ border: 2px solid rgba(255, 100, 100, 0.8); border-radius: 50%; /* Make it circular */ display: flex; /* Use flexbox for centering */ justify-content: center; align-items: center; cursor: pointer; z-index: 10; /* Above canvas, below menus */ box-shadow: 0 0 10px rgba(255, 0, 0, 0.6); transition: background-color 0.1s, transform 0.1s; user-select: none; /* Prevent text selection */ -webkit-user-select: none; /* Safari */ -webkit-tap-highlight-color: transparent; /* Remove tap highlight */ } #fire-button::before { /* Inner circle for target look */ content: ''; width: 30px; height: 30px; background-color: rgba(255, 150, 150, 0.6); border-radius: 50%; border: 1px solid rgba(255, 200, 200, 0.9); } #fire-button:active { /* Style when pressed */ background-color: rgba(255, 0, 0, 0.7); transform: scale(0.95); /* Slightly shrink when pressed */ } /* Hide fire button on larger screens (e.g., tablets in landscape, desktops) */ @media (min-width: 768px) { #fire-button { display: none; } } </style> </head> <body> <div id="backgroundWrap"> <!-- zoom (CSS transition) --> <div id="parallaxWrap"> <!-- translate3d every frame --> <video id="backgroundVideo" autoplay loop muted playsinline> <source src="/cave_city_h264_compat.mp4" type="video/mp4"> </video> </div> </div> <div id="pixi-container"></div> <!-- PixiJS will add its canvas here --> <!-- Pause Button (Top Right) --> <button id="pause-button" style=" position: fixed; top: 10px; right: 10px; z-index: 10; /* Above canvas, below menus */ padding: 8px 12px; font-size: 18px; /* Adjust size as needed */ font-weight: bold; background-color: rgba(0, 255, 204, 0.5); /* Semi-transparent cyan */ color: black; border: 1px solid #00ffcc; border-radius: 5px; cursor: pointer; box-shadow: 0 0 8px rgba(0, 255, 204, 0.7); transition: background-color 0.2s, box-shadow 0.2s; ">❚❚</button> <!-- Initial state: Paused icon --> <!-- Mini leaderboard HUD (hidden via media query on phones) --> <div id="inGameLeaderboard"></div> <!-- Touchscreen Fire Button --> <div id="fire-button" title="Fire"></div> <div id="loadingScreen" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.9); color: white; display: flex; justify-content: center; align-items: center; font-size: 2em; z-index: 100;"> Loading... </div> <!-- UI Menus --> <div id="mainMenu" class="menu-overlay"> <h2>Neon Serpent</h2> <button id="startGameBtn">Start New Game</button> <button id="controlsBtnMain">Controls</button> <button id="scoreboardBtn">Scoreboard</button> <!-- Placeholder --> <button id="toggleDevModeBtn" style="font-size: 1em; padding: 8px 15px; min-width: 150px; margin-top: 20px;">Toggle Dev Mode</button> <!-- Added Dev Mode Button --> </div> <div id="controlsMenu" class="menu-overlay hidden"> <div id="controlsContent"> <h2>Controls</h2> <div class="controls-section"> <p>Move: WASD or Arrow Keys</p> <p>Turn towards cursor/touch direction</p> </div> <div class="controls-section"> <p>Pause/Resume: Space Bar</p> <p>Pause Menu: Escape (Esc)</p> </div> <button id="backToMainBtnControls">Back</button> </div> </div> <div id="pauseMenu" class="menu-overlay hidden"> <h2>Paused</h2> <!-- In-game leaderboard --> <div id="pauseLeaderboard"> <h3>Leaderboard</h3> <ol id="pauseLeaderboardList"> <!-- Scores populated by JS --> <!-- Example: <li><span class="leaderboard-name player-score">Player</span><span class="leaderboard-score player-score">500</span></li> --> <!-- Example: <li><span class="leaderboard-name">AI Name</span><span class="leaderboard-score">120</span></li> --> </ol> </div> <button id="resumeGameBtn">Resume Game</button> <button id="newGameBtnPause">New Game</button> <button id="controlsBtnPause">Controls</button> </div> <div id="gameOverMenu" class="menu-overlay hidden"> <h2>Game Over</h2> <div id="finalScore" class="score">Score: 0</div> <button id="mainMenuBtnGameOver">Main Menu</button> </div> <!-- End UI Menus --> <script type="module" src="/src/main.ts"></script> </body> </html>

package.json

{ "name": "neonserpent_webgl", "version": "1.0.0", "main": "index.js", "scripts": { "dev": "vite --host --port 6161", "build": "vite build", "typecheck": "tsc --noEmit" }, "keywords": [], "author": "", "license": "ISC", "description": "", "devDependencies": { "@types/node": "^22.15.3", "typescript": "^5.8.3", "vite": "^6.3.3" }, "dependencies": { "pixi.js": "^8.9.1", "simplex-noise": "^4.0.3" } }

public/a.png

This is a binary file of the type: Image

public/cave_city_h264_compat.mp4

This is a binary file of the type: Binary

public/cave_city_h264.mp4

This is a binary file of the type: Binary

public/cave_city.mp4

This is a binary file of the type: Binary

public/d.png

This is a binary file of the type: Image

public/down.png

This is a binary file of the type: Image

public/left.png

This is a binary file of the type: Image

public/orb1.png

This is a binary file of the type: Image

public/orb2.png

This is a binary file of the type: Image

public/orb3.png

This is a binary file of the type: Image

public/orbs_original.png

This is a binary file of the type: Image

public/orbs.png

This is a binary file of the type: Image

public/right.png

This is a binary file of the type: Image

public/s.png

This is a binary file of the type: Image

public/space.png

This is a binary file of the type: Image

public/sparkle.png

This is a binary file of the type: Image

public/up.png

This is a binary file of the type: Image

public/w.png

This is a binary file of the type: Image

src/aiConstants.ts

export const AI_COUNT = 8; export const AI_BASE_SPEED = 36; // px/s export const AI_VARIANCE_SPEED = 12; // ± export const AI_VIEW_RADIUS = 600; // px export const AI_TURN_COOLDOWN_MS = 80; export const AI_NOISE = 0.15; // 0‒1 steering wobble export const AI_STATES = ['GATHER','HUNT','EVADE'] as const; export type AIState = typeof AI_STATES[number];

src/AIController.ts

import { Point, SnakeState, segRadius } from './types'; // Added segRadius import import Orb from './Orb'; import { dist, distTorus } from './utils'; // Added distTorus import import { createNoise2D } from 'simplex-noise'; import { AI_VIEW_RADIUS, /* AI_TURN_COOLDOWN_MS, */ AI_NOISE, // Commented out unused import again AIState } from './aiConstants'; const noise2D = createNoise2D(); export default class AIController { private owner: SnakeState; private state: AIState = 'GATHER'; private readonly idBias: number; private readonly viewR: number; // Per-bot view radius // Reusable Point objects to minimize allocations private _futurePos: Point = { x: 0, y: 0 }; // private _targetPoint: Point = { x: 0, y: 0 }; // Unused, removed private _evadeTarget: Point = { x: 0, y: 0 }; // Specific for evasion calculation private _defaultTarget: Point = { x: 0, y: 0 }; // For default forward movement private _dirVector: Point = { x: 0, y: 0 }; private _noiseVector: Point = { x: 0, y: 0 }; private _combinedVector: Point = { x: 0, y: 0 }; private _normalizedVector: Point = { x: 0, y: 0 }; constructor(owner: SnakeState) { this.owner = owner; this.idBias = Math.random() * 9999; // each bot gets its own reflex delay & view range // this.reactionMs = AI_TURN_COOLDOWN_MS * (0.7 + Math.random() * 0.6); // Removed assignment this.viewR = AI_VIEW_RADIUS * (0.8 + Math.random() * 0.4); } /** Chooses a normalized steering vector once per frame */ decide(orbs: Orb[], snakes: SnakeState[], worldW: number, worldH: number, // Added world dimensions now: number): Point { // 1. pick visible objects const myHead = this.owner.segs[0]; const inViewOrbs = orbs.filter(o => o.visible && dist(o, myHead) < this.viewR); const otherSnakes = snakes.filter(s => s !== this.owner && s.visible); // Filter out non-visible snakes too // 2-A. Foresight collision check (~300 ms look-ahead) const lookAhead = 0.3 * this.owner.speed; // px // Update reusable _futurePos this._futurePos.x = myHead.x + this.owner.velocity.vx * lookAhead; this._futurePos.y = myHead.y + this.owner.velocity.vy * lookAhead; const danger = snakes.some(s => s.segs .slice(6) // ignore necks .some(seg => distTorus(this._futurePos, seg, worldW, worldH) < // Use _futurePos segRadius(this.owner.length) + 2)); if (danger) this.state = 'EVADE'; // 2-B. Determine state priority: Hunt > Evade Imminent > Gather const tgtSnake = this.pickSnakeTarget(otherSnakes, myHead); const imminent = otherSnakes.find(s => s.length > this.owner.length && dist(s.segs[0], myHead) < 120 ); // Check distance to *head* of bigger snake // Update state based on priority, ensuring EVADE from danger check isn't overwritten unless a higher priority exists if (tgtSnake) { this.state = 'HUNT'; // HUNT overrides EVADE from danger check } else if (imminent) { this.state = 'EVADE'; // EVADE from imminent threat overrides EVADE from danger check } else if (this.state !== 'EVADE') { // Only change to GATHER if not already EVADING from danger if (inViewOrbs.length > 0) { this.state = 'GATHER'; } else { // Default state if no targets or threats and not evading this.state = 'GATHER'; // Default to seeking orbs } } // If state is still EVADE from the danger check, it remains EVADE // 3. Determine target point based on state let target: Point | null = null; if (this.state === 'HUNT') { target = tgtSnake!.segs.at(-2)!; // Aim at tail–1 of target snake } else if (this.state === 'GATHER') { target = this.pickBestOrb(inViewOrbs); // pickBestOrb handles empty array } else if (this.state === 'EVADE') { // Steer away from the imminent threat's head or just continue forward? if (imminent) { // Simple evasion: steer directly away from the threat's head const threatHead = imminent.segs[0]; // Update reusable _evadeTarget this._evadeTarget.x = myHead.x + (myHead.x - threatHead.x); this._evadeTarget.y = myHead.y + (myHead.y - threatHead.y); target = this._evadeTarget; // Assign reference } else { // If EVADE state was reached from danger check (no imminent threat) // Steer opposite current velocity // Update reusable _evadeTarget this._evadeTarget.x = myHead.x - this.owner.velocity.vx * 100; this._evadeTarget.y = myHead.y - this.owner.velocity.vy * 100; target = this._evadeTarget; // Assign reference } } // Ensure target is never null (pickBestOrb handles default case) if (!target) { // This case should ideally not be reached if pickBestOrb is correct console.warn(`AI ${this.owner.id} target became null unexpectedly in state ${this.state}. Defaulting.`); this._defaultTarget.x = myHead.x + this.owner.velocity.vx; this._defaultTarget.y = myHead.y + this.owner.velocity.vy; target = this._defaultTarget; } // 4. Calculate steering vector towards target (updates _dirVector) this.vecTo(myHead, target); // 5. Calculate noise wobble (updates _noiseVector) this.noise(now); // 6. Combine direction and wobble into _combinedVector this._combinedVector.x = this._dirVector.x + this._noiseVector.x; this._combinedVector.y = this._dirVector.y + this._noiseVector.y; // 7. Normalize the combined vector (updates and returns _normalizedVector) return this.normalize(this._combinedVector); } /* ---------- helpers ---------- */ private pickSnakeTarget(snakes: SnakeState[], head: Point): SnakeState | null { let best: SnakeState | null = null, bestD = Infinity; snakes.forEach(s => { // Ensure target snake has enough segments to aim at segs.at(-2) if (s.segs.length < 3) return; const targetPoint = s.segs.at(-2)!; // Use the segment before the tail tip const d = dist(targetPoint, head); // Use this bot's specific viewR if (d < this.viewR && s.length < this.owner.length && d < bestD) { best = s; bestD = d; } }); return best; } private pickBestOrb(orbs: Orb[]): Point { // Returns a reference to an Orb or _defaultTarget // Handle case where orbs array might be empty after filtering if (orbs.length === 0) { // Default behavior: update and return the reusable _defaultTarget this._defaultTarget.x = this.owner.segs[0].x + this.owner.velocity.vx; this._defaultTarget.y = this.owner.segs[0].y + this.owner.velocity.vy; return this._defaultTarget; } // Find the orb with the highest value (returns the Orb object itself) // The caller will use its x, y properties. return orbs.reduce((p, c) => (c.value > p.value ? c : p)); } // Updates and returns the reusable _dirVector private vecTo(from: Point, to: Point): Point { this._dirVector.x = to.x - from.x; this._dirVector.y = to.y - from.y; return this._dirVector; } // Updates and returns the reusable _normalizedVector private normalize(v: Point): Point { const l = Math.hypot(v.x, v.y) || 1; this._normalizedVector.x = v.x / l; this._normalizedVector.y = v.y / l; return this._normalizedVector; } // Updates and returns the reusable _noiseVector private noise(t: number): Point { this._noiseVector.x = (noise2D(this.idBias, t * 0.0005)) * AI_NOISE; this._noiseVector.y = (noise2D(this.idBias + 1111, t * 0.0005)) * AI_NOISE; return this._noiseVector; } }

src/aiNames.ts

// Placeholder lists for AI name generation. // IMPORTANT: Replace these placeholders with your desired terms. // Using offensive terms is against safety guidelines. export const GAY_NOUNS = [ 'MuscleCub','PowerBottom','Otter','LeatherDaddy','GymBunny', 'DiscoTwink','CircuitQueen','PupHandler','BearHugger','StoneButch', 'SteamRoomDoc','PrideDiva','LubeLord','LockerLegend','SaunaScout', 'FlexFiend','ThiccIcon','GluteGuru','JockstrapJester','RainbowRogue', 'ChestChamp','ThirstTrap','ShowerSiren','KinkKnight','SquatSquire', 'BeefcakeBaron','TwerkTroll','GogoGoblin','TugBoat','GlitterGhoul' ] as const; export const GAY_VERBS = [ 'Pounder','Pleaser','Rimmer','Grinder','Stroker', 'Slurper','Sniffer','Worshiper','Luster','Explorer', 'Teaser','Taster','Wrestler','Nibbler','Hunter', 'Chaser','Churner','Milker','Thruster','Squeezer', 'Nuzzler','Hugger','Tugger','Licker','Sucker', 'Digger','Diver','Rider','Peeper','Muncher' ] as const; /** * Generates a random AI name by combining a term and a verb. * @returns A randomly generated name string. */ export function generateAiName(): string { // Use the correct array names GAY_NOUNS and GAY_VERBS const randomTerm = GAY_NOUNS[Math.floor(Math.random() * GAY_NOUNS.length)]; const randomVerb = GAY_VERBS[Math.floor(Math.random() * GAY_VERBS.length)]; return `${randomTerm} ${randomVerb}`; }

src/AISerpent.ts

import { Serpent } from './Serpent'; // Import the base class import AIController from './AIController'; import { Point, // Added missing import SnakeState, PLAYER_INITIAL_LENGTH, PLAYER_INITIAL_SEGMENTS, segRadius, // Using player constants for AI effects for now - TODO: Create AI-specific constants PLAYER_EAT_SPEED_BOOST, PLAYER_EAT_SPEED_BOOST_DURATION_MS, PLAYER_EAT_GLOW_FRAMES, // PLAYER_LENGTH_PER_ORB // AI will use simpler growth for now } from './types'; import { AI_BASE_SPEED, AI_VARIANCE_SPEED, AI_TURN_COOLDOWN_MS } from './aiConstants'; import Orb from './Orb'; import { generateAiName } from './aiNames'; import { dist, lerpColor } from './utils'; // Import necessary utils export default class AISerpent extends Serpent { private ai: AIController; constructor(x: number, y: number, hue: number) { const id = `ai-${Math.random().toString(36).slice(2)}`; const name = generateAiName(); const initialSpeed = AI_BASE_SPEED + (Math.random() * AI_VARIANCE_SPEED - AI_VARIANCE_SPEED / 2); // Using player initial length/segments for now, could create AI-specific constants later const initialLength = PLAYER_INITIAL_LENGTH; const initialSegments = PLAYER_INITIAL_SEGMENTS; super( id, x, y, initialLength, initialSegments, initialSpeed, hue, // color name, false // isPlayer ); // Initialize AI Controller after super() call // Cast 'this' to SnakeState for the AIController constructor this.ai = new AIController(this as unknown as SnakeState); console.log(`AISerpent constructor finished for ID: ${this.id}`); } // AI-specific update logic, called separately from the main update loop updateAI(now: number, // Removed unused delta parameter orbs: Orb[], snakes: SnakeState[], worldW: number, worldH: number): void { // --- AI Steering --- // AI decides where to go based on the environment const desired = this.ai.decide(orbs, snakes, worldW, worldH, now); // Use the inherited attemptTurn method this.attemptTurn(desired, now, AI_TURN_COOLDOWN_MS); // Note: The actual movement, growth, etc., is handled by the base Serpent.update(), // which is called separately in Game.ts. } // Override attemptTurn to prevent instant 180-degree turns override attemptTurn(desiredDir: Point, currentTimestamp: number, turnCooldown: number): boolean { // Basic cooldown check if (currentTimestamp - this.lastTurnTimestamp <= turnCooldown) { return false; } // Prevent 180 turns (copied from PlayerSerpent) const dotProduct = desiredDir.x * this.velocity.vx + desiredDir.y * this.velocity.vy; if (dotProduct < -0.9) { return false; // Prevent sharp reversals } // If checks pass, update velocity and timestamp this.setDirection(desiredDir.x, desiredDir.y); // Use inherited setDirection this.lastTurnTimestamp = currentTimestamp; return true; } // Override update to include AI-specific baseSpeed calculation override update(deltaTime: number, worldWidth: number, worldHeight: number): void { if (!this.visible) return; // --- AI-Specific Base Speed Calculation --- // Adjust base speed based on length *before* calling super.update() // so that the base update uses the correct base speed if resetting from boost. this.baseSpeed = AI_BASE_SPEED + Math.min(this.length, 200) * 0.15; // simple scale // --- Call Base Update Logic --- // Handles movement, segment following, growth animation, basic timer decrements super.update(deltaTime, worldWidth, worldHeight); // --- Additional AI-Specific Updates After Base --- // (None currently needed) } // Override eatOrb to add effects similar to player, then call base method override eatOrb(orbValue: number, growthAmount: number): void { // AI uses the growthAmount passed in (usually orb.value from Game.ts) // instead of PLAYER_LENGTH_PER_ORB calculation. // Call base eatOrb to handle core logic (score, length, queue) super.eatOrb(orbValue, growthAmount); // AI-specific effects (using player constants as placeholders) // TODO: Define AI-specific constants in aiConstants.ts this.glowFrames = PLAYER_EAT_GLOW_FRAMES; this.targetSpeed = this.baseSpeed * PLAYER_EAT_SPEED_BOOST; // Set target speed this.speedBoostTimer = PLAYER_EAT_SPEED_BOOST_DURATION_MS / 1000; } /** * syncPixi - AI-specific rendering override, based on PlayerSerpent's advanced effects. * Renders head with eyes, body with pulse/glow, but no mouth or safe-neck coloring. */ override syncPixi(_playerSkipCount: number, worldWidth: number, worldHeight: number): void { // _playerSkipCount is unused for AI rendering logic, but kept for signature match if (!this.pixiObject) return; this.pixiObject.visible = this.visible; if (!this.visible || this.segs.length === 0) { this.pixiObject.clear(); return; } const graphics = this.pixiObject; graphics.clear(); // 1. Setup Constants (mostly copied from PlayerSerpent, removed safe-neck) const radius = segRadius(this.length); const headRadius = radius * 1.2; const baseHexColor = this.color; const baseAlpha = 1.0; const baseOutlineColor = 0xffffff; const basePlayerGlowColor = 0xFFFF00; // Yellow base glow (use same for AI for now) let basePlayerGlowStrength = 9; // Base strength, will be made dynamic const baseGlowAlpha = 0.25; // Removed safeNeckColor, safeNeckGlowStrength, safeNeckTransitionSegments const pulseColor = 0xffffff; // Body color during pulse const pulseGlowColor = 0xffffff; // Glow color during pulse const pulseGlowBoost = 4; const pulseWidthMultiplier = 0.5; const secondaryPulseWidthFactor = 2.0; const secondaryPulseGlowAlpha = 0.05; const secondaryPulseGlowBoost = 2; // --- Dynamic Glow Strength based on Speed --- const speedRatio = this.baseSpeed > 0 ? this.speed / this.baseSpeed : 1; const dynamicGlowFactor = Math.max(0.8, Math.min(1.5, 1 + (speedRatio - 1) * 0.3)); const dynamicPlayerGlowStrength = basePlayerGlowStrength * dynamicGlowFactor; // 2. Calculate cumulative distances for pulse effects let cumulativeDistances: number[] = [0]; let currentTotalDistance = 0; for (let i = 0; i < this.segs.length - 1; i++) { const segA = this.segs[i]; const segB = this.segs[i + 1]; const wrapDist = Math.abs(segA.x - segB.x) > worldWidth / 2 || Math.abs(segA.y - segB.y) > worldHeight / 2; if (!wrapDist) { currentTotalDistance += dist(segA, segB); } cumulativeDistances.push(currentTotalDistance); } // 3. --- Draw Body Segments as Circles (excluding head) --- for (let i = this.segs.length - 1; i >= 1; i--) { const seg = this.segs[i]; const scaleFactor = seg.growthProgress ?? 1.0; const segmentMidpointDistance = cumulativeDistances[i]; // --- Calculate Swell Factors --- let primarySwellFactor = 0; let secondarySwellFactor = 0; const primaryPulseWaveWidth = segRadius(this.length) * 8; const secondaryPulseWaveWidth = primaryPulseWaveWidth * secondaryPulseWidthFactor; for (const pulse of this.eatQueue) { const distFromPulseCenter = Math.abs(segmentMidpointDistance - pulse.distanceTraveled); if (distFromPulseCenter < primaryPulseWaveWidth / 2) { primarySwellFactor = Math.max(primarySwellFactor, Math.cos(distFromPulseCenter / (primaryPulseWaveWidth / 2) * (Math.PI / 2))); } if (distFromPulseCenter < secondaryPulseWaveWidth / 2) { secondarySwellFactor = Math.max(secondarySwellFactor, Math.cos(distFromPulseCenter / (secondaryPulseWaveWidth / 2) * (Math.PI / 2))); } } const isPulsing = primarySwellFactor > 0.01; const isSecondaryPulsing = secondarySwellFactor > 0.01; // --- Determine Segment State (AI doesn't have safe neck) --- // const isStrictlySafe = false; // AI doesn't use this // const isInTransition = false; // AI doesn't use this const transitionFactor = 1; // Always fully normal color for AI // --- Base Style Calculation (Simplified for AI) --- const normalBodyColor = this.glowFrames > 0 ? 0xffffff : baseHexColor; const normalGlowColor = basePlayerGlowColor; // Use same base glow for now const normalGlowStrength = dynamicPlayerGlowStrength; let baseBodyC = lerpColor(normalBodyColor, normalBodyColor, transitionFactor); // Simplified lerp let baseGlowC = lerpColor(normalGlowColor, normalGlowColor, transitionFactor); // Simplified lerp let baseGlowS = normalGlowStrength; // AI uses normal strength // --- Apply Pulse Effects --- const actualBodyColor = isPulsing ? pulseColor : baseBodyC; const actualGlowColor = isPulsing ? pulseGlowColor : baseGlowC; const actualGlowStrength = isPulsing ? (baseGlowS + pulseGlowBoost) : baseGlowS; // --- Calculate Radii --- const radiusWithSwell = radius * (1 + primarySwellFactor * pulseWidthMultiplier); const currentRadius = radiusWithSwell * scaleFactor; const currentOutlineRadius = currentRadius + Math.max(0.5, 1 * scaleFactor); const currentGlowRadius = currentRadius + (actualGlowStrength / 2) * scaleFactor; const secondaryPulseRadiusBoost = (secondaryPulseGlowBoost / 2) * secondarySwellFactor * scaleFactor; const currentSecondaryGlowRadius = currentRadius + secondaryPulseRadiusBoost; const currentAlpha = baseAlpha * scaleFactor; // --- Draw Layers --- if (currentRadius > 0.1) { // 0. Secondary Pulse Glow if (isSecondaryPulsing && currentSecondaryGlowRadius > currentRadius) { // graphics.lineStyle(0); // No longer needed graphics.fill({ color: pulseGlowColor, alpha: secondaryPulseGlowAlpha * scaleFactor }); graphics.circle(seg.x, seg.y, currentSecondaryGlowRadius); } // 1. Outline graphics.setStrokeStyle({ width: Math.max(1, 2 * scaleFactor), color: baseOutlineColor, alpha: currentAlpha }); // Use setStrokeStyle graphics.moveTo(seg.x + currentOutlineRadius, seg.y); graphics.arc(seg.x, seg.y, currentOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // 2. Primary Glow // graphics.lineStyle(0); // No longer needed graphics.fill({ color: actualGlowColor, alpha: baseGlowAlpha * scaleFactor }); graphics.circle(seg.x, seg.y, currentGlowRadius); // 3. Body graphics.fill({ color: actualBodyColor, alpha: currentAlpha }); graphics.circle(seg.x, seg.y, currentRadius); } } // End of body segment circle loop // 4. --- Head & Eyes (Layered) --- (Copied from Player, removed mouth) if (this.segs.length > 0) { const headSeg = this.segs[0]; const headGrowth = headSeg.growthProgress ?? 1.0; const currentHeadRadius = headRadius * headGrowth; if (currentHeadRadius > 0.1) { const headColor = this.glowFrames > 0 ? 0xffffff : baseHexColor; const headGlowStrength = dynamicPlayerGlowStrength + 2; const currentHeadGlowRadius = currentHeadRadius + (headGlowStrength / 2) * headGrowth; const currentHeadOutlineRadius = currentHeadRadius + 1 * headGrowth; const currentHeadAlpha = baseAlpha * headGrowth; // 1. Head Outline graphics.setStrokeStyle({ width: Math.max(1, 2 * headGrowth), color: baseOutlineColor, alpha: currentHeadAlpha }); // Use setStrokeStyle graphics.moveTo(headSeg.x + currentHeadOutlineRadius, headSeg.y); graphics.arc(headSeg.x, headSeg.y, currentHeadOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // 2. Head Glow // graphics.lineStyle(0); // No longer needed graphics.fill({ color: basePlayerGlowColor, alpha: baseGlowAlpha * headGrowth }); graphics.circle(headSeg.x, headSeg.y, currentHeadGlowRadius); // 3. Head Fill graphics.fill({ color: headColor, alpha: currentHeadAlpha }); graphics.circle(headSeg.x, headSeg.y, currentHeadRadius); // --- Draw Eyes --- const finalEyeRadius = Math.max(1, currentHeadRadius * 0.2); const eyeDist = currentHeadRadius * 0.5; const eyeAngle = Math.atan2(this.velocity.vy, this.velocity.vx); const eyeAnglePerp = eyeAngle + Math.PI / 2; const eye1X = headSeg.x + Math.cos(eyeAnglePerp) * eyeDist; const eye1Y = headSeg.y + Math.sin(eyeAnglePerp) * eyeDist; const eye2X = headSeg.x - Math.cos(eyeAnglePerp) * eyeDist; const eye2Y = headSeg.y - Math.sin(eyeAnglePerp) * eyeDist; const eyeGlowColor = 0xffffff; const eyeGlowAlpha = 0.15 * headGrowth; const eyeGlowRadius = finalEyeRadius + Math.max(0.5, 2 * headGrowth); const eyeFillColor = 0x000000; const eyeFillAlpha = 0.9 * headGrowth; // Eye Glow graphics.fill({ color: eyeGlowColor, alpha: eyeGlowAlpha }); graphics.circle(eye1X, eye1Y, eyeGlowRadius); graphics.circle(eye2X, eye2Y, eyeGlowRadius); // Eye Fill graphics.fill({ color: eyeFillColor, alpha: eyeFillAlpha }); graphics.circle(eye1X, eye1Y, finalEyeRadius); graphics.circle(eye2X, eye2Y, finalEyeRadius); // --- Mouth Removed for AI --- // if (this.isPlayer) { ... } // Removed block } } } // End syncPixi override }

src/CollisionSystem.ts

import { SnakeState, segRadius } from './types'; import { dist, distTorus } from './utils'; import EntityManager from './EntityManager'; import SpatialHashGrid from './SpatialHashGrid'; // Import the grid import PlayerSerpent from './PlayerSerpent'; // Import for instanceof check import AISerpent from './AISerpent'; // Import for instanceof check // Define callback function types for clarity type KillCallback = (snake: SnakeState) => void; type AbsorbCallback = (winner: SnakeState, loser: SnakeState) => void; /** * Resolves collisions between snake heads and bodies. * @param manager The EntityManager instance containing all game entities. * @param worldW The width of the game world. * @param worldH The height of the game world. * @param killCb Callback function to execute when a snake should be killed. * @param absorbCb Callback function to execute when a snake absorbs another. * @param grid Optional SpatialHashGrid for optimized querying. */ export function resolveCollisions( manager: EntityManager, worldW: number, // No longer prefixed as unused worldH: number, // No longer prefixed as unused killCb: KillCallback, absorbCb: AbsorbCallback, grid?: SpatialHashGrid | null // Make grid optional ): void { const snakes = manager.getAllSnakes(); const checkedPairs = new Set<string>(); // For head-on checks // --- Head vs Body Collisions --- snakes.forEach(attacker => { if (!attacker.visible) return; const head = attacker.segs[0]; const atkRad = segRadius(attacker.length); const queryBounds = { x: head.x, y: head.y, radius: atkRad * 2 }; // Query slightly larger area // Query potential colliders using the grid if available, otherwise check all snakes const potentialColliders = grid ? grid.query(queryBounds) : new Set(snakes); potentialColliders.forEach(collider => { // Ensure collider is a snake, is visible, and is not the attacker itself if (!(collider instanceof PlayerSerpent || collider instanceof AISerpent) || !collider.visible || collider === attacker) { return; } const victim = collider as SnakeState; // Cast to SnakeState for segment access // Check collision against victim's body segments (skip neck) const victimRad = segRadius(victim.length); // Determine skipCount based on victim type const skipCount = (victim instanceof PlayerSerpent) ? victim.calculateSkipSegments() // Player uses its method : 6; // AI uses a fixed skip count for (let idx = skipCount; idx < victim.segs.length; idx++) { const seg = victim.segs[idx]; if (!seg) continue; // Safety check if (distTorus(head, seg, worldW, worldH) < atkRad + victimRad) { // Determine outcome based on attacker/victim types if (attacker.isPlayer) { // Player head hits AI body killCb(attacker); } else if (victim.isPlayer) { // AI head hits Player body killCb(attacker); absorbCb(victim, attacker); } else { // AI head hits other AI body killCb(attacker); absorbCb(victim, attacker); } // Exit loops for this attacker once a collision is resolved return; // Exit potentialColliders.forEach for this attacker } } // If attacker died during the inner loop, stop checking further victims for this attacker if (!attacker.visible) return; // Exit potentialColliders.forEach early }); }); // --- Head-on Collisions --- snakes.forEach(s1 => { if (!s1.visible) return; const head1 = s1.segs[0]; const rad1 = segRadius(s1.length); const queryBoundsHead = { x: head1.x, y: head1.y, radius: rad1 * 2 }; const potentialHeadColliders = grid ? grid.query(queryBoundsHead) : new Set(snakes); potentialHeadColliders.forEach(collider => { // Ensure collider is a snake, is visible, and not s1 itself if (!(collider instanceof PlayerSerpent || collider instanceof AISerpent) || !collider.visible || collider === s1) { return; } const s2 = collider as SnakeState; // Prevent double-checking pairs const pairKey = s1.id < s2.id ? `${s1.id}-${s2.id}` : `${s2.id}-${s1.id}`; if (checkedPairs.has(pairKey)) { return; } checkedPairs.add(pairKey); const head2 = s2.segs[0]; const rad2 = segRadius(s2.length); // Use simple distance for head-on check (torus might be overkill here) if (dist(head1, head2) < rad1 + rad2) { // Head-on collision: Determine outcome if (s1.length > s2.length + 5) { // s1 significantly longer killCb(s2); absorbCb(s1, s2); } else if (s2.length > s1.length + 5) { // s2 significantly longer killCb(s1); absorbCb(s2, s1); } else { // Similar length or exact tie: both die killCb(s1); killCb(s2); } // Exit early if s1 died if (!s1.visible) return; } }); }); }

src/constants.ts

// Gameplay settings export const PLAYER_SPEED = 2.5; // Base speed export const PLAYER_TURN_RATE = 0.05; // Radians per frame export const SEGMENT_SPACING = 8; // Distance between segments export const INITIAL_PLAYER_LENGTH = 10; // Starting number of segments export const ORB_DENSITY = 50 / (1000 * 1000); // Orbs per square pixel export const ORB_RADIUS = 10; export const WORLD_PADDING = 50; // Padding from the effective screen edge // Rendering settings export const SEGMENT_RADIUS = 5; export const HEAD_RADIUS = 8; export const EYE_RADIUS = 2; export const EYE_OFFSET = 5; // Distance from head center export const BODY_COLOR = 0x00ff00; // Green export const HEAD_COLOR = 0x00dd00; // Slightly darker green export const EYE_COLOR = 0xffffff; // White export const BORDER_COLOR = 0xffffff; // White border export const BORDER_WIDTH = 2; export const BACKGROUND_VIDEO_PATH = '/cave_city_h264_compat.mp4'; // Or '/cave_city.mp4' // Collision settings export const SELF_COLLISION_THRESHOLD = 5; // Min segments away to trigger collision // UI settings export const SCORE_TEXT_STYLE = { fontFamily: 'Arial', fontSize: 24, fill: 0xffffff, // White align: 'right' as const, };

src/EntityManager.ts

import AISerpent from './AISerpent'; import PlayerSerpent from './PlayerSerpent'; import Orb from './Orb'; import { AI_COUNT } from './aiConstants'; import * as PIXI from 'pixi.js'; import { SnakeState } from './types'; // Import SnakeState for type safety export default class EntityManager { player!: PlayerSerpent; readonly ai: AISerpent[] = []; readonly orbs: Orb[] = []; private stage: PIXI.Container; constructor(stage: PIXI.Container) { this.stage = stage; } spawnPlayer(): void { this.player = new PlayerSerpent(0,0); this.player.initPixi(this.stage); } spawnAI(worldW:number, worldH:number): void { for(let i=0;i<AI_COUNT;i++){ const x = (Math.random()-0.5)*worldW; const y = (Math.random()-0.5)*worldH; // Generate distinct hues, avoiding pure red (0xFF0000) if possible // Cycle through hues, ensuring some separation const hueStep = 0xFFFFFF / (AI_COUNT + 1); // Divide color space const hue = (i + 1) * hueStep; // Start offset from 0 const s = new AISerpent(x,y, hue); s.initPixi(this.stage); this.ai.push(s); } } // Method to spawn a single AI serpent spawnSingleAI(worldW: number, worldH: number): void { // Check if we are already at or above the limit if (this.ai.length >= AI_COUNT) { console.log("AI count limit reached, not spawning new AI."); return; } const x = (Math.random() - 0.5) * worldW; const y = (Math.random() - 0.5) * worldH; // Simple random hue for respawned AI for now const hue = Math.random() * 0xFFFFFF; const s = new AISerpent(x, y, hue); s.initPixi(this.stage); this.ai.push(s); console.log(`Spawned single AI: ${s.id} at (${x.toFixed(0)}, ${y.toFixed(0)}). Total AI: ${this.ai.length}`); } /* expose flat list for collision queries */ // Ensure the returned array elements conform to SnakeState getAllSnakes(): SnakeState[] { // Explicitly cast PlayerSerpent and AISerpent[] to SnakeState[] // This assumes both classes correctly implement the SnakeState interface return [this.player as unknown as SnakeState, ...this.ai as unknown as SnakeState[]]; } // Method to remove a specific AI serpent removeAISerpent(serpentToRemove: AISerpent): void { const index = this.ai.indexOf(serpentToRemove); if (index > -1) { this.ai.splice(index, 1); } } // Method to remove a specific Orb removeOrb(orbToRemove: Orb): void { const index = this.orbs.indexOf(orbToRemove); if (index > -1) { this.orbs.splice(index, 1); } } }

src/Game.ts

import * as PIXI from 'pixi.js'; import InputHandler from './InputHandler'; import { UIManager, UIAction } from './UI'; // import PlayerSerpent from './PlayerSerpent'; // Removed unused import based on lint feedback // import AISerpent from './AISerpent'; // Removed unused import based on lint feedback import Orb from './Orb'; import EntityManager from './EntityManager'; import { resolveCollisions } from './CollisionSystem'; import { dist } from './utils'; import SpatialHashGrid from './SpatialHashGrid'; // Import the grid import { // Import types from types.ts KEYBOARD_TURN_COOLDOWN_MS, JOYSTICK_TURN_COOLDOWN_MS, segRadius, OrbTier, ORB_TIER_CONFIG, ORB_TOTAL_SPAWN_WEIGHT, PLAYER_INITIAL_LENGTH, // Import initial length for orb sizing ORB_BASE_RADIUS, // Import new orb sizing constants ORB_RADIUS_MULTIPLIER, SnakeState, // Added missing import // Import effect constants for absorption feedback PLAYER_EAT_GLOW_FRAMES, PLAYER_EAT_SPEED_BOOST, PLAYER_EAT_SPEED_BOOST_DURATION_MS } from './types'; import { // Import constants from constants.ts ORB_DENSITY, // ORB_RADIUS as CONST_ORB_RADIUS, // No longer needed, calculated dynamically WORLD_PADDING } from './constants'; // Removed unused import: import { AI_COUNT } from './aiConstants'; // --- Constants for Orb Generation (Placeholders - some moved/replaced) --- // ORB_COUNT is now dynamic // ORB_RADIUS is now calculated based on initial player segment size const CLUSTER_PROBABILITY = 0.3; // Increased chance for clusters const MIN_CLUSTER_SIZE = 4; const MAX_CLUSTER_SIZE = 7; const CLUSTER_RADIUS = 50; // Radius for random clusters // CLUSTER_LINE_SPACING is now calculated dynamically based on orbR // Define Game States export enum GameState { LOADING, // Initial state before assets are ready MENU, // Main menu is visible PLAYING, // Game is active PAUSED, // Pause menu is visible CONTROLS,// Controls menu is visible (can overlay Pause or Main) GAME_OVER // Game over screen is visible } class Game { // private renderer: Renderer; // Removed private pixiApp: PIXI.Application; // Added private pixiStage: PIXI.Container; // Added: Reference to the main stage private gameContainer: PIXI.Container; // Added: Container for game world objects (player, orbs, AI) private inputHandler: InputHandler; private uiManager: UIManager; private backgroundVideoElement: HTMLVideoElement; // Store the video element private gameState: GameState = GameState.LOADING; private previousState: GameState = GameState.LOADING; // private playerSerpent: PlayerSerpent | null = null; // Replaced by entities.player // private orbs: Orb[] = []; // Replaced by entities.orbs private entities!: EntityManager; // Added EntityManager private cameraX: number = 0; // Camera position X (relative to world origin) private cameraY: number = 0; // Camera position Y (relative to world origin) private parallaxFactor: number = 0.1; // Game specific state /* no stand-alone score field needed */ private scoreText: PIXI.Text | null = null; // Added for score display private devInfoText: PIXI.Text | null = null; // Added for dev mode display private devModeActive: boolean = false; // Flag for dev mode private borderGraphics: PIXI.Graphics | null = null; // Added for world border private worldWidth: number = 1000; // Default, will be updated private worldHeight: number = 1000; // Default, will be updated private lastMiniBoardUpdate: number = 0; private spatialGrid: SpatialHashGrid | null = null; // Added spatial grid member private readonly CELL_SIZE = 100; // Define cell size for the grid private aiRespawnTimeouts: number[] = []; // Added to track AI respawn timeouts // Reusable objects for optimization private _reusableKeyboardDir: { x: number, y: number } = { x: 0, y: 0 }; private _reusableQueryBounds: { x: number, y: number, radius: number } = { x: 0, y: 0, radius: 0 }; constructor(pixiApp: PIXI.Application, inputHandler: InputHandler, backgroundVideo: HTMLVideoElement) { console.log('Game constructor called'); this.pixiApp = pixiApp; // Store Pixi Application this.pixiStage = pixiApp.stage; // Store Pixi Stage this.inputHandler = inputHandler; this.backgroundVideoElement = backgroundVideo; this.uiManager = new UIManager(this.handleUIAction.bind(this)); // Create the main container for game objects this.gameContainer = new PIXI.Container(); this.pixiStage.addChild(this.gameContainer); // Initial world size calculation this.updateWorldSize(); } // Calculate dynamic world size based on video dimensions, zoom factor, and padding private updateWorldSize(): void { // Base size on video's intrinsic dimensions, fallback if not loaded const baseW = this.backgroundVideoElement.videoWidth || 3840; const baseH = this.backgroundVideoElement.videoHeight || 2160; // Use zoom factor 1.5 ONLY when playing, otherwise use 1.0 for calculations const zoom = this.gameState === GameState.PLAYING ? 1.5 : 1.0; // Effective size of the video content area after zoom const effectiveW = baseW * zoom; const effectiveH = baseH * zoom; // World size is the effective video area minus padding on each side this.worldWidth = effectiveW - WORLD_PADDING * 2; this.worldHeight = effectiveH - WORLD_PADDING * 2; // Ensure world size is not negative if padding is too large for video size/zoom this.worldWidth = Math.max(100, this.worldWidth); // Set a minimum world size this.worldHeight = Math.max(100, this.worldHeight); console.log(`Updated world size: ${this.worldWidth.toFixed(0)}x${this.worldHeight.toFixed(0)} (Video: ${baseW}x${baseH}, Zoom: ${zoom}, Padding: ${WORLD_PADDING})`); // Update border graphics if they exist if (this.borderGraphics) { this.borderGraphics.clear(); this.borderGraphics.rect(-this.worldWidth / 2, -this.worldHeight / 2, this.worldWidth, this.worldHeight); this.borderGraphics.stroke({ width: 5, color: 0xFFFFFF, alpha: 0.3 }); } } init(): void { console.log('Game init called'); this.uiManager.init(); // Initialize Dev Info Text (always add to stage, visibility controlled later) this.devInfoText = new PIXI.Text({ text: '', // Start empty style: { fontFamily: '"Courier New", Courier, monospace', fontSize: 14, fill: 0x00ff00, // Neon green align: 'left', wordWrap: true, // Enable word wrap wordWrapWidth: this.pixiApp.screen.width - 20, // Wrap near screen edge lineHeight: 18, stroke: { color: 0x000000, width: 2, join: 'round' }, // Black stroke for readability } }); this.devInfoText.position.set(10, 30); // Position below FPS counter this.devInfoText.visible = false; // Start hidden this.pixiStage.addChild(this.devInfoText); this.changeState(GameState.MENU); console.log('Game initialized, showing main menu.'); } // Central handler for actions triggered by UI buttons private handleUIAction(action: UIAction): void { console.log(`UI Action received: ${action}`); switch (action) { case 'startGame': this.startGame(); break; case 'showControls': this.showControls(); break; case 'hideControls': this.hideControls(); break; case 'resumeGame': this.resumeGame(); break; case 'newGameFromPause': case 'newGameFromGameOver': this.returnToMenu(); break; // case 'showScoreboard': // Placeholder for original scoreboard button if needed // console.log('Show scoreboard action'); // break; case 'toggleDevMode': this.toggleDevMode(); break; case 'togglePause': // Added case for the new button this.togglePause(); break; } } // Method to change game state and update UI accordingly private changeState(newState: GameState): void { if (this.gameState === newState) return; if (newState !== GameState.CONTROLS) { this.previousState = this.gameState; } console.log(`Changing state from ${GameState[this.gameState]} to ${GameState[newState]}`); this.gameState = newState; // Update UI and video zoom switch (this.gameState) { case GameState.MENU: this.uiManager.hideAllMenus(); this.uiManager.showMainMenu(); this.setVideoZoom(false); this.gameContainer.visible = false; // Hide game objects in menu break; case GameState.PLAYING: this.uiManager.hideAllMenus(); this.setVideoZoom(true); this.gameContainer.visible = true; // Show game objects if (this.scoreText) this.scoreText.visible = true; // Show score text break; case GameState.PAUSED: this.uiManager.hideAllMenus(); this.uiManager.showPauseMenu(); // Will be updated to pass scores this.setVideoZoom(false); this.gameContainer.visible = true; // Keep game objects visible but static break; case GameState.CONTROLS: // Visibility handled by showControls/hideControls this.setVideoZoom(false); // Keep gameContainer visibility as it was (usually true if paused, false if menu) this.gameContainer.visible = (this.previousState === GameState.PAUSED); break; case GameState.GAME_OVER: this.uiManager.hideAllMenus(); // Use player's score for game over screen this.uiManager.showGameOverMenu(this.entities?.player?.score ?? 0); // Use optional chaining and nullish coalescing this.setVideoZoom(false); this.gameContainer.visible = true; // Show final state if (this.scoreText) this.scoreText.visible = false; // Hide score text break; } // Hide score text in non-playing states by default if (this.gameState !== GameState.PLAYING && this.scoreText) { this.scoreText.visible = false; } } // Helper method to control video zoom (targets the wrapper) and lock transition private setVideoZoom(zoomIn: boolean): void { const scale = zoomIn ? 1.5 : 1; const wrap = document.getElementById('backgroundWrap'); if (wrap) { // Ensure transition is enabled before changing transform wrap.style.transition = 'transform .5s ease'; wrap.style.transform = `scale(${scale})`; console.log(`Setting #backgroundWrap zoom to scale(${scale}) with transition`); // Function to lock the transition after it ends const lockTransition = () => { wrap.style.transition = 'none'; // Disable transition after animation wrap.removeEventListener('transitionend', lockTransition); // Clean up listener console.log("Background zoom transition locked."); }; // Remove any existing listener before adding a new one wrap.removeEventListener('transitionend', lockTransition); // Add listener to lock transition after it completes wrap.addEventListener('transitionend', lockTransition); } else { console.error("Could not find #backgroundWrap element to apply zoom."); } } // --- Game State Control Methods --- startGame(): void { if (this.gameState === GameState.MENU || this.gameState === GameState.GAME_OVER) { this.cleanupGameEntities(); // Clean up previous game objects, including grid // Create entities using EntityManager this.entities = new EntityManager(this.gameContainer); this.entities.spawnPlayer(); // Generate orbs and add them to the manager const generatedOrbs = this.generateOrbs(); // Generate first this.entities.orbs.push(...generatedOrbs); // Then add to manager this.entities.orbs.forEach(o => o.initPixi(this.gameContainer)); // Init Pixi for orbs // Change state to PLAYING *before* calculating world size, spawning AI, and creating grid this.changeState(GameState.PLAYING); // Initialize World Border Graphics (now uses PLAYING state zoom) // Initialize World Border Graphics (now uses PLAYING state zoom) this.updateWorldSize(); // Ensure world size is calculated *after* state change // Instantiate the Spatial Hash Grid *after* world size is determined for PLAYING state this.spatialGrid = new SpatialHashGrid(this.worldWidth, this.worldHeight, this.CELL_SIZE); if (!this.borderGraphics) { this.borderGraphics = new PIXI.Graphics(); // Draw initial border (updateWorldSize will redraw if needed) this.borderGraphics.rect(-this.worldWidth / 2, -this.worldHeight / 2, this.worldWidth, this.worldHeight); this.borderGraphics.stroke({ width: 5, color: 0xFFFFFF, alpha: 0.3 }); this.gameContainer.addChild(this.borderGraphics); } else { // Ensure border is redrawn with correct size if restarting game this.borderGraphics.clear(); this.borderGraphics.rect(-this.worldWidth / 2, -this.worldHeight / 2, this.worldWidth, this.worldHeight); this.borderGraphics.stroke({ width: 5, color: 0xFFFFFF, alpha: 0.3 }); } // Reset camera this.cameraX = 0; this.cameraY = 0; this.updateGameContainerPosition(); // Apply initial camera position // Initialize Score Text if (!this.scoreText) { this.scoreText = new PIXI.Text({ text: `Score: ${this.entities.player.score}`, style: { fontFamily: '"Courier New", Courier, monospace', fontSize: 24, fill: 0xffffff, // White align: 'right', stroke: { color: 0x000000, width: 4, join: 'round' }, // Black stroke dropShadow: { color: '#000000', blur: 4, angle: Math.PI / 6, distance: 3 }, } }); this.scoreText.anchor.set(1, 0); // Anchor top-right this.scoreText.position.set(this.pixiApp.screen.width - 10, 10); // Position top-right this.pixiStage.addChild(this.scoreText); // Add to main stage } else { this.scoreText.text = `Score: ${this.entities.player.score}`; // Reset text this.scoreText.visible = true; // Ensure visible } // Spawn AI *after* world size is updated based on PLAYING state zoom this.updateWorldSize(); // Ensure world size is calculated *after* state change this.entities.spawnAI(this.worldWidth, this.worldHeight); // Spawn AI // Log after all spawns console.log(`Starting new game... Player spawned, ${this.entities.orbs.length} orbs generated, ${this.entities.ai.length} AI spawned.`); // this.changeState(GameState.PLAYING); // Moved earlier } } pauseGame(): void { if (this.gameState === GameState.PLAYING) { // Gather scores before changing state if (this.entities) { const currentScores = this.entities.getAllSnakes() .filter(s => s.visible) // Only include visible snakes .map(s => ({ name: s.name, score: s.score, isPlayer: s.isPlayer })); this.uiManager.updatePauseLeaderboard(currentScores); } else { this.uiManager.updatePauseLeaderboard([]); // Show empty if no entities } this.changeState(GameState.PAUSED); this.uiManager.updatePauseButton(true); // Update button to show "Resume" icon } } resumeGame(): void { if (this.gameState === GameState.PAUSED || this.gameState === GameState.CONTROLS) { this.uiManager.hideControlsMenu(); // Ensure controls are hidden if resuming from there this.changeState(GameState.PLAYING); this.uiManager.updatePauseButton(false); // Update button to show "Pause" icon } } showControls(): void { if (this.gameState === GameState.MENU || this.gameState === GameState.PAUSED) { this.previousState = this.gameState; this.uiManager.hideAllMenus(); this.uiManager.showControlsMenu(); this.changeState(GameState.CONTROLS); } } hideControls(): void { if (this.gameState === GameState.CONTROLS) { this.uiManager.hideControlsMenu(); // Restore the previous menu/state if (this.previousState === GameState.MENU) { this.changeState(GameState.MENU); // changeState handles showing the correct menu } else if (this.previousState === GameState.PAUSED) { this.changeState(GameState.PAUSED); // changeState handles showing the correct menu } else { this.changeState(GameState.MENU); // Fallback } } } // Method to toggle pause state via the button private togglePause(): void { if (this.gameState === GameState.PLAYING) { this.pauseGame(); } else if (this.gameState === GameState.PAUSED) { this.resumeGame(); } // No action if in menu, game over, etc. } gameOver(): void { if (this.gameState === GameState.PLAYING) { console.log("Game Over!"); this.changeState(GameState.GAME_OVER); // Entities remain visible but game loop stops updating them } } returnToMenu(): void { this.cleanupGameEntities(); // Clean up before returning to menu this.changeState(GameState.MENU); } // Helper to remove all game entities and their Pixi objects, and clear the grid private cleanupGameEntities(): void { console.log("Cleaning up game entities, spatial grid, and pending respawns..."); // Clear pending AI respawn timeouts first this.aiRespawnTimeouts.forEach(timeoutId => clearTimeout(timeoutId)); this.aiRespawnTimeouts = []; // Reset the array // Clear grid this.spatialGrid?.clear(); this.spatialGrid = null; if (this.entities) { if (this.entities.player) { this.entities.player.destroyPixi(); // No need to null player, EntityManager will be replaced } this.entities.orbs.forEach(orb => orb.destroyPixi()); this.entities.ai.forEach(s => s.destroyPixi()); // Clear arrays within the existing manager instance before potentially replacing it this.entities.orbs.length = 0; this.entities.ai.length = 0; // No need to set this.entities = null, it gets overwritten in startGame } // Clean up score text if (this.scoreText) { this.scoreText.destroy(); this.scoreText = null; } // Clean up border graphics if (this.borderGraphics) { this.borderGraphics.destroy(); this.borderGraphics = null; } // Clear the container explicitly (might be redundant if destroyPixi removes children) // Note: destroyPixi in PlayerSerpent/Orb should handle removing children, but this is safer. this.gameContainer.removeChildren(); } // --- Orb Generation --- // Helper function for weighted random tier selection private getRandomOrbTier(): OrbTier { let randomWeight = Math.random() * ORB_TOTAL_SPAWN_WEIGHT; for (const tierStr in ORB_TIER_CONFIG) { const tier = parseInt(tierStr) as OrbTier; // Convert string key back to enum number const config = ORB_TIER_CONFIG[tier]; if (randomWeight < config.spawnWeight) { return tier; } randomWeight -= config.spawnWeight; } return OrbTier.LOW; // Fallback to LOW tier } // Generates orbs based on world size and density constant, attempting to avoid overlaps private generateOrbs(): Orb[] { const generatedOrbs: Orb[] = []; const maxPlacementAttempts = 10; // Max tries to place an orb without overlap // Calculate dynamic orb count based on density const orbCount = Math.round(this.worldWidth * this.worldHeight * ORB_DENSITY); console.log(`Generating approximately ${orbCount} orbs (with overlap avoidance) for world size ${this.worldWidth.toFixed(0)}x${this.worldHeight.toFixed(0)}`); // Use dynamic world size for orb generation bounds const halfWidth = this.worldWidth / 2; const halfHeight = this.worldHeight / 2; // Helper to check for overlap - checks center distance against combined radii const isOverlapping = (x: number, y: number, radius: number, existingOrbs: Orb[]): boolean => { for (const existing of existingOrbs) { // Use sum of radii for check to ensure visual separation if (dist({ x, y }, existing) < radius + existing.radius) { return true; } } return false; }; // Calculate the desired orb radius once const baseSegR = segRadius(PLAYER_INITIAL_LENGTH); const orbR = Math.max(ORB_BASE_RADIUS, baseSegR * ORB_RADIUS_MULTIPLIER); const adjustedLineSpacing = orbR * 2.5; // Spacing for line clusters based on new radius let orbsAttempted = 0; // Track attempts to prevent infinite loops in edge cases const maxTotalAttempts = orbCount * maxPlacementAttempts * 2; // Safety break while (generatedOrbs.length < orbCount && orbsAttempted < maxTotalAttempts) { orbsAttempted++; // Decide whether to generate a cluster or single orb const isCluster = Math.random() < CLUSTER_PROBABILITY && (orbCount - generatedOrbs.length) >= MIN_CLUSTER_SIZE; if (isCluster) { const clusterSize = Math.floor(Math.random() * (MAX_CLUSTER_SIZE - MIN_CLUSTER_SIZE + 1)) + MIN_CLUSTER_SIZE; let clusterOrbsPlaced = 0; // Try finding a center for the cluster first (fewer attempts needed here) let clusterCenterX = 0; let clusterCenterY = 0; let centerFound = false; for (let attempt = 0; attempt < maxPlacementAttempts; attempt++) { clusterCenterX = Math.random() * this.worldWidth - halfWidth; clusterCenterY = Math.random() * this.worldHeight - halfHeight; // Check if center itself is clear enough (using orbR as a proxy) if (!isOverlapping(clusterCenterX, clusterCenterY, orbR, generatedOrbs)) { centerFound = true; break; } } if (!centerFound) continue; // Skip this cluster attempt if center is too crowded const isLineCluster = Math.random() < 0.5; if (isLineCluster) { const lineAngle = Math.random() * Math.PI * 2; const dx = Math.cos(lineAngle) * adjustedLineSpacing; // Use adjusted spacing const dy = Math.sin(lineAngle) * adjustedLineSpacing; let currentX = clusterCenterX - (dx * (clusterSize - 1)) / 2; let currentY = clusterCenterY - (dy * (clusterSize - 1)) / 2; for (let i = 0; i < clusterSize && generatedOrbs.length < orbCount; i++) { // Check overlap for the specific position in the line if (!isOverlapping(currentX, currentY, orbR, generatedOrbs)) { const tier = this.getRandomOrbTier(); generatedOrbs.push(new Orb(currentX, currentY, orbR, tier)); clusterOrbsPlaced++; } else { // Optional: Could try slightly nudging position, but skipping is simpler // console.log("Skipping line cluster orb due to overlap."); } currentX += dx; currentY += dy; } } else { // Scatter Cluster for (let i = 0; i < clusterSize && generatedOrbs.length < orbCount; i++) { let orbX = 0, orbY = 0; let positionFound = false; for (let attempt = 0; attempt < maxPlacementAttempts; attempt++) { const angle = Math.random() * Math.PI * 2; const radiusOffset = Math.random() * CLUSTER_RADIUS; // Keep original cluster radius orbX = clusterCenterX + Math.cos(angle) * radiusOffset; orbY = clusterCenterY + Math.sin(angle) * radiusOffset; if (!isOverlapping(orbX, orbY, orbR, generatedOrbs)) { positionFound = true; break; } } if (positionFound) { const tier = this.getRandomOrbTier(); generatedOrbs.push(new Orb(orbX, orbY, orbR, tier)); clusterOrbsPlaced++; } else { // console.log("Skipping scatter cluster orb due to overlap after attempts."); } } } // console.log(`Cluster attempt: Placed ${clusterOrbsPlaced}/${clusterSize} orbs.`); } else { // Single Orb let orbX = 0, orbY = 0; let positionFound = false; for (let attempt = 0; attempt < maxPlacementAttempts; attempt++) { orbX = Math.random() * this.worldWidth - halfWidth; orbY = Math.random() * this.worldHeight - halfHeight; if (!isOverlapping(orbX, orbY, orbR, generatedOrbs)) { positionFound = true; break; } } if (positionFound) { const tier = this.getRandomOrbTier(); generatedOrbs.push(new Orb(orbX, orbY, orbR, tier)); } else { // console.log("Skipping single orb due to overlap after attempts."); } } // The main while loop condition (generatedOrbs.length < orbCount) handles stopping. } if (orbsAttempted >= maxTotalAttempts && generatedOrbs.length < orbCount) { console.warn(`Orb generation stopped early after ${maxTotalAttempts} attempts to avoid potential infinite loop. Generated ${generatedOrbs.length}/${orbCount} orbs.`); } else { console.log(`Successfully generated ${generatedOrbs.length} orbs.`); } return generatedOrbs; } // --- Core Game Loop Methods --- update(deltaTime: number): void { // Update Dev Info Text regardless of game state if active if (this.devModeActive && this.devInfoText && this.entities?.player) { this.updateDevInfoText(); this.devInfoText.visible = true; } else if (this.devInfoText) { this.devInfoText.visible = false; } // Game logic update only happens when PLAYING if (this.gameState !== GameState.PLAYING || !this.entities) { // Still update camera and parallax even if paused, to allow looking around? // Or maybe only update if paused? Let's keep it simple for now: only update if PLAYING. return; } // --- START of PLAYING state logic --- // Get entities for easier access const { player, ai, orbs } = this.entities; const now = performance.now(); // Get current time once // --- Update Spatial Grid --- // Use optional chaining for safety, although grid should exist in PLAYING state this.spatialGrid?.clear(); if (this.spatialGrid) { // Keep the block check for insertions // Insert Player if (player.visible) { const playerRadius = segRadius(player.length); // Insert head this.spatialGrid?.insert(player, { x: player.segs[0].x, y: player.segs[0].y, radius: playerRadius }); // Add optional chaining // Insert relevant body segments (skip neck) const skipCount = player.calculateSkipSegments(); // Player has this method for (let i = skipCount; i < player.segs.length; i++) { // Store the player object itself for segments, maybe add index later if needed this.spatialGrid?.insert(player, { x: player.segs[i].x, y: player.segs[i].y, radius: playerRadius }); // Add optional chaining } } // Insert AI Snakes ai.forEach(s => { if (s.visible) { const aiRadius = segRadius(s.length); // Insert head this.spatialGrid?.insert(s, { x: s.segs[0].x, y: s.segs[0].y, radius: aiRadius }); // Add optional chaining // Insert relevant body segments (skip neck) const skipCount = 6; // Use a fixed skip count for AI grid insertion for (let i = skipCount; i < s.segs.length; i++) { this.spatialGrid?.insert(s, { x: s.segs[i].x, y: s.segs[i].y, radius: aiRadius }); // Add optional chaining } } }); // Insert Orbs orbs.forEach(orb => { if (orb.visible) { this.spatialGrid?.insert(orb, { x: orb.x, y: orb.y, radius: orb.radius }); } }); } // End of spatialGrid check block // --- Player Input & Physics --- // Extracted logic for getting desired direction (can be refactored later) let desiredDir = this.inputHandler.getJoystickDirection(); let turnCooldown = JOYSTICK_TURN_COOLDOWN_MS; let isJoystickActive = desiredDir.x !== 0 || desiredDir.y !== 0; if (!isJoystickActive) { const keyboardDir = this.inputHandler.getKeyboardDirection(); // Gets a potentially non-zero vector if (keyboardDir.x !== 0 || keyboardDir.y !== 0) { const magnitude = Math.hypot(keyboardDir.x, keyboardDir.y); // Update and use the reusable object this._reusableKeyboardDir.x = keyboardDir.x / magnitude; this._reusableKeyboardDir.y = keyboardDir.y / magnitude; desiredDir = this._reusableKeyboardDir; turnCooldown = KEYBOARD_TURN_COOLDOWN_MS; } else { // Input handler now returns a reusable zero vector if no keys pressed desiredDir = keyboardDir; // Use the zero vector returned by inputHandler } } // Apply turn if there's input if (desiredDir.x !== 0 || desiredDir.y !== 0) { player.attemptTurn(desiredDir, now, turnCooldown); } // Update player physics player.update(deltaTime, this.worldWidth, this.worldHeight); // Update camera to follow player this.cameraX = player.segs[0].x; this.cameraY = player.segs[0].y; // Update parallax and container position based on camera this.updateBackgroundParallax(); this.updateGameContainerPosition(); // Sync player graphics player.syncPixi(player.calculateSkipSegments(), this.worldWidth, this.worldHeight); // --- AI Update & Sync --- ai.forEach(s => { if (!s.visible) return; // Skip dead AI // 1. Call AI-specific logic (decision making, turning) s.updateAI(now, orbs, this.entities.getAllSnakes(), this.worldWidth, this.worldHeight); // 2. Call the base update method for physics (movement, growth, etc.) s.update(deltaTime, this.worldWidth, this.worldHeight); // 3. Sync graphics (AI uses base syncPixi which doesn't need skip count, pass 0) s.syncPixi(0, this.worldWidth, this.worldHeight); }); // ---- AI eats orbs ---- // Refactored to use spatial grid ai.forEach(bot => { if (!bot.visible) return; // Skip dead bots const head = bot.segs[0]; const botRad = segRadius(bot.length); const orbsToRemoveAI: Orb[] = []; if (this.spatialGrid) { // Update and use reusable query bounds this._reusableQueryBounds.x = head.x; this._reusableQueryBounds.y = head.y; this._reusableQueryBounds.radius = botRad * 2; const potentialOrbsAI = this.spatialGrid.query(this._reusableQueryBounds); potentialOrbsAI.forEach(entity => { if (entity instanceof Orb && entity.visible) { const orb = entity; if (dist(head, orb) < botRad + orb.radius) { // AI uses the base eatOrb, pass orbValue and simple growthAmount const growthAmount = orb.value; // AI growth = orb value bot.eatOrb(orb.value, growthAmount); orb.visible = false; orb.destroyPixi(); orbsToRemoveAI.push(orb); } } }); } else { // Fallback: Iterate all orbs if grid is not available for (let k = orbs.length - 1; k >= 0; k--) { const orb = orbs[k]; if (!orb.visible) continue; if (dist(head, orb) < botRad + orb.radius) { // AI uses the base eatOrb, pass orbValue and simple growthAmount const growthAmount = orb.value; // AI growth = orb value bot.eatOrb(orb.value, growthAmount); orb.visible = false; orb.destroyPixi(); orbsToRemoveAI.push(orb); // Still collect for removal below } } } // Remove collected orbs from the main list orbsToRemoveAI.forEach(orb => this.entities.removeOrb(orb)); }); // --- Orb Sync --- (Update logic removed as they are static for now) orbs.forEach(o => o.syncPixi()); // Sync remaining orbs // --- Collisions --- (Pass grid to collision functions if available) resolveCollisions(this.entities, this.worldWidth, this.worldHeight, (snake) => this.killSnake(snake), (winner, loser) => this.absorb(winner, loser), this.spatialGrid); // Pass the grid instance (can be null) // --- Check Orb Collisions (Player Head vs Orbs) --- this.checkOrbCollisions(); // Separate check for player eating orbs // --- Update Score Text --- if (this.scoreText) { this.scoreText.text = `Score: ${this.entities.player.score}`; } // --- Mini-leaderboard (update each ½ sec) --- if (now - (this.lastMiniBoardUpdate ?? 0) > 500) { this.lastMiniBoardUpdate = now; const scores = this.entities.getAllSnakes() .filter(s => s.visible) .map(s => ({ name: s.name, score: s.score, isPlayer: s.isPlayer })); this.uiManager.updateMiniLeaderboard(scores); } // --- Update Dev Info Display --- // Ensure player exists before accessing properties for dev info if (this.devModeActive && this.devInfoText && player) { this.updateDevInfoText(); this.devInfoText.visible = true; } else if (this.devInfoText) { this.devInfoText.visible = false; } // Example: Check for game over condition // if (some_condition) { // this.gameOver(); // } } // render(deltaTime: number): void { // REMOVED - PixiJS handles rendering via ticker // } // --- Collision Detection --- (REMOVED - Handled by resolveCollisions and checkOrbCollisions) // private checkCollisions(): void { ... } // --- Orb Collision Check (Player only) --- private checkOrbCollisions(): void { if (!this.entities || !this.entities.player || !this.entities.player.visible) return; const player = this.entities.player; const head = player.segs[0]; const playerRadius = segRadius(player.length); // Use spatial grid for orb collisions if available, otherwise fallback to iteration if (this.spatialGrid) { // Update and use reusable query bounds this._reusableQueryBounds.x = head.x; this._reusableQueryBounds.y = head.y; this._reusableQueryBounds.radius = playerRadius * 2; const potentialOrbs = this.spatialGrid.query(this._reusableQueryBounds); const orbsToRemove: Orb[] = []; // Collect orbs to remove after iteration potentialOrbs.forEach(entity => { // Check if the entity is a visible Orb if (entity instanceof Orb && entity.visible) { const orb = entity; const distance = dist(head, orb); const collisionThreshold = playerRadius + orb.radius; if (distance < collisionThreshold) { // Collision detected! // console.log("Player ate orb!"); // Less verbose // Player.eatOrb override handles growth calculation and calls super. // We only need to pass the original orbValue here. player.eatOrb(orb.value); // Mark orb for removal orb.visible = false; orb.destroyPixi(); orbsToRemove.push(orb); // Add to removal list } } }); // Remove collected orbs from the main list orbsToRemove.forEach(orb => this.entities.removeOrb(orb)); } else { // Fallback: Iterate backwards to safely remove orbs while looping for (let i = this.entities.orbs.length - 1; i >= 0; i--) { const orb = this.entities.orbs[i]; if (!orb.visible) continue; // Skip already invisible orbs const distance = dist(head, orb); const collisionThreshold = playerRadius + orb.radius; if (distance < collisionThreshold) { // Collision detected! // console.log("Player ate orb!"); // Less verbose // Player.eatOrb override handles growth calculation and calls super. // We only need to pass the original orbValue here. player.eatOrb(orb.value); // Mark orb for removal, destroy its Pixi object, and remove from manager orb.visible = false; orb.destroyPixi(); this.entities.removeOrb(orb); // Use EntityManager method } } } // Self-collision check (moved from old checkCollisions) const skipCount = player.calculateSkipSegments(); if (player.willHitTail(head.x, head.y, skipCount)) { this.killSnake(player); // Use killSnake for consistency } } // --- Camera Simulation --- private updateGameContainerPosition(): void { // To simulate the camera, we move the game container *opposite* to the camera's logical position. // The container's top-left corner should be positioned such that the camera's logical center // aligns with the screen's center. const screenWidth = this.pixiApp.screen.width; const screenHeight = this.pixiApp.screen.height; // Calculate the desired top-left position of the container const containerX = screenWidth / 2 - this.cameraX; const containerY = screenHeight / 2 - this.cameraY; this.gameContainer.position.set(containerX, containerY); } resize(width: number, height: number): void { console.log('Game resize called with dimensions:', width, height); // Recalculate world size based on new screen dimensions this.updateWorldSize(); // Re-center the game container based on the new screen size this.updateGameContainerPosition(); // Reposition score text and dev info text on resize if (this.scoreText) { this.scoreText.position.set(width - 10, 10); } if (this.devInfoText) { this.devInfoText.position.set(10, 30); // Keep below FPS // Adjust wrap width if needed if (this.devInfoText.style.wordWrapWidth !== width - 20) { this.devInfoText.style.wordWrapWidth = width - 20; } } } // Pause/Resume called by main.ts on visibility change pause(): void { console.log('Game focus lost (pause)'); if (this.gameState === GameState.PLAYING) { this.pauseGame(); // Automatically pause if tab loses focus while playing } } resume(): void { console.log('Game focus gained (resume)'); // Don't automatically resume gameplay state, let user do it via UI. // Ticker is restarted in main.ts's visibility handler. } // Public getter for the current state public getCurrentState(): GameState { return this.gameState; } // --- Dev Mode --- private toggleDevMode(): void { this.devModeActive = !this.devModeActive; console.log(`Dev Mode ${this.devModeActive ? 'Activated' : 'Deactivated'}`); // Visibility of devInfoText is handled in the update loop } private updateDevInfoText(): void { // Check entities and player exist if (!this.devInfoText || !this.entities || !this.entities.player) { return; } const player = this.entities.player; // Use player from entities // Access player properties (ensure they are public or have getters) const segCount = player.segs.length; const currentSpeed = player.speed.toFixed(1); // Access public properties directly const baseSpeed = player.baseSpeed.toFixed(1); const boostTimer = player.speedBoostTimer.toFixed(2); const logicalLength = player.length.toFixed(0); const headX = player.segs[0].x.toFixed(0); const headY = player.segs[0].y.toFixed(0); const cameraX = this.cameraX.toFixed(0); const cameraY = this.cameraY.toFixed(0); const worldW = this.worldWidth.toFixed(0); const worldH = this.worldHeight.toFixed(0); this.devInfoText.text = `Segments: ${segCount} (Logical: ${logicalLength})\n` + `Speed: ${currentSpeed} (Base: ${baseSpeed})\n` + `Boost Time: ${boostTimer}s\n` + `Head Pos: (${headX}, ${headY})\n` + `Camera: (${cameraX}, ${cameraY})\n` + `World: ${worldW}x${worldH}`; } // --- Parallax Update --- private updateBackgroundParallax(): void { const parallaxX = -this.cameraX * this.parallaxFactor; const parallaxY = -this.cameraY * this.parallaxFactor; const parallaxWrap = document.getElementById('parallaxWrap') as HTMLElement | null; if (parallaxWrap) { // Apply only translation to the parallax wrapper parallaxWrap.style.transform = `translate3d(${parallaxX}px, ${parallaxY}px, 0)`; } else { // console.warn("Could not find #parallaxWrap element for parallax update."); // Keep commented unless debugging } // The video element itself no longer needs per-frame transform updates // this.backgroundVideoElement.style.transform = `...`; // Removed } // --- Collision Resolution Helpers --- Added private killSnake(s: SnakeState): void { // Accept SnakeState if (!s.visible) return; // Already dead console.log(`Killing snake: ${s.id}`); s.visible = false; // Mark state as not visible // Find the actual instance to call destroyPixi on const instanceToDestroy = s.isPlayer ? this.entities.player : this.entities.ai.find(aiSnake => aiSnake.id === s.id); if (instanceToDestroy) { instanceToDestroy.destroyPixi(); // Call destroyPixi on the actual instance } else { console.error(`Could not find instance with ID ${s.id} to destroy Pixi object.`); } if (s.isPlayer) { this.gameOver(); } else { // Remove AI snake from the manager - Need to find the AISerpent instance // Find the corresponding AISerpent instance in the manager's list const aiToRemove = this.entities.ai.find(aiSnake => aiSnake.id === s.id); if (aiToRemove) { this.entities.removeAISerpent(aiToRemove); // ✅ respawn replacement after 3 s, storing timeout ID // Cast setTimeout result to number for browser environment compatibility const timeoutId = setTimeout(() => { // Check if entities still exist AND game is still playing if (this.entities && this.gameState === GameState.PLAYING) { console.log(`Respawning AI after snake ${s.id} was killed.`); // Need to call spawnSingleAI on the instance, not the class this.entities.spawnSingleAI(this.worldWidth, this.worldHeight); // Call the new method // Remove this timeout ID from the list once executed successfully this.aiRespawnTimeouts = this.aiRespawnTimeouts.filter(id => id !== timeoutId); } else { console.log(`Skipping AI respawn for ${s.id} due to game state change or missing entities.`); // Also remove from list if skipped // Ensure comparison is number vs number this.aiRespawnTimeouts = this.aiRespawnTimeouts.filter(id => id !== timeoutId as unknown as number); } }, 3000) as unknown as number; // Cast the timeoutId itself this.aiRespawnTimeouts.push(timeoutId); // Store the timeout ID (already cast) } else { console.warn(`Could not find AI serpent with ID ${s.id} to remove.`); } } } private absorb(winnerState: SnakeState, loserState: SnakeState): void { // Accept SnakeState if (!winnerState.visible || !loserState.visible) return; // Ensure both are 'alive' at the moment of absorption // Find the actual winner/loser instances in the entity manager const winner = winnerState.isPlayer ? this.entities.player : this.entities.ai.find(s => s.id === winnerState.id); const loser = loserState.isPlayer ? this.entities.player : this.entities.ai.find(s => s.id === loserState.id); // Ensure both instances were found if (!winner || !loser) { console.error("Could not find winner or loser instance for absorption.", { winnerId: winnerState.id, loserId: loserState.id }); return; } console.log(`Snake ${winner.id} absorbing snake ${loser.id}`); winner.length += loser.length; // Add logical length winner.score += loser.score; // Add score (assuming score property exists or is added) // Append segments (skip neck of loser) // Ensure loser.segs exists and has enough segments if (loser.segs && loser.segs.length > 6) { winner.segs.push(...loser.segs.slice(6)); } // Winner's graphics will update automatically based on new segs array in syncPixi // --- Add Absorption Effects --- // TODO: Consider creating specific absorption effect constants later winner.glowFrames = PLAYER_EAT_GLOW_FRAMES * 2; // Longer glow for absorption winner.speed = winner.baseSpeed * PLAYER_EAT_SPEED_BOOST; // Apply speed boost winner.speedBoostTimer = (PLAYER_EAT_SPEED_BOOST_DURATION_MS / 1000) * 1.5; // Longer boost duration } // --- KeyDown Handler --- Added to satisfy main.ts public handleKeyDown(event: KeyboardEvent): void { // Handle global keys like Pause (Space, Esc) switch (event.key) { case 'Escape': if (this.gameState === GameState.PLAYING) { this.pauseGame(); } else if (this.gameState === GameState.PAUSED) { this.resumeGame(); // Allow Esc to resume from pause } else if (this.gameState === GameState.CONTROLS) { this.hideControls(); // Allow Esc to exit controls } break; // case ' ': // Space bar - Functionality removed, will be used for firing later // event.preventDefault(); // // Fire action will be handled in the update loop via inputHandler.isFireButtonPressed() // break; } // Note: Movement keys (WASD/Arrows) are handled by InputHandler polling in the update loop } } export default Game;

src/image/PlayerSerpent/1746127243894.png

This is a binary file of the type: Image

src/image/PlayerSerpent/1746127249548.png

This is a binary file of the type: Image

src/InputHandler.ts

import { Point } from './types'; import * as PIXI from 'pixi.js'; // Import PixiJS // Define a more detailed JoystickState interface JoystickState { active: boolean; startPosition: Point | null; // Position where touch began currentPosition: Point | null; // Current touch position vector: Point; // Calculated direction vector (normalized) deadzoneRadius: number; // Radius around start where input is ignored maxTravelRadius?: number; // Max distance knob can move from base center } class InputHandler { private canvas: HTMLCanvasElement; // private keys: { [key: string]: boolean }; // Keep generic keys if needed, but use specific flags for movement private joystick: JoystickState; // PixiJS graphics for joystick visualization private joystickContainer: PIXI.Container | null = null; private joystickBaseGraphics: PIXI.Graphics | null = null; private joystickKnobGraphics: PIXI.Graphics | null = null; // Track movement key states private moveUp: boolean = false; private moveDown: boolean = false; private moveLeft: boolean = false; private moveRight: boolean = false; private isFiring: boolean = false; // State for the fire button // Reusable objects to minimize allocation private _keyboardDirection: Point = { x: 0, y: 0 }; private _zeroDirection: Point = { x: 0, y: 0 }; constructor(canvas: HTMLCanvasElement) { console.log('InputHandler constructor called'); this.canvas = canvas; // Store input state this.joystick = { active: false, startPosition: null, currentPosition: null, vector: { x: 0, y: 0 }, deadzoneRadius: 20 // Example deadzone radius in pixels }; // No need for void this.joystick anymore // Add event listeners this.addEventListeners(); // No need for void this.canvas anymore } // --- PixiJS Graphics Methods --- /** Initializes the PixiJS graphics objects for the joystick */ initJoystickGraphics(stage: PIXI.Container): void { this.joystickContainer = new PIXI.Container(); this.joystickContainer.visible = false; // Initially hidden // Define appearance const baseRadius = 50; const knobRadius = 25; const baseColor = 0xcccccc; const knobColor = 0x888888; const baseAlpha = 0.3; const knobAlpha = 0.5; // Create base graphics this.joystickBaseGraphics = new PIXI.Graphics(); this.joystickBaseGraphics.circle(0, 0, baseRadius); this.joystickBaseGraphics.fill({ color: baseColor, alpha: baseAlpha }); this.joystickContainer.addChild(this.joystickBaseGraphics); // Create knob graphics this.joystickKnobGraphics = new PIXI.Graphics(); this.joystickKnobGraphics.circle(0, 0, knobRadius); this.joystickKnobGraphics.fill({ color: knobColor, alpha: knobAlpha }); this.joystickContainer.addChild(this.joystickKnobGraphics); // Add the container to the main stage stage.addChild(this.joystickContainer); // Set max travel radius based on base radius this.joystick.maxTravelRadius = baseRadius - knobRadius / 2; // Knob center shouldn't go past base edge console.log('Joystick graphics initialized.'); } /** Updates the joystick graphics based on the current state */ updateJoystickGraphics(): void { if (!this.joystickContainer || !this.joystickBaseGraphics || !this.joystickKnobGraphics) { return; } this.joystickContainer.visible = this.joystick.active; if (this.joystick.active && this.joystick.startPosition && this.joystick.currentPosition) { // Position the container (base) at the start position this.joystickContainer.position.set(this.joystick.startPosition.x, this.joystick.startPosition.y); // Calculate knob position relative to the base center let knobX = this.joystick.currentPosition.x - this.joystick.startPosition.x; let knobY = this.joystick.currentPosition.y - this.joystick.startPosition.y; const distance = Math.hypot(knobX, knobY); // Clamp knob position within the max travel radius const maxRadius = this.joystick.maxTravelRadius ?? 50; // Use default if not set if (distance > maxRadius) { const scale = maxRadius / distance; knobX *= scale; knobY *= scale; } // Apply deadzone visually (optional, could just snap to center if vector is 0,0) if (this.joystick.vector.x === 0 && this.joystick.vector.y === 0) { knobX = 0; knobY = 0; } this.joystickKnobGraphics.position.set(knobX, knobY); } } /** Cleans up the joystick graphics */ destroyJoystickGraphics(): void { if (this.joystickContainer) { this.joystickContainer.parent?.removeChild(this.joystickContainer); this.joystickContainer.destroy({ children: true }); this.joystickContainer = null; this.joystickBaseGraphics = null; this.joystickKnobGraphics = null; console.log('Joystick graphics destroyed.'); } } // --- Event Listeners --- addEventListeners(): void { console.log('InputHandler adding event listeners'); // Bind listeners correctly to maintain 'this' context this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); this.handleTouchStart = this.handleTouchStart.bind(this); this.handleTouchMove = this.handleTouchMove.bind(this); this.handleTouchEnd = this.handleTouchEnd.bind(this); this.handleFireButtonDown = this.handleFireButtonDown.bind(this); // Bind fire button handlers this.handleFireButtonUp = this.handleFireButtonUp.bind(this); // Add keyboard listeners window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keyup', this.handleKeyUp); // Add touch listeners to the canvas this.canvas.addEventListener('touchstart', this.handleTouchStart, { passive: false }); // passive: false to allow preventDefault this.canvas.addEventListener('touchmove', this.handleTouchMove, { passive: false }); this.canvas.addEventListener('touchend', this.handleTouchEnd); this.canvas.addEventListener('touchcancel', this.handleTouchEnd); // Treat cancel like end // Add listeners for the fire button const fireButton = document.getElementById('fire-button'); if (fireButton) { // Use touch events for mobile, but also mouse events as fallback/desktop testing fireButton.addEventListener('touchstart', this.handleFireButtonDown, { passive: false }); fireButton.addEventListener('touchend', this.handleFireButtonUp); fireButton.addEventListener('touchcancel', this.handleFireButtonUp); fireButton.addEventListener('mousedown', this.handleFireButtonDown); // Fallback window.addEventListener('mouseup', this.handleFireButtonUp); // Listen on window for mouseup } else { console.warn('InputHandler: Fire button element (#fire-button) not found.'); } } removeEventListeners(): void { console.log('InputHandler removing event listeners'); // Remove keyboard listeners window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keyup', this.handleKeyUp); // Remove touch listeners this.canvas.removeEventListener('touchstart', this.handleTouchStart); this.canvas.removeEventListener('touchmove', this.handleTouchMove); this.canvas.removeEventListener('touchend', this.handleTouchEnd); this.canvas.removeEventListener('touchcancel', this.handleTouchEnd); // Remove listeners for the fire button const fireButton = document.getElementById('fire-button'); if (fireButton) { fireButton.removeEventListener('touchstart', this.handleFireButtonDown); fireButton.removeEventListener('touchend', this.handleFireButtonUp); fireButton.removeEventListener('touchcancel', this.handleFireButtonUp); fireButton.removeEventListener('mousedown', this.handleFireButtonDown); window.removeEventListener('mouseup', this.handleFireButtonUp); } } // Keyboard event handlers private handleKeyDown(event: KeyboardEvent): void { // Prevent default browser actions for arrow keys, WASD, space if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 'a', 's', 'd', ' '].includes(event.key)) { // Keep space bar preventDefault in main.ts for game state control if (event.key !== ' ') { event.preventDefault(); } } switch (event.key) { case 'w': case 'ArrowUp': this.moveUp = true; break; case 's': case 'ArrowDown': this.moveDown = true; break; case 'a': case 'ArrowLeft': this.moveLeft = true; break; case 'd': case 'ArrowRight': this.moveRight = true; break; // Space bar will be handled separately for firing later // case ' ': // this.isFiring = true; // Example if space also fires // break; } // Store generic key state if needed elsewhere // this.keys[event.key] = true; } private handleKeyUp(event: KeyboardEvent): void { switch (event.key) { case 'w': case 'ArrowUp': this.moveUp = false; break; case 's': case 'ArrowDown': this.moveDown = false; break; case 'a': case 'ArrowLeft': this.moveLeft = false; break; case 'd': case 'ArrowRight': this.moveRight = false; break; // case ' ': // this.isFiring = false; // Example if space also fires // break; } // Store generic key state if needed elsewhere // delete this.keys[event.key]; } // --- Touch Event Handlers --- private handleTouchStart(event: TouchEvent): void { event.preventDefault(); // Prevent scrolling/zooming if (event.touches.length > 0) { const touch = event.touches[0]; const rect = this.canvas.getBoundingClientRect(); this.joystick.startPosition = { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; this.joystick.currentPosition = { ...this.joystick.startPosition }; // Start at the same point this.joystick.active = true; this.joystick.vector = { x: 0, y: 0 }; // Reset vector initially // console.log('Touch Start:', this.joystick.startPosition); // Update graphics immediately on touch start this.updateJoystickGraphics(); } } private handleTouchMove(event: TouchEvent): void { event.preventDefault(); if (!this.joystick.active || !this.joystick.startPosition || event.touches.length === 0) { return; } const touch = event.touches[0]; const rect = this.canvas.getBoundingClientRect(); this.joystick.currentPosition = { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; // Calculate vector from start to current let dx = this.joystick.currentPosition.x - this.joystick.startPosition.x; let dy = this.joystick.currentPosition.y - this.joystick.startPosition.y; const distance = Math.hypot(dx, dy); // Apply deadzone if (distance < this.joystick.deadzoneRadius) { this.joystick.vector = { x: 0, y: 0 }; } else { // Normalize the vector this.joystick.vector = { x: dx / distance, y: dy / distance }; } // console.log('Touch Move - Vector:', this.joystick.vector); // Update graphics on move this.updateJoystickGraphics(); } private handleTouchEnd(/* event: TouchEvent */): void { // Removed unused event parameter // Check if the touch ending is the one we were tracking (optional but good practice) // For simplicity, we assume any touchend deactivates the joystick if active. if (this.joystick.active) { this.joystick.active = false; this.joystick.startPosition = null; this.joystick.currentPosition = null; // Keep the last vector? Or reset? Let's keep it for now, serpent continues. // this.joystick.vector = { x: 0, y: 0 }; // console.log('Touch End'); // Update graphics (will hide the container) this.updateJoystickGraphics(); } } // --- Fire Button Handlers --- private handleFireButtonDown(event: TouchEvent | MouseEvent): void { event.preventDefault(); // Prevent potential double actions or page scroll this.isFiring = true; // console.log("Fire button pressed"); // Optional debug log } private handleFireButtonUp(): void { // No event needed here as touchend/mouseup don't carry useful info for just stopping this.isFiring = false; // console.log("Fire button released"); // Optional debug log } // Method to get the desired movement direction from keyboard // Returns a non-normalized vector, or {x: 0, y: 0} if no direction keys are pressed getKeyboardDirection(): Point { let dx = 0; let dy = 0; if (this.moveUp) dy -= 1; if (this.moveDown) dy += 1; if (this.moveLeft) dx -= 1; if (this.moveRight) dx += 1; // Update the reusable object instead of creating a new one this._keyboardDirection.x = dx; this._keyboardDirection.y = dy; return this._keyboardDirection; } // Method to get the desired movement direction from joystick // Returns a *normalized* vector, or the reusable zero vector if inactive/deadzone getJoystickDirection(): Point { // Return the joystick's vector if active AND not in deadzone, otherwise return the zero vector return (this.joystick.active && (this.joystick.vector.x !== 0 || this.joystick.vector.y !== 0)) ? this.joystick.vector : this._zeroDirection; } // Method to check if the fire action is currently active isFireButtonPressed(): boolean { // For now, just returns the state. Later, might check keyboard too. return this.isFiring; } // Method to get current input state (optional, maybe remove if not needed) // getInputState(): { keys: { [key: string]: boolean }, joystick: JoystickState } { // // Return current state of keys and joystick // return { // keys: this.keys, // joystick: this.joystick // }; // } } export default InputHandler;

src/main.ts

import * as PIXI from 'pixi.js'; import Game, { GameState } from './Game'; // Import GameState enum import InputHandler from './InputHandler'; let game: Game | undefined; let inputHandler: InputHandler | undefined; let pixiApp: PIXI.Application | undefined; let fpsText: PIXI.Text | undefined; // Added for FPS counter // DOM Element References const pixiContainer = document.getElementById('pixi-container') as HTMLDivElement | null; const backgroundVideo = document.getElementById('backgroundVideo') as HTMLVideoElement | null; const loadingScreen = document.getElementById('loadingScreen') as HTMLDivElement | null; // --- Initialization --- if (!pixiContainer || !backgroundVideo || !loadingScreen) { console.error('Critical DOM elements not found! Need #pixi-container, #backgroundVideo, #loadingScreen.'); if (loadingScreen) { loadingScreen.textContent = 'Error: Missing required page elements. Cannot initialize game.'; } } else { // Add listeners early window.addEventListener('resize', handleResize); document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('keydown', handleKeyDown); // Asynchronous initialization function async function initializeGame(): Promise<void> { try { // 1. Create PixiJS Application pixiApp = new PIXI.Application(); // 2. Initialize PixiJS Application with settings await pixiApp.init({ width: window.innerWidth, height: window.innerHeight, backgroundAlpha: 0, // Transparent background powerPreference: 'high-performance', // Request high performance GPU // gcMode: PIXI.GC_MODE.AUTO, // Removed - Not a valid option in v8 ApplicationOptions antialias: true, resolution: window.devicePixelRatio || 1, // Adjust for device pixel ratio autoDensity: true, // Automatically handle resolution changes }); // 3. Append PixiJS canvas to the container pixiContainer!.appendChild(pixiApp.canvas); // Use non-null assertion // 4. Instantiate Input Handler (pass PixiJS canvas for touch events) inputHandler = new InputHandler(pixiApp.canvas); // Pass PixiJS canvas inputHandler.initJoystickGraphics(pixiApp.stage); // Initialize joystick visuals // Preload orb textures before setting up the game console.log("Loading orb assets..."); await PIXI.Assets.load(['/orb1.png','/orb2.png','/orb3.png']); console.log("Orb assets loaded."); // 5. Wait for video metadata before instantiating Game const setupGame = () => { // Instantiate Game (pass PixiJS App and other dependencies) game = new Game(pixiApp!, inputHandler!, backgroundVideo!); // Use non-null assertions // Initialize game state (creates menus, etc.) game.init(); // Initialize FPS Counter Text fpsText = new PIXI.Text({ text: 'FPS: 0', style: { fontFamily: 'Arial', fontSize: 18, fill: 0x00ff00, // Neon green align: 'left', } }); fpsText.position.set(10, 10); // Position top-left pixiApp!.stage.addChild(fpsText); // Add to the main stage (not gameContainer) // Set up PixiJS Ticker for the game loop pixiApp!.ticker.maxFPS = 60; // Cap FPS pixiApp!.ticker.add((ticker: PIXI.Ticker) => { // Update FPS counter if (fpsText) { fpsText.text = `FPS: ${ticker.FPS.toFixed(2)}`; } // Update game logic if playing if (game && game.getCurrentState() === GameState.PLAYING) { const deltaTimeSeconds = ticker.deltaMS / 1000.0; // Clamp delta time to prevent large jumps (e.g., after tab focus) const clampedDeltaTime = Math.min(deltaTimeSeconds, 1 / 30); // Max ~30 FPS step game.update(clampedDeltaTime); // Rendering is handled by PixiJS automatically based on stage updates in game.update -> entity.syncPixi() } // Update joystick visuals regardless of game state if (inputHandler) { inputHandler.updateJoystickGraphics(); } }); // Ticker starts automatically // Hide loading screen loadingScreen!.style.display = 'none'; // Use non-null assertion // Initial resize call to ensure correct sizing handleResize(); // Ensure background video plays (moved here to ensure it plays after setup) backgroundVideo!.play().catch(error => { console.warn('Background video autoplay prevented:', error); // Consider adding a user interaction requirement to play video }); }; // Check if metadata is already loaded if (backgroundVideo!.readyState >= backgroundVideo!.HAVE_METADATA) { console.log("Video metadata already loaded."); setupGame(); } else { console.log("Waiting for video metadata..."); backgroundVideo!.addEventListener('loadedmetadata', () => { console.log("Video metadata loaded."); setupGame(); }, { once: true }); // Ensure listener runs only once } // --- Ticker and Loading Screen logic moved inside setupGame --- /* // 6.5 Initialize FPS Counter Text fpsText = new PIXI.Text({ text: 'FPS: 0', style: { fontFamily: 'Arial', fontSize: 18, fill: 0x00ff00, // Neon green align: 'left', } }); fpsText.position.set(10, 10); // Position top-left */ } catch (error) { console.error('Game initialization failed:', error); if (loadingScreen) { const message = (error instanceof Error) ? error.message : String(error); loadingScreen.textContent = `Error during initialization: ${message}. Check console.`; loadingScreen.style.color = 'red'; } // Clean up PixiJS app if initialization failed partially if (pixiApp) { inputHandler?.destroyJoystickGraphics(); // Clean up joystick graphics // Remove baseTexture, it's not a valid option here pixiApp.destroy(true, { children: true, texture: true /*, baseTexture: true */ }); pixiApp = undefined; } } } // Start the initialization process initializeGame(); } // --- Event Handlers --- function handleResize(): void { if (pixiApp && game) { const newWidth = window.innerWidth; const newHeight = window.innerHeight; // Resize the PixiJS renderer pixiApp.renderer.resize(newWidth, newHeight); // Optionally notify the game about the resize if it needs to adjust layout game.resize(newWidth, newHeight); } } function handleVisibilityChange(): void { if (!pixiApp || !game) return; if (document.hidden) { // Tab is hidden pixiApp.ticker.stop(); // Stop the PixiJS ticker game.pause(); // Pause game logic (handles state change) console.log("Game paused - Tab hidden"); } else { // Tab is visible pixiApp.ticker.start(); // Start the PixiJS ticker // game.resume(); // Game resume logic should be handled by user interaction (e.g., clicking resume button) console.log("Game active - Tab visible"); // Note: We don't automatically resume the game's *state* here, // only the rendering loop. The game remains paused until the user resumes it via UI. // If the game wasn't paused by the user before hiding, it might need explicit resume. // Let's rely on the game's internal state management triggered by UI. } } function handleKeyDown(event: KeyboardEvent): void { if (!game) return; // Game not initialized yet // const currentState = game.getCurrentState(); // Removed unused variable // Let the game instance handle keydown events for state changes (pause/resume) game.handleKeyDown(event); // Example of direct handling (could be moved into Game class): // switch (event.key) { // case 'Escape': // if (currentState === GameState.PLAYING) { // game.pauseGame(); // } else if (currentState === GameState.PAUSED) { // game.resumeGame(); // } else if (currentState === GameState.CONTROLS) { // game.hideControls(); // } // break; // case ' ': // Space bar // event.preventDefault(); // Prevent page scroll // if (currentState === GameState.PLAYING) { // game.pauseGame(); // } else if (currentState === GameState.PAUSED) { // game.resumeGame(); // } else if (currentState === GameState.GAME_OVER) { // game.returnToMenu(); // } // break; // } }

src/Orb.ts

import * as PIXI from 'pixi.js'; // Import PixiJS import { OrbTier, ORB_TIER_CONFIG, ORB_TEXTURE } from './types'; // Import Orb tier types and texture map // Represents a collectible orb export class Orb { public x: number; public y: number; public radius: number; // Represents the collision radius (circle inside sprite) public tier: OrbTier; // Store the tier public value: number; // Value derived from tier public visible: boolean = true; // Added visibility state // --- PixiJS Integration --- public pixiSprite: PIXI.Sprite | null = null; // Changed from pixiObject (Graphics) to pixiSprite constructor(x: number, y: number, radius: number, tier: OrbTier) { this.x = x; this.y = y; this.radius = radius; this.tier = tier; this.value = ORB_TIER_CONFIG[tier].value; // Get value from config // Removed console log for brevity during generation } // --- PixiJS Methods --- /** Initializes the PixiJS Sprite object for the orb */ initPixi(stage: PIXI.Container): void { if (this.pixiSprite) { this.destroyPixi(); // Clean up existing sprite if any } // Get texture from the preloaded assets based on tier const texturePath = ORB_TEXTURE[this.tier]; const texture = PIXI.Assets.get(texturePath); // Use PIXI.Assets.get if (!texture) { console.error(`Orb texture not found: ${texturePath}`); return; // Cannot create sprite without texture } // Set scale mode for crisp pixels (optional polish) texture.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST; this.pixiSprite = new PIXI.Sprite(texture); this.pixiSprite.anchor.set(0.5); // Center the anchor // Set initial size based on the provided radius (diameter = radius * 2) // This radius comes from generateOrbs and is calculated using ORB_BASE_RADIUS/ORB_RADIUS_MULTIPLIER this.pixiSprite.width = this.radius * 2; this.pixiSprite.height = this.radius * 2; // Set initial position and visibility this.pixiSprite.position.set(this.x, this.y); this.pixiSprite.visible = this.visible; // Add to the stage stage.addChild(this.pixiSprite); } /** Updates the PixiJS Sprite object to match the orb's current state */ syncPixi(): void { if (!this.pixiSprite) return; // Update visibility first this.pixiSprite.visible = this.visible; if (this.visible) { // Update position if visible this.pixiSprite.position.set(this.x, this.y); // Update size if needed (e.g., if player segment size changes dynamically) // For now, size is set at init based on initial player segment size. // If we want dynamic orb size matching player size, we'd need to pass // the current player segment radius here and update width/height. // this.pixiSprite.width = currentSegmentRadius * 2; // this.pixiSprite.height = currentSegmentRadius * 2; } } /** Removes the PixiJS Sprite object from the stage and destroys it */ destroyPixi(): void { if (this.pixiSprite) { this.pixiSprite.parent?.removeChild(this.pixiSprite); this.pixiSprite.destroy(); // Destroy the sprite this.pixiSprite = null; } } // Orbs are likely static, so no update method needed for now // update(deltaTime: number): void { // void deltaTime; // } } export default Orb;

src/PlayerSerpent.ts

// src/PlayerSerpent.ts // PIXI import removed as PIXI.Graphics is inherited via Serpent import { Serpent } from './Serpent'; // Import the base class import { Point, // Segment, // Type hint not needed locally anymore // Velocity, // Type hint not needed locally anymore // SnakeState, // Implemented by Serpent PLAYER_INITIAL_SPEED, PLAYER_INITIAL_LENGTH, PLAYER_INITIAL_SEGMENTS, PLAYER_COLOR, PLAYER_MAX_ADDITIONAL_SPEED, PLAYER_SPEED_LENGTH_FACTOR, SAFE_PX, MAX_NECK_SKIP_SEGMENTS, segRadius, PLAYER_EAT_SPEED_BOOST, PLAYER_EAT_SPEED_BOOST_DURATION_MS, PLAYER_EAT_GLOW_FRAMES, PLAYER_LENGTH_PER_ORB } from './types'; // Import necessary utils import { dist, lerpColor } from './utils'; // Removed unused wrap, moveTowardsTorus // Local helper functions are removed as they are now imported from utils.ts or inherited export class PlayerSerpent extends Serpent { // Inherited properties are handled by the base class 'Serpent' // Note: Access modifiers for inherited properties like lastTurnTimestamp and segmentGrowthDuration // might need adjustment in the base Serpent class (e.g., to protected) if PlayerSerpent needs direct access. // For now, assuming they are accessible or logic is adjusted. constructor(startX: number, startY: number) { // Call the Serpent constructor with player-specific defaults super( 'player', // id startX, startY, PLAYER_INITIAL_LENGTH, PLAYER_INITIAL_SEGMENTS, PLAYER_INITIAL_SPEED, PLAYER_COLOR, "Player", // name true // isPlayer ); console.log(`PlayerSerpent constructor finished for ID: ${this.id}`); } // initPixi and destroyPixi are inherited from Serpent. // We keep the player-specific syncPixi override. /** * syncPixi - Player-specific rendering with advanced effects (pulse, glow, safe neck, mouth). * This completely overrides the base Serpent.syncPixi rendering. */ override syncPixi(playerSkipCount: number, worldWidth: number, worldHeight: number): void { if (!this.pixiObject) return; this.pixiObject.visible = this.visible; if (!this.visible || this.segs.length === 0) { this.pixiObject.clear(); return; } const graphics = this.pixiObject; graphics.clear(); // 1. Setup Constants const radius = segRadius(this.length); // const bodyWidth = radius * 2; // Unused variable removed const headRadius = radius * 1.2; const baseHexColor = this.color; const baseAlpha = 1.0; const baseOutlineColor = 0xffffff; const basePlayerGlowColor = 0xFFFF00; // Yellow base glow let basePlayerGlowStrength = 9; // Base strength, will be made dynamic const baseGlowAlpha = 0.25; const safeNeckColor = 0x0099aa; // Cyan-ish const safeNeckGlowStrength = 3; const safeNeckTransitionSegments = 3; // How many segments to transition color over const pulseColor = 0xffffff; // Body color during pulse const pulseGlowColor = 0xffffff; // Glow color during pulse (changed from base yellow) const pulseGlowBoost = 4; const pulseWidthMultiplier = 0.5; const secondaryPulseWidthFactor = 2.0; // How much wider the secondary pulse is const secondaryPulseGlowAlpha = 0.05; // Alpha for the secondary pulse glow const secondaryPulseGlowBoost = 2; // Extra radius for secondary pulse // --- Dynamic Glow Strength based on Speed --- const speedRatio = this.baseSpeed > 0 ? this.speed / this.baseSpeed : 1; // Apply a modest boost/reduction based on speed ratio, clamped const dynamicGlowFactor = Math.max(0.8, Math.min(1.5, 1 + (speedRatio - 1) * 0.3)); const dynamicPlayerGlowStrength = basePlayerGlowStrength * dynamicGlowFactor; // 2. Global Line Style Defaults (Not needed for circle fills) // 3. --- Draw Body Segments as Circles (excluding head) --- // Calculate cumulative distances for pulse and transition effects let cumulativeDistances: number[] = [0]; let currentTotalDistance = 0; for (let i = 0; i < this.segs.length - 1; i++) { const segA = this.segs[i]; const segB = this.segs[i + 1]; const wrapDist = Math.abs(segA.x - segB.x) > worldWidth / 2 || Math.abs(segA.y - segB.y) > worldHeight / 2; if (!wrapDist) { currentTotalDistance += dist(segA, segB); } cumulativeDistances.push(currentTotalDistance); // Store distance *to* segment i+1 } // Draw segments from tail towards head (excluding head itself, index 0) // This layering ensures segments closer to the head draw on top for (let i = this.segs.length - 1; i >= 1; i--) { const seg = this.segs[i]; const scaleFactor = seg.growthProgress ?? 1.0; // Use growthProgress for smooth scaling (0 to 1) const segmentMidpointDistance = cumulativeDistances[i]; // Use pre-calculated distance // --- Calculate Swell Factors --- let primarySwellFactor = 0; let secondarySwellFactor = 0; const primaryPulseWaveWidth = segRadius(this.length) * 8; const secondaryPulseWaveWidth = primaryPulseWaveWidth * secondaryPulseWidthFactor; for (const pulse of this.eatQueue) { const distFromPulseCenter = Math.abs(segmentMidpointDistance - pulse.distanceTraveled); // Primary Pulse if (distFromPulseCenter < primaryPulseWaveWidth / 2) { primarySwellFactor = Math.max(primarySwellFactor, Math.cos(distFromPulseCenter / (primaryPulseWaveWidth / 2) * (Math.PI / 2))); } // Secondary Pulse if (distFromPulseCenter < secondaryPulseWaveWidth / 2) { secondarySwellFactor = Math.max(secondarySwellFactor, Math.cos(distFromPulseCenter / (secondaryPulseWaveWidth / 2) * (Math.PI / 2))); } } const isPulsing = primarySwellFactor > 0.01; const isSecondaryPulsing = secondarySwellFactor > 0.01; // --- Determine Segment State (Safe, Transitioning, Normal) --- const isStrictlySafe = this.isPlayer && playerSkipCount > 0 && i < playerSkipCount; const isInTransition = this.isPlayer && playerSkipCount > 0 && i >= playerSkipCount && i < playerSkipCount + safeNeckTransitionSegments; let transitionFactor = 0; // 0 = fully safe, 1 = fully normal color if (isInTransition) { transitionFactor = (i - playerSkipCount + 1) / safeNeckTransitionSegments; } else if (!isStrictlySafe) { transitionFactor = 1; // Fully normal color } // --- Base Style Calculation (Considering Transition) --- const normalBodyColor = this.glowFrames > 0 ? 0xffffff : baseHexColor; // White if globally glowing, else base color const normalGlowColor = basePlayerGlowColor; // Base yellow glow const normalGlowStrength = dynamicPlayerGlowStrength; // Use speed-adjusted strength let baseBodyC = lerpColor(safeNeckColor, normalBodyColor, transitionFactor); let baseGlowC = lerpColor(safeNeckColor, normalGlowColor, transitionFactor); // Glow transitions too let baseGlowS = isStrictlySafe ? safeNeckGlowStrength : normalGlowStrength; // Use safe strength only if strictly safe // --- Apply Pulse Effects (Overrides base styles) --- const actualBodyColor = isPulsing ? pulseColor : baseBodyC; // Pulse overrides body color const actualGlowColor = isPulsing ? pulseGlowColor : baseGlowC; // Pulse overrides glow color const actualGlowStrength = isPulsing ? (baseGlowS + pulseGlowBoost) : baseGlowS; // Pulse boosts strength // --- Calculate Radii (Scaled by Growth and Swell) --- const radiusWithSwell = radius * (1 + primarySwellFactor * pulseWidthMultiplier); const currentRadius = radiusWithSwell * scaleFactor; // Apply growth scale factor // Ensure outline doesn't disappear completely for small segments const currentOutlineRadius = currentRadius + Math.max(0.5, 1 * scaleFactor); const currentGlowRadius = currentRadius + (actualGlowStrength / 2) * scaleFactor; // Scale glow addition by growth // Secondary pulse radius calculation const secondaryPulseRadiusBoost = (secondaryPulseGlowBoost / 2) * secondarySwellFactor * scaleFactor; const currentSecondaryGlowRadius = currentRadius + secondaryPulseRadiusBoost; const currentAlpha = baseAlpha * scaleFactor; // Scale alpha by growth // --- Draw Layers (if radius is significant) --- if (currentRadius > 0.1) { // 0. Secondary Pulse Glow (Very Subtle - Draw First) if (isSecondaryPulsing && currentSecondaryGlowRadius > currentRadius) { // graphics.lineStyle(0); // No longer needed graphics.fill({ color: pulseGlowColor, alpha: secondaryPulseGlowAlpha * scaleFactor }); // Use pulse color, very low alpha graphics.circle(seg.x, seg.y, currentSecondaryGlowRadius); } // 1. Outline (Stroke) graphics.setStrokeStyle({ width: Math.max(1, 2 * scaleFactor), color: baseOutlineColor, alpha: currentAlpha }); // Use setStrokeStyle graphics.moveTo(seg.x + currentOutlineRadius, seg.y); // Need moveTo before arc for stroke graphics.arc(seg.x, seg.y, currentOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // Apply the stroke // 2. Primary Glow (Fill) // graphics.lineStyle(0); // No longer needed graphics.fill({ color: actualGlowColor, alpha: baseGlowAlpha * scaleFactor }); // Use actual (pulsed?) glow color graphics.circle(seg.x, seg.y, currentGlowRadius); // 3. Body (Fill) graphics.fill({ color: actualBodyColor, alpha: currentAlpha }); // Use actual (pulsed?) body color graphics.circle(seg.x, seg.y, currentRadius); } } // End of body segment circle loop // 4. --- Head & Eyes (Layered) --- (Draw head last so it's on top) if (this.segs.length > 0) { const headSeg = this.segs[0]; const headGrowth = headSeg.growthProgress ?? 1.0; // Use head's own growth const currentHeadRadius = headRadius * headGrowth; // Scale head radius by its growth if (currentHeadRadius > 0.1) { // Head color is affected by global glow, but not pulsing swell const headColor = this.glowFrames > 0 ? 0xffffff : baseHexColor; // Use dynamic glow strength for head too, maybe slightly boosted const headGlowStrength = dynamicPlayerGlowStrength + 2; const currentHeadGlowRadius = currentHeadRadius + (headGlowStrength / 2) * headGrowth; // Scale glow addition const currentHeadOutlineRadius = currentHeadRadius + 1 * headGrowth; // Scale outline addition const currentHeadAlpha = baseAlpha * headGrowth; // Scale alpha // 1. Head Outline graphics.setStrokeStyle({ width: Math.max(1, 2 * headGrowth), color: baseOutlineColor, alpha: currentHeadAlpha }); // Use setStrokeStyle graphics.moveTo(headSeg.x + currentHeadOutlineRadius, headSeg.y); graphics.arc(headSeg.x, headSeg.y, currentHeadOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // 2. Head Glow (Fill) // graphics.lineStyle(0); // No longer needed graphics.fill({ color: basePlayerGlowColor, alpha: baseGlowAlpha * headGrowth }); graphics.circle(headSeg.x, headSeg.y, currentHeadGlowRadius); // Use scaled glow alpha // 3. Head Fill graphics.fill({ color: headColor, alpha: currentHeadAlpha }); graphics.circle(headSeg.x, headSeg.y, currentHeadRadius); // Use scaled head alpha // --- Draw Eyes (Scaled by headGrowth) --- const finalEyeRadius = Math.max(1, currentHeadRadius * 0.2); // Base size on current head radius, min 1px const eyeDist = currentHeadRadius * 0.5; // Eye distance scales with head radius const eyeAngle = Math.atan2(this.velocity.vy, this.velocity.vx); const eyeAnglePerp = eyeAngle + Math.PI / 2; const eye1X = headSeg.x + Math.cos(eyeAnglePerp) * eyeDist; const eye1Y = headSeg.y + Math.sin(eyeAnglePerp) * eyeDist; const eye2X = headSeg.x - Math.cos(eyeAnglePerp) * eyeDist; const eye2Y = headSeg.y - Math.sin(eyeAnglePerp) * eyeDist; const eyeGlowColor = 0xffffff; const eyeGlowAlpha = 0.15 * headGrowth; // Scale eye glow alpha const eyeGlowRadius = finalEyeRadius + Math.max(0.5, 2 * headGrowth); // Scale eye glow addition const eyeFillColor = 0x000000; const eyeFillAlpha = 0.9 * headGrowth; // Scale eye fill alpha // Eye Glow (Fill) graphics.fill({ color: eyeGlowColor, alpha: eyeGlowAlpha }); graphics.circle(eye1X, eye1Y, eyeGlowRadius); graphics.circle(eye2X, eye2Y, eyeGlowRadius); // Eye Fill // Eye Fill graphics.fill({ color: eyeFillColor, alpha: eyeFillAlpha }); graphics.circle(eye1X, eye1Y, finalEyeRadius); graphics.circle(eye2X, eye2Y, finalEyeRadius); // --- Draw Mouth (Player Only) --- if (this.isPlayer) { // Check if this instance is the player const mouthRadius = currentHeadRadius * 0.6; // Position mouth relative to head center const mouthAngleOffset = Math.PI * 0.8; // How wide the mouth arc is (less than PI) const mouthStartX = headSeg.x + Math.cos(eyeAngle - mouthAngleOffset / 2) * mouthRadius; const mouthStartY = headSeg.y + Math.sin(eyeAngle - mouthAngleOffset / 2) * mouthRadius; const mouthEndX = headSeg.x + Math.cos(eyeAngle + mouthAngleOffset / 2) * mouthRadius; const mouthEndY = headSeg.y + Math.sin(eyeAngle + mouthAngleOffset / 2) * mouthRadius; const mouthControlX = headSeg.x + Math.cos(eyeAngle) * mouthRadius * 0.8; // Control point slightly forward const mouthControlY = headSeg.y + Math.sin(eyeAngle) * mouthRadius * 0.8; graphics.setStrokeStyle({ width: Math.max(1, 2 * headGrowth), color: eyeFillColor, alpha: eyeFillAlpha * 0.8 }); // Use setStrokeStyle graphics.moveTo(mouthStartX, mouthStartY); // Use quadraticCurveTo for a simple arc shape graphics.quadraticCurveTo(mouthControlX, mouthControlY, mouthEndX, mouthEndY); graphics.stroke(); // Apply the stroke for the mouth line } // --- Draw NEW Smiling Mouth (Top of Head) --- const smileRadius = currentHeadRadius * 0.5; // Distance from center const smileAngle = eyeAngle - Math.PI / 2; // Angle pointing upwards relative to head const smileWidthAngle = Math.PI * 0.6; // How wide the smile arc is // Start point (left side of smile) const smileStartX = headSeg.x + Math.cos(smileAngle - smileWidthAngle / 2) * smileRadius; const smileStartY = headSeg.y + Math.sin(smileAngle - smileWidthAngle / 2) * smileRadius; // End point (right side of smile) const smileEndX = headSeg.x + Math.cos(smileAngle + smileWidthAngle / 2) * smileRadius; const smileEndY = headSeg.y + Math.sin(smileAngle + smileWidthAngle / 2) * smileRadius; // Control point (above the center of the smile arc to make it curve up) const smileControlX = headSeg.x + Math.cos(smileAngle) * smileRadius * 1.4; // Adjust multiplier for curve const smileControlY = headSeg.y + Math.sin(smileAngle) * smileRadius * 1.4; // Adjust multiplier for curve graphics.setStrokeStyle({ width: Math.max(1, 1.5 * headGrowth), color: eyeFillColor, alpha: eyeFillAlpha * 0.7 }); // Slightly thinner? graphics.moveTo(smileStartX, smileStartY); graphics.quadraticCurveTo(smileControlX, smileControlY, smileEndX, smileEndY); graphics.stroke(); // --- End NEW Smiling Mouth --- } } } // End syncPixi // destroyPixi is inherited from Serpent // --- Player-Specific Logic Methods --- // setDirection is inherited from Serpent // Player-specific turn logic override attemptTurn(desiredDir: Point, currentTimestamp: number, turnCooldown: number): boolean { // Basic cooldown check (could call super.attemptTurn for this part if it only did cooldown) if (currentTimestamp - this.lastTurnTimestamp <= turnCooldown) return false; // Player-specific checks: No 180 turns, check for self-collision const dotProduct = desiredDir.x * this.velocity.vx + desiredDir.y * this.velocity.vy; if (dotProduct < -0.9) return false; const head = this.segs[0]; const predictStep = this.speed * 1.5; const nextX = head.x + desiredDir.x * predictStep; const nextY = head.y + desiredDir.y * predictStep; const skipCount = this.calculateSkipSegments(); const wouldBite = this.willHitTail(nextX, nextY, skipCount); if (wouldBite) return false; this.velocity.vx = desiredDir.x; this.velocity.vy = desiredDir.y; this.lastTurnTimestamp = currentTimestamp; // If checks pass, update velocity and timestamp this.setDirection(desiredDir.x, desiredDir.y); // Use inherited setDirection this.lastTurnTimestamp = currentTimestamp; return true; } // Player-specific method (no override) calculateSkipSegments(): number { if (this.speed <= 0) return MAX_NECK_SKIP_SEGMENTS; const baseSegmentSpacing = segRadius(PLAYER_INITIAL_LENGTH) * 2; const segmentsPerSecond = this.speed / baseSegmentSpacing; const safeTime = SAFE_PX / this.speed; const links = Math.round(safeTime * segmentsPerSecond); return Math.min(links, MAX_NECK_SKIP_SEGMENTS, this.segs.length -1); } public willHitTail(checkX: number, checkY: number, skipCount: number): boolean { const checkPoint: Point = { x: checkX, y: checkY }; const threshold = segRadius(this.length) + 1; const startCheckIndex = Math.max(6, Math.min(skipCount, this.segs.length - 1)); if (startCheckIndex >= this.segs.length) return false; for (let i = startCheckIndex; i < this.segs.length; i++) { const segmentToCheck = this.segs[i]; if (!segmentToCheck) continue; if (dist(checkPoint, segmentToCheck) < threshold) return true; } return false; } // Override eatOrb to calculate player-specific growth and add effects, then call base method override eatOrb(orbValue: number): void { // Player-specific growth calculation const growthAmount = orbValue * PLAYER_LENGTH_PER_ORB; // Call base eatOrb to handle core logic (score, length, queue) super.eatOrb(orbValue, growthAmount); // Player-specific effects: Set target speed for boost this.glowFrames = PLAYER_EAT_GLOW_FRAMES; this.targetSpeed = this.baseSpeed * PLAYER_EAT_SPEED_BOOST; // Set target speed this.speedBoostTimer = PLAYER_EAT_SPEED_BOOST_DURATION_MS / 1000; } // Override update to add player-specific logic after base logic override update(deltaTime: number, worldWidth: number, worldHeight: number): void { if (!this.visible) return; // --- Player-Specific Updates First --- // Update Base Speed based on length const lengthContribution = PLAYER_MAX_ADDITIONAL_SPEED * (1 - Math.exp(-(this.length - PLAYER_INITIAL_LENGTH) * PLAYER_SPEED_LENGTH_FACTOR)); this.baseSpeed = PLAYER_INITIAL_SPEED + Math.max(0, lengthContribution); // Update Target Speed if not boosting (base class handles boost ending) // If the boost timer is NOT active, ensure targetSpeed matches the calculated baseSpeed. if (this.speedBoostTimer <= 0) { this.targetSpeed = this.baseSpeed; } // Speed interpolation and glow frame decrement are handled in base update // --- Call Base Update Logic --- // This handles movement, segment following, growth animation, basic timer decrements super.update(deltaTime, worldWidth, worldHeight); // --- Additional Player-Specific Updates After Base --- // (None currently needed after restructuring) } // calculateLengthPixels is inherited from Serpent } export default PlayerSerpent;

src/Serpent.ts

// src/Serpent.ts import * as PIXI from 'pixi.js'; import { Point, Segment, Velocity, SnakeState, segRadius, PLAYER_INITIAL_LENGTH // Using player initial length for base segment spacing, can be adjusted } from './types'; // Restored missing imports import { dist, wrap, moveTowardsTorus, lerp } from './utils'; // Ensure lerp is imported export class Serpent implements SnakeState { id: string | number; segs: Segment[]; velocity: Velocity; speed: number; // Current actual speed targetSpeed: number; // Speed the snake is interpolating towards baseSpeed: number; // Base speed without boosts length: number; color: number; visible: boolean; name: string; score: number; isPlayer: boolean; // Default to false, subclasses override pixiObject: PIXI.Graphics | null = null; lastTurnTimestamp: number = 0; eatQueue: { distanceTraveled: number, growthAmount: number }[] = []; glowFrames: number = 0; // Basic glow tracking speedBoostTimer: number = 0; // Basic speed boost tracking protected readonly segmentGrowthDuration = 0.5; // Made protected if subclasses need it constructor(id: string | number, startX: number, startY: number, initialLength: number, initialSegments: number, initialSpeed: number, color: number, name: string, isPlayer: boolean = false) { this.id = id; this.isPlayer = isPlayer; this.visible = true; this.color = color; this.baseSpeed = initialSpeed; this.speed = this.baseSpeed; // Start at base speed this.targetSpeed = this.baseSpeed; // Target speed also starts at base this.length = initialLength; this.velocity = { vx: 1, vy: 0 }; // Default direction this.segs = []; this.name = name; this.score = 0; // Initialize segments const spacing = segRadius(this.length) * 2; for (let i = 0; i < initialSegments; i++) { this.segs.push({ x: startX - i * spacing, y: startY }); } console.log(`Serpent (${this.id}) created at (${startX}, ${startY}).`); } initPixi(stage: PIXI.Container): void { if (this.pixiObject) this.destroyPixi(); this.pixiObject = new PIXI.Graphics(); this.pixiObject.visible = this.visible; stage.addChild(this.pixiObject); console.log(`Serpent (${this.id}) Pixi Graphics object initialized.`); } destroyPixi(): void { if (this.pixiObject) { this.pixiObject.parent?.removeChild(this.pixiObject); this.pixiObject.destroy({ children: true }); this.pixiObject = null; console.log(`Serpent (${this.id}) Pixi Graphics object destroyed.`); } } /** * Basic syncPixi - Renders segments and head as simple circles. * Subclasses should override this to add specific effects. * Base implementation doesn't use skipCount or world dimensions, but signature matches override. * Prefixing unused parameters with '_' to satisfy TypeScript compiler/linter. */ syncPixi(_playerSkipCount: number, _worldWidth: number, _worldHeight: number): void { if (!this.pixiObject) return; this.pixiObject.visible = this.visible; if (!this.visible || this.segs.length === 0) { this.pixiObject.clear(); return; } const graphics = this.pixiObject; graphics.clear(); const radius = segRadius(this.length); const headRadius = radius * 1.2; const baseHexColor = this.color; const baseAlpha = 1.0; const baseOutlineColor = 0xffffff; // Simple white outline // Draw body segments (simple circles) - tail first for layering for (let i = this.segs.length - 1; i >= 1; i--) { const seg = this.segs[i]; const scaleFactor = seg.growthProgress ?? 1.0; const currentRadius = radius * scaleFactor; const currentOutlineRadius = currentRadius + Math.max(0.5, 1 * scaleFactor); const currentAlpha = baseAlpha * scaleFactor; if (currentRadius > 0.1) { // Outline - Use setStrokeStyle (assuming Pixi v8+) graphics.setStrokeStyle({ width: Math.max(1, 2 * scaleFactor), color: baseOutlineColor, alpha: currentAlpha }); graphics.moveTo(seg.x + currentOutlineRadius, seg.y); graphics.arc(seg.x, seg.y, currentOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // Body Fill graphics.fill({ color: baseHexColor, alpha: currentAlpha }); graphics.circle(seg.x, seg.y, currentRadius); } } // Draw head (simple circle) if (this.segs.length > 0) { const headSeg = this.segs[0]; const headGrowth = headSeg.growthProgress ?? 1.0; const currentHeadRadius = headRadius * headGrowth; const currentHeadOutlineRadius = currentHeadRadius + 1 * headGrowth; const currentHeadAlpha = baseAlpha * headGrowth; if (currentHeadRadius > 0.1) { // Outline - Use setStrokeStyle (assuming Pixi v8+) graphics.setStrokeStyle({ width: Math.max(1, 2 * headGrowth), color: baseOutlineColor, alpha: currentHeadAlpha }); graphics.moveTo(headSeg.x + currentHeadOutlineRadius, headSeg.y); graphics.arc(headSeg.x, headSeg.y, currentHeadOutlineRadius, 0, Math.PI * 2); graphics.stroke(); // Head Fill graphics.fill({ color: baseHexColor, alpha: currentHeadAlpha }); graphics.circle(headSeg.x, headSeg.y, currentHeadRadius); // Basic Eyes (optional for base class, could be added in override) // const finalEyeRadius = Math.max(1, currentHeadRadius * 0.2); // const eyeDist = currentHeadRadius * 0.5; // const eyeAngle = Math.atan2(this.velocity.vy, this.velocity.vx); // const eyeAnglePerp = eyeAngle + Math.PI / 2; // const eye1X = headSeg.x + Math.cos(eyeAnglePerp) * eyeDist; const eye1Y = headSeg.y + Math.sin(eyeAnglePerp) * eyeDist; // const eye2X = headSeg.x - Math.cos(eyeAnglePerp) * eyeDist; const eye2Y = headSeg.y - Math.sin(eyeAnglePerp) * eyeDist; // const eyeFillColor = 0x000000; // const eyeFillAlpha = 0.9 * headGrowth; // graphics.fill({ color: eyeFillColor, alpha: eyeFillAlpha }); graphics.circle(eye1X, eye1Y, finalEyeRadius); graphics.circle(eye2X, eye2Y, finalEyeRadius); } } } /** * Core update logic - Handles movement, segment following, basic growth. * Subclasses should call super.update() and add specific logic. */ update(deltaTime: number, worldWidth: number, worldHeight: number): void { if (!this.visible) return; // --- Speed Interpolation --- // Interpolate current speed towards target speed // Adjust the interpolation factor (e.g., 0.1) for faster/slower transitions const speedLerpFactor = 0.1; this.speed = lerp(this.speed, this.targetSpeed, speedLerpFactor); // Optional: Snap to target speed if very close to avoid tiny fluctuations if (Math.abs(this.speed - this.targetSpeed) < 0.1) { this.speed = this.targetSpeed; } // --- Core Movement --- // Use the (potentially interpolated) speed for movement calculation const moveDistance = this.speed * deltaTime; // --- Eat Queue & Growth Processing (Basic) --- // Note: Player/AI might have different growth amounts/effects, handled in overrides const currentLengthPixels = this.calculateLengthPixels(); const completedPulsesIndices: number[] = []; this.eatQueue.forEach((pulse, index) => { pulse.distanceTraveled += moveDistance; // Basic growth: Add segments when pulse reaches end if (pulse.distanceTraveled >= currentLengthPixels) { for (let g = 0; g < pulse.growthAmount; g++) { if (this.segs.length > 0) { const tail = this.segs[this.segs.length - 1]; // Add segment with growth state this.segs.push({ x: tail.x, y: tail.y, isGrowing: true, growthProgress: 0 }); } } completedPulsesIndices.push(index); } }); // Remove completed pulses for (let i = completedPulsesIndices.length - 1; i >= 0; i--) { this.eatQueue.splice(completedPulsesIndices[i], 1); } // --- Segment Growth Animation --- const growthIncrement = deltaTime / this.segmentGrowthDuration; this.segs.forEach(seg => { if (seg.isGrowing) { seg.growthProgress = (seg.growthProgress ?? 0) + growthIncrement; if (seg.growthProgress >= 1) { seg.growthProgress = 1; seg.isGrowing = false; } } }); // --- Head Movement & Wrapping --- if (this.segs.length === 0) return; const originalHead = this.segs[0]; let newHead: Point = { x: originalHead.x + this.velocity.vx * moveDistance, y: originalHead.y + this.velocity.vy * moveDistance }; newHead = wrap(newHead, worldWidth, worldHeight); this.segs[0] = newHead; // Update head position // --- Segment Following Logic --- // Using PLAYER_INITIAL_LENGTH for base spacing, adjust if needed const baseSegmentSpacing = segRadius(PLAYER_INITIAL_LENGTH) * 2; for (let i = 1; i < this.segs.length; i++) { const currGp = this.segs[i].growthProgress ?? 1; const prevGp = this.segs[i - 1]?.growthProgress ?? 1; // Use previous segment's growth const avgGp = (currGp + prevGp) * 0.5; // Average growth affects spacing const desiredSpacing = baseSegmentSpacing * avgGp; // Scale spacing by growth // Move current segment towards the previous one, maintaining desired spacing const newSegPos = moveTowardsTorus(this.segs[i - 1], this.segs[i], desiredSpacing, worldWidth, worldHeight); this.segs[i].x = newSegPos.x; this.segs[i].y = newSegPos.y; } // --- Basic Effect Timers (Subclasses might modify speed/glow differently) --- if (this.speedBoostTimer > 0) { this.speedBoostTimer -= deltaTime; if (this.speedBoostTimer <= 0) { // When boost ends, set target speed back to base speed this.targetSpeed = this.baseSpeed; this.speedBoostTimer = 0; } // Note: Subclasses now set targetSpeed, not speed directly } if (this.glowFrames > 0) { this.glowFrames--; } } setDirection(dx: number, dy: number): void { const magnitude = Math.hypot(dx, dy); if (magnitude > 0.01) { // Avoid division by zero or near-zero this.velocity.vx = dx / magnitude; this.velocity.vy = dy / magnitude; } } calculateLengthPixels(): number { let totalDistance = 0; if (this.segs.length < 2) return 0; for (let i = 0; i < this.segs.length - 1; i++) { // Basic distance calculation, assuming no wrapping between segments for length calculation // More complex logic might be needed if segments can wrap significantly const segA = this.segs[i]; const segB = this.segs[i+1]; // Simple distance, ignoring wrap for length calculation simplicity totalDistance += dist(segA, segB); } return totalDistance; } // Basic eatOrb - Handles core logic: adding score, length, and queuing growth. // Subclasses override to calculate specific growthAmount, set targetSpeed/glowFrames, then call super.eatOrb(). eatOrb(orbValue: number, growthAmount: number): void { // Basic logic using the provided growthAmount this.length += growthAmount; this.score += orbValue; // Score still based on original orb value this.eatQueue.push({ distanceTraveled: 0, growthAmount: growthAmount }); // Use calculated growthAmount // console.log(`Serpent (${this.id}) ate orb, score: ${this.score}, length: ${this.length}`); // Logging can be done in subclass override } // Basic turn logic placeholder - Player/AI implement specific checks attemptTurn(desiredDir: Point, currentTimestamp: number, turnCooldown: number): boolean { // Basic cooldown check if (currentTimestamp - this.lastTurnTimestamp <= turnCooldown) { return false; } // Basic direction change (no collision checks here) this.setDirection(desiredDir.x, desiredDir.y); this.lastTurnTimestamp = currentTimestamp; return true; } } export default Serpent;

src/SpatialHashGrid.ts

// Define a simple bounding box interface for entities interface Bounds { x: number; y: number; radius?: number; // For circular objects like heads, orbs width?: number; // For rectangular objects (optional) height?: number; // For rectangular objects (optional) } // Define the type for entities stored in the grid // Using 'any' for now, but could be refined to specific entity types (Orb, PlayerSerpent, AISerpent, Segment) type Entity = any; export class SpatialHashGrid { private worldWidth: number; private worldHeight: number; private halfWidth: number; private halfHeight: number; private cellSize: number; private grid: Map<string, Set<Entity>>; /** * Creates a Spatial Hash Grid. * @param worldWidth The total width of the toroidal world. * @param worldHeight The total height of the toroidal world. * @param cellSize The size of each grid cell. Should be large enough to contain typical interacting objects. */ constructor(worldWidth: number, worldHeight: number, cellSize: number) { this.worldWidth = worldWidth; this.worldHeight = worldHeight; this.halfWidth = worldWidth / 2; this.halfHeight = worldHeight / 2; this.cellSize = cellSize; this.grid = new Map<string, Set<Entity>>(); console.log(`SpatialHashGrid created: ${worldWidth}x${worldHeight}, CellSize: ${cellSize}`); } /** Clears all entities from the grid. Call this at the beginning of each update cycle. */ clear(): void { this.grid.clear(); } /** Helper to wrap coordinates for toroidal world */ private wrapCoord(value: number, max: number, halfMax: number): number { if (value > halfMax) return value - max; if (value < -halfMax) return value + max; return value; } /** Helper to get cell coordinates from world coordinates */ private getCellCoords(x: number, y: number): { cellX: number; cellY: number } { // Wrap coordinates first to handle entities near the edge correctly const wrappedX = this.wrapCoord(x, this.worldWidth, this.halfWidth); const wrappedY = this.wrapCoord(y, this.worldHeight, this.halfHeight); // Calculate cell index based on wrapped coordinates const cellX = Math.floor((wrappedX + this.halfWidth) / this.cellSize); const cellY = Math.floor((wrappedY + this.halfHeight) / this.cellSize); return { cellX, cellY }; } /** Helper to generate the grid key string */ private getKey(cellX: number, cellY: number): string { return `${cellX}_${cellY}`; } /** * Inserts an entity into the grid based on its bounds. * Handles world wrapping by inserting into cells across edges if necessary. * @param entity The entity to insert (can be any type). * @param bounds The bounding area of the entity. */ insert(entity: Entity, bounds: Bounds): void { const radius = bounds.radius ?? Math.max(bounds.width ?? 0, bounds.height ?? 0) / 2; // Use radius or estimate from width/height const minX = bounds.x - radius; const maxX = bounds.x + radius; const minY = bounds.y - radius; const maxY = bounds.y + radius; const startCell = this.getCellCoords(minX, minY); const endCell = this.getCellCoords(maxX, maxY); for (let cx = startCell.cellX; cx <= endCell.cellX; cx++) { for (let cy = startCell.cellY; cy <= endCell.cellY; cy++) { const key = this.getKey(cx, cy); if (!this.grid.has(key)) { this.grid.set(key, new Set<Entity>()); } this.grid.get(key)!.add(entity); // Use non-null assertion } } // --- Handle World Wrapping --- // Check if the bounds cross the world edges and insert into wrapped cells if needed. // This basic implementation might double-insert if an object spans more than half the world width/height in a dimension, // but that's unlikely for snake segments/orbs. A more robust solution might check wrap explicitly. // Example: If minX < -halfWidth and maxX > -halfWidth (crosses left edge) // Need to calculate wrapped coordinates and insert into cells on the right side too. // Similar logic for right, top, and bottom edges. // Simplified wrap check: If start/end cells are far apart, assume wrap const maxCellX = Math.floor(this.worldWidth / this.cellSize); const maxCellY = Math.floor(this.worldHeight / this.cellSize); const checkWrap = (start: number, end: number, maxCell: number): boolean => { // A large difference might indicate wrapping, especially if cellSize is small relative to world // This is a heuristic and might need refinement. return Math.abs(end - start) > maxCell / 2; }; const wrapX = checkWrap(startCell.cellX, endCell.cellX, maxCellX); const wrapY = checkWrap(startCell.cellY, endCell.cellY, maxCellY); if (wrapX || wrapY) { // If wrapping is detected, iterate through all potentially wrapped cells // This is less efficient but simpler than precise wrap calculation for now. const wrappedMinX = this.wrapCoord(minX, this.worldWidth, this.halfWidth); const wrappedMaxX = this.wrapCoord(maxX, this.worldWidth, this.halfWidth); const wrappedMinY = this.wrapCoord(minY, this.worldHeight, this.halfHeight); const wrappedMaxY = this.wrapCoord(maxY, this.worldHeight, this.halfHeight); const wrappedStartCell = this.getCellCoords(wrappedMinX, wrappedMinY); const wrappedEndCell = this.getCellCoords(wrappedMaxX, wrappedMaxY); // Iterate potentially wrapped cells (might overlap with non-wrapped loop) for (let cx = wrappedStartCell.cellX; cx <= wrappedEndCell.cellX; cx++) { for (let cy = wrappedStartCell.cellY; cy <= wrappedEndCell.cellY; cy++) { // Handle cell index wrapping if necessary (e.g., cx < 0 or cx >= maxCellX) const finalCX = (cx + maxCellX) % maxCellX; const finalCY = (cy + maxCellY) % maxCellY; const key = this.getKey(finalCX, finalCY); if (!this.grid.has(key)) { this.grid.set(key, new Set<Entity>()); } this.grid.get(key)!.add(entity); } } } } /** * Queries the grid to find potential colliders within the given bounds. * Handles world wrapping for the query area. * @param bounds The bounding area to query. * @returns A Set of unique entities found in the overlapping cells. */ query(bounds: Bounds): Set<Entity> { const results = new Set<Entity>(); const radius = bounds.radius ?? Math.max(bounds.width ?? 0, bounds.height ?? 0) / 2; const minX = bounds.x - radius; const maxX = bounds.x + radius; const minY = bounds.y - radius; const maxY = bounds.y + radius; const startCell = this.getCellCoords(minX, minY); const endCell = this.getCellCoords(maxX, maxY); const maxCellX = Math.floor(this.worldWidth / this.cellSize); const maxCellY = Math.floor(this.worldHeight / this.cellSize); for (let cx = startCell.cellX; cx <= endCell.cellX; cx++) { for (let cy = startCell.cellY; cy <= endCell.cellY; cy++) { // Handle cell index wrapping for the query const finalCX = (cx + maxCellX) % maxCellX; const finalCY = (cy + maxCellY) % maxCellY; const key = this.getKey(finalCX, finalCY); if (this.grid.has(key)) { this.grid.get(key)!.forEach(entity => results.add(entity)); } } } return results; } } export default SpatialHashGrid;

src/types.ts

// --- Core Data Structures --- /** Represents a 2D point or vector */ export interface Point { x: number; y: number; } /** Represents a segment of the snake's body */ export interface Segment extends Point { isGrowing?: boolean; // Is the segment currently in its growth animation? growthProgress?: number; // Animation progress (0 to 1) } /** Represents the snake's current movement vector */ export interface Velocity { vx: number; vy: number; } /** * Represents the state of a single snake. * Adapted for the NeonSerpent WebGL game. */ export interface SnakeState { id: string | number; // Unique identifier (using 0 for player for now) segs: Segment[]; // Array of segments, head is at index 0 velocity: Velocity; // Current direction of movement speed: number; // Current movement speed (pixels per update/second) length: number; // Logical length (used for radius calculations, might differ from segs.length) color: number; // Base color as a hex number (e.g., 0xRRGGBB) isPlayer: boolean; // Flag indicating if this is the player's snake // --- Simplified/Deferred properties from original code --- // glowFrames: number; // Counter for temporary post-eating glow effect (Deferred) // eatQueue: number[]; // Positions of active eating "pulse" animations along the body (Deferred) visible: boolean; // Should this snake be rendered? name: string; // Added name property score: number; // Added score property (ensure it's here) // Add other state as needed (e.g., power-ups) } // --- World Constants --- // World size is now dynamic, calculated in Game.ts based on screen size and zoom export const WORLD_PADDING = 50; // Pixels from the edge of the visible zoomed video // --- Player Constants --- // These might move to a dedicated constants.ts file later export const PLAYER_INITIAL_SPEED = 45; // Base speed (pixels per second) export const PLAYER_MAX_ADDITIONAL_SPEED = 100; // Increased max speed increase from length (was 60) export const PLAYER_SPEED_LENGTH_FACTOR = 0.008; // Slightly reduced factor for slower ramp-up (was 0.01) export const PLAYER_INITIAL_LENGTH = 10; export const PLAYER_INITIAL_SEGMENTS = 10; export const PLAYER_COLOR: number = 0x00FF00; // Green in hex format // --- Eating & Effects Constants --- export const PLAYER_EAT_SPEED_BOOST = 2.0; // Multiplier for speed boost peak export const PLAYER_EAT_SPEED_BOOST_DURATION_MS = 750; // Increased duration (was 350) export const PLAYER_EAT_GLOW_FRAMES = 15; // How many frames the body glow lasts export const PLAYER_LENGTH_PER_ORB = 1; // How much logical length increases per orb // --- Movement & Collision Constants --- export const KEYBOARD_TURN_COOLDOWN_MS = 100; // Milliseconds between keyboard turns export const JOYSTICK_TURN_COOLDOWN_MS = 50; // Milliseconds between joystick turns (shorter) export const SAFE_PX = 192; // Safety distance behind head for self-collision checks export const MAX_NECK_SKIP_SEGMENTS = 60; // Max segments to ignore for self-collision // Helper function adapted from provided code /** * Calculates the approximate visual radius of a snake segment based on its length. * Reduced by ~50% */ export function segRadius(snakeLength: number): number { // Reduced base radius and scaling factor for smaller snake // Start at 4px and increase slightly with length return 4 + snakeLength / 80; } // ---- Orb sizing ---- // Minimum visual radius in px (sprite will be diameter = radius * 2) export const ORB_BASE_RADIUS = 12; // Multiplier relative to the starting snake segment radius — lets us scale with future tweaks export const ORB_RADIUS_MULTIPLIER = 3; // --- Orb Constants --- export enum OrbTier { LOW, MID, HIGH } export interface OrbTierConfig { value: number; color: number; // Hex color spawnWeight: number; // Relative probability weight } export const ORB_TIER_CONFIG: Record<OrbTier, OrbTierConfig> = { [OrbTier.LOW]: { value: 1, color: 0x8888FF, spawnWeight: 6 }, // Light Blue, 60% [OrbTier.MID]: { value: 3, color: 0xFF88FF, spawnWeight: 3 }, // Pink, 30% [OrbTier.HIGH]: { value: 5, color: 0xFFFF88, spawnWeight: 1 }, // Light Yellow, 10% }; // Calculate total weight for weighted random selection export const ORB_TOTAL_SPAWN_WEIGHT = Object.values(ORB_TIER_CONFIG).reduce((sum, tier) => sum + tier.spawnWeight, 0); // Mapping from Orb Tier to its texture path export const ORB_TEXTURE: Record<OrbTier, string> = { [OrbTier.LOW]: '/orb1.png', [OrbTier.MID]: '/orb2.png', [OrbTier.HIGH]: '/orb3.png' }; // Add other shared types or constants as needed

src/UI.ts

// Define potential game states or actions triggered by UI export type UIAction = | 'startGame' | 'showControls' | 'hideControls' | 'resumeGame' | 'pauseGame' // Although pause might be triggered by key press, menu could confirm | 'newGameFromPause' | 'newGameFromGameOver' // | 'showScoreboard' // Placeholder for original scoreboard button if needed | 'toggleDevMode' | 'togglePause'; // Action for the new pause button // Callback type for UI actions export type UIActionCallback = (action: UIAction) => void; export class UIManager { // Menu Containers private mainMenu: HTMLElement | null = null; private controlsMenu: HTMLElement | null = null; private pauseMenu: HTMLElement | null = null; private gameOverMenu: HTMLElement | null = null; private loadingScreen: HTMLElement | null = null; // Keep track of loading screen private pauseLeaderboardList: HTMLElement | null = null; // Added for in-game leaderboard private inGameMiniBoard: HTMLElement | null = null; private pauseButton: HTMLButtonElement | null = null; // Reference to the new pause button // Score Display private finalScoreElement: HTMLElement | null = null; // Callback for notifying the game logic private actionCallback: UIActionCallback; constructor(callback: UIActionCallback) { this.actionCallback = callback; } init(): void { // Get menu elements this.mainMenu = document.getElementById('mainMenu'); this.controlsMenu = document.getElementById('controlsMenu'); this.pauseMenu = document.getElementById('pauseMenu'); this.gameOverMenu = document.getElementById('gameOverMenu'); this.loadingScreen = document.getElementById('loadingScreen'); this.finalScoreElement = document.getElementById('finalScore'); this.pauseLeaderboardList = document.getElementById('pauseLeaderboardList'); // Get pause leaderboard list this.inGameMiniBoard = document.getElementById('inGameLeaderboard'); this.pauseButton = document.getElementById('pause-button') as HTMLButtonElement; // Get the pause button // Basic validation if (!this.mainMenu || !this.controlsMenu || !this.pauseMenu || !this.gameOverMenu || !this.loadingScreen || !this.finalScoreElement || !this.pauseLeaderboardList || !this.pauseButton) { console.error('UIManager: Failed to find one or more required UI elements (including pause-button)!'); // Don't return here, just warn about missing mini-board if applicable // return; } if (!this.inGameMiniBoard) console.warn('#inGameLeaderboard missing'); // --- Attach Button Listeners --- // Main Menu Buttons this.attachListener('startGameBtn', () => this.actionCallback('startGame')); this.attachListener('controlsBtnMain', () => this.actionCallback('showControls')); // Scoreboard button is placeholder for now - Re-add if needed, or use the new ID if keeping the button // this.attachListener('showLeaderboardBtn', () => this.actionCallback('showLeaderboard')); // Example if keeping button this.attachListener('scoreboardBtn', () => console.log('Scoreboard clicked (placeholder)')); // Original placeholder listener // Dev Mode Button this.attachListener('toggleDevModeBtn', () => this.actionCallback('toggleDevMode')); // Controls Menu Buttons this.attachListener('backToMainBtnControls', () => this.actionCallback('hideControls')); // Pause Menu Buttons this.attachListener('resumeGameBtn', () => this.actionCallback('resumeGame')); this.attachListener('newGameBtnPause', () => this.actionCallback('newGameFromPause')); this.attachListener('controlsBtnPause', () => this.actionCallback('showControls')); // Game Over Menu Buttons this.attachListener('mainMenuBtnGameOver', () => this.actionCallback('newGameFromGameOver')); // Or could be 'showMainMenu' // New Pause Button Listener this.attachListener('pause-button', () => this.actionCallback('togglePause')); console.log('UIManager initialized.'); // Initially, ensure only the main menu might be visible (or none if loading) // Actual visibility will be controlled by Game state later this.hideAllMenus(); } private attachListener(elementId: string, callback: () => void): void { const element = document.getElementById(elementId); if (element) { element.addEventListener('click', callback); } else { console.warn(`UIManager: Element with ID "${elementId}" not found.`); } } hideAllMenus(): void { this.mainMenu?.classList.add('hidden'); this.controlsMenu?.classList.add('hidden'); this.pauseMenu?.classList.add('hidden'); this.gameOverMenu?.classList.add('hidden'); } showMainMenu(): void { this.hideAllMenus(); this.mainMenu?.classList.remove('hidden'); } showControlsMenu(): void { // Show the controls menu and apply blur this.controlsMenu?.classList.remove('hidden'); this.controlsMenu?.classList.add('backdrop-blur'); // Add blur class } hideControlsMenu(): void { // Hide the controls menu and remove blur this.controlsMenu?.classList.add('hidden'); this.controlsMenu?.classList.remove('backdrop-blur'); // Remove blur class } showPauseMenu(): void { // Will be updated later to accept scores this.hideAllMenus(); // Usually pause takes over the screen this.pauseMenu?.classList.remove('hidden'); } showGameOverMenu(score: number): void { this.hideAllMenus(); if (this.finalScoreElement) { this.finalScoreElement.textContent = `Score: ${score}`; } this.gameOverMenu?.classList.remove('hidden'); } // --- Loading Screen --- hideLoadingScreen(): void { this.loadingScreen?.style.setProperty('display', 'none'); // Use setProperty for style changes } showLoadingScreen(message: string = 'Loading...'): void { if (this.loadingScreen) { this.loadingScreen.textContent = message; this.loadingScreen.style.setProperty('display', 'flex'); } } // --- In-Game Leaderboard Update --- updatePauseLeaderboard(scores: { name: string, score: number, isPlayer: boolean }[]): void { if (!this.pauseLeaderboardList) return; // Clear previous entries this.pauseLeaderboardList.innerHTML = ''; // Sort scores descending scores.sort((a, b) => b.score - a.score); // Populate with new scores (limit to top 10 or less) const topScores = scores.slice(0, 10); topScores.forEach(entry => { const li = document.createElement('li'); const nameSpan = document.createElement('span'); nameSpan.classList.add('leaderboard-name'); if (entry.isPlayer) { nameSpan.classList.add('player-score'); // Highlight player name } nameSpan.textContent = entry.name; const scoreSpan = document.createElement('span'); scoreSpan.classList.add('leaderboard-score'); if (entry.isPlayer) { scoreSpan.classList.add('player-score'); // Highlight player score } scoreSpan.textContent = entry.score.toString(); li.appendChild(nameSpan); li.appendChild(scoreSpan); this.pauseLeaderboardList?.appendChild(li); }); } // 🆕 live HUD (top 5) updateMiniLeaderboard(scores:{name:string,score:number,isPlayer:boolean}[]) { if(!this.inGameMiniBoard) return; // hide on small screens (CSS already does `display:none`) this.inGameMiniBoard.innerHTML = ''; scores.sort((a,b)=>b.score-a.score); scores.slice(0,5).forEach(e=>{ const div=document.createElement('div'); div.className='mini-row'+(e.isPlayer?' player':''); div.textContent=`${e.name} ${e.score}`; this.inGameMiniBoard!.appendChild(div); }); } // --- Pause Button Update --- updatePauseButton(isPaused: boolean): void { if (this.pauseButton) { // Use Unicode characters for pause (❚❚) and play (►) this.pauseButton.textContent = isPaused ? '►' : '❚❚'; // Optional: Change style/tooltip based on state this.pauseButton.title = isPaused ? 'Resume Game' : 'Pause Game'; } } } // Export the class and types // export {}; // No longer needed as class is exported

src/utils.ts

import { Point } from './types'; /** Calculates Euclidean distance between two points */ export function dist(p1: Point, p2: Point): number { return Math.hypot(p1.x - p2.x, p1.y - p2.y); } /** Toroidal (wrapping) distance — shortest path across world edges */ export function distTorus(p1: Point, p2: Point, worldW: number, worldH: number): number { const halfW = worldW * 0.5, halfH = worldH * 0.5; let dx = Math.abs(p1.x - p2.x), dy = Math.abs(p1.y - p2.y); if (dx > halfW) dx = worldW - dx; if (dy > halfH) dy = worldH - dy; return Math.hypot(dx, dy); } // Add other utility functions here as needed /** Helper function to wrap coordinates within the torus world */ export function wrap(p: Point, worldWidth: number, worldHeight: number): Point { const halfWidth = worldWidth / 2; const halfHeight = worldHeight / 2; let { x, y } = p; if (x > halfWidth) { x -= worldWidth; } else if (x < -halfWidth) { x += worldWidth; } if (y > halfHeight) { y -= worldHeight; } else if (y < -halfHeight) { y += worldHeight; } return { x, y }; } /** Helper function to calculate the shortest delta on a torus */ export function torusDelta(target: Point, current: Point, worldWidth: number, worldHeight: number): [number, number] { const halfWidth = worldWidth / 2; const halfHeight = worldHeight / 2; let dx = target.x - current.x; let dy = target.y - current.y; if (dx > halfWidth) { dx -= worldWidth; } else if (dx < -halfWidth) { dx += worldWidth; } if (dy > halfHeight) { dy -= worldHeight; } else if (dy < -halfHeight) { dy += worldHeight; } return [dx, dy]; } /** Torus-aware version of moveTowards */ export function moveTowardsTorus(target: Point, current: Point, distance: number, worldWidth: number, worldHeight: number): Point { const [dx, dy] = torusDelta(current, target, worldWidth, worldHeight); // Depends on torusDelta const len = Math.hypot(dx, dy) || 1; const scale = distance / len; return wrap({ x: target.x + dx * scale, y: target.y + dy * scale }, worldWidth, worldHeight); // Depends on wrap } // Helper function for linear color interpolation (Adding this here too as it was in PlayerSerpent) export function lerpColor(color1: number, color2: number, t: number): number { t = Math.max(0, Math.min(1, t)); // Clamp t between 0 and 1 const r1 = (color1 >> 16) & 0xff; const g1 = (color1 >> 8) & 0xff; const b1 = color1 & 0xff; const r2 = (color2 >> 16) & 0xff; const g2 = (color2 >> 8) & 0xff; const b2 = color2 & 0xff; const r = Math.round(r1 + (r2 - r1) * t); const g = Math.round(g1 + (g2 - g1) * t); const b = Math.round(b1 + (b2 - b1) * t); return (r << 16) | (g << 8) | b; } /** Linear interpolation between two numbers */ export function lerp(start: number, end: number, t: number): number { t = Math.max(0, Math.min(1, t)); // Clamp t between 0 and 1 return start + (end - start) * t; }

src/vite-env.d.ts

/// <reference types="vite/client" /> declare module '*?raw' { const content: string; export default content; }

src/World.ts

export {};

tsconfig.json

{ "compilerOptions": { /* Base Options: */ "esModuleInterop": true, "skipLibCheck": true, "target": "ESNext", "allowJs": false, "resolveJsonModule": true, "moduleDetection": "force", "isolatedModules": true, /* Strictness */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, /* If NOT transpiling with TypeScript: */ "module": "ESNext", "noEmit": true, /* If your code runs in the DOM: */ "lib": ["ESNext", "DOM", "DOM.Iterable"], /* Module Resolution Options */ "moduleResolution": "bundler" // "baseUrl": ".", // "paths": {}, /* Source Map Options */ // "sourceMap": true, // "outDir": "./dist", // "rootDir": "./src", }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] }