diff --git a/index.html b/index.html index c509cbe..a285ca5 100644 --- a/index.html +++ b/index.html @@ -15,9 +15,6 @@ font-family: 'Arial', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } - #gameCanvas { - display: block; - } #ui { position: absolute; top: 20px; @@ -82,6 +79,26 @@ z-index: 100; font-family: 'Courier New', monospace; } + #perfMonitor { + position: absolute; + top: 60px; + right: 20px; + background: rgba(0, 0, 0, 0.7); + padding: 10px; + border-radius: 5px; + color: #00ff00; + font-size: 12px; + font-family: 'Courier New', monospace; + z-index: 100; + display: none; + min-width: 120px; + } + #perfMonitor.visible { + display: block; + } + #perfMonitor .label { + color: #888; + } @media (max-width: 768px) { #instructions { font-size: 14px; @@ -91,6 +108,11 @@ top: 10px; right: 10px; } + #perfMonitor { + top: 40px; + right: 10px; + font-size: 11px; + } } @@ -102,6 +124,12 @@
v-
+
+
FPS: 60
+
Frame: 16.7ms
+
Entities: 0
+
+

Game Over!

Final Score: 0

@@ -110,623 +138,22 @@

Controls: WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!

+

Press "T" or shake device to toggle performance monitor

- - - \ No newline at end of file + + diff --git a/src/components/Collidable.js b/src/components/Collidable.js new file mode 100644 index 0000000..7c45c2d --- /dev/null +++ b/src/components/Collidable.js @@ -0,0 +1,20 @@ +/** + * Collidable component - defines collision properties for an entity + */ +export class Collidable { + /** + * @param {number} [radius=1.0] - Collision radius + * @param {string} [layer='default'] - Collision layer (e.g., 'player', 'coin', 'obstacle') + */ + constructor(radius = 1.0, layer = 'default') { + /** @type {number} Collision detection radius */ + this.radius = radius; + + /** @type {string} Collision layer identifier */ + this.layer = layer; + + /** @type {string[]} Array of layer names this entity can collide with */ + this.collidesWith = []; + } +} + diff --git a/src/components/Health.js b/src/components/Health.js new file mode 100644 index 0000000..304acb6 --- /dev/null +++ b/src/components/Health.js @@ -0,0 +1,50 @@ +/** + * Health component - health points and damage/healing logic for entities + */ +export class Health { + /** + * @param {number} [maxHealth=100] - Maximum health points + */ + constructor(maxHealth = 100) { + /** @type {number} Maximum health value */ + this.maxHealth = maxHealth; + + /** @type {number} Current health value */ + this.currentHealth = maxHealth; + } + + /** + * Apply damage to the entity + * @param {number} amount - Damage amount + * @returns {boolean} True if entity is dead (health <= 0) + */ + damage(amount) { + this.currentHealth = Math.max(0, this.currentHealth - amount); + return this.currentHealth <= 0; + } + + /** + * Heal the entity + * @param {number} amount - Heal amount + */ + heal(amount) { + this.currentHealth = Math.min(this.maxHealth, this.currentHealth + amount); + } + + /** + * Check if entity is alive + * @returns {boolean} True if health > 0 + */ + isAlive() { + return this.currentHealth > 0; + } + + /** + * Get health as a percentage + * @returns {number} Health percentage (0-100) + */ + getPercentage() { + return (this.currentHealth / this.maxHealth) * 100; + } +} + diff --git a/src/components/MeshComponent.js b/src/components/MeshComponent.js new file mode 100644 index 0000000..6c4247b --- /dev/null +++ b/src/components/MeshComponent.js @@ -0,0 +1,19 @@ +/** + * Mesh component - holds reference to Three.js mesh for rendering + */ +export class MeshComponent { + /** + * @param {THREE.Mesh} mesh - The Three.js mesh to render + */ + constructor(mesh) { + /** @type {THREE.Mesh} The Three.js mesh object */ + this.mesh = mesh; + + /** @type {boolean} Whether this mesh casts shadows */ + this.castShadow = true; + + /** @type {boolean} Whether this mesh receives shadows */ + this.receiveShadow = true; + } +} + diff --git a/src/components/Tags.js b/src/components/Tags.js new file mode 100644 index 0000000..e26a33a --- /dev/null +++ b/src/components/Tags.js @@ -0,0 +1,66 @@ +/** + * Tag components - simple markers and metadata for entity types. + * These are lightweight components used for filtering and entity-specific data. + */ + +/** + * PlayerTag - marks the player entity + */ +export class PlayerTag {} + +/** + * CoinTag - marks collectible coin entities with animation properties + */ +export class CoinTag { + /** + * @param {number} [index=0] - Unique index for this coin + */ + constructor(index = 0) { + /** @type {number} Unique identifier for animation offset */ + this.index = index; + + /** @type {number} Rotation speed for coin spin */ + this.rotationSpeed = 0.02; + + /** @type {number} Bobbing animation speed */ + this.bobSpeed = 0.003; + + /** @type {number} Bobbing animation height */ + this.bobAmount = 0.2; + } +} + +/** + * ObstacleTag - marks obstacle entities with movement properties + */ +export class ObstacleTag { + constructor() { + /** @type {number} Movement speed for obstacle */ + this.speed = 0.05; + } +} + +/** + * BoundaryConstrained - marks entities that should stay within boundaries + */ +export class BoundaryConstrained { + /** + * @param {number} [boundarySize=30] - Total size of the boundary area + */ + constructor(boundarySize = 30) { + /** @type {number} Total boundary size */ + this.boundarySize = boundarySize; + + /** @type {number} Safety margin from edges */ + this.margin = 0.5; + } + + /** + * Get the effective boundary accounting for margin + * @returns {number} Usable boundary from center + */ + getBoundary() { + return this.boundarySize / 2 - this.margin; + } +} + diff --git a/src/components/Transform.js b/src/components/Transform.js new file mode 100644 index 0000000..8147fd4 --- /dev/null +++ b/src/components/Transform.js @@ -0,0 +1,39 @@ +/** + * Transform component - position, rotation, and scale in 3D space + */ +export class Transform { + /** + * @param {number} [x=0] - X position + * @param {number} [y=0] - Y position + * @param {number} [z=0] - Z position + */ + constructor(x = 0, y = 0, z = 0) { + /** @type {THREE.Vector3} */ + this.position = new window.THREE.Vector3(x, y, z); + + /** @type {THREE.Euler} */ + this.rotation = new window.THREE.Euler(0, 0, 0); + + /** @type {THREE.Vector3} */ + this.scale = new window.THREE.Vector3(1, 1, 1); + } + + /** + * Set the position + * @param {number} x - X position + * @param {number} y - Y position + * @param {number} z - Z position + */ + setPosition(x, y, z) { + this.position.set(x, y, z); + } + + /** + * Get a clone of the position + * @returns {THREE.Vector3} Cloned position vector + */ + getPosition() { + return this.position.clone(); + } +} + diff --git a/src/components/Velocity.js b/src/components/Velocity.js new file mode 100644 index 0000000..1ed9301 --- /dev/null +++ b/src/components/Velocity.js @@ -0,0 +1,67 @@ +/** + * Velocity component - movement speed, direction, and physics properties + */ +export class Velocity { + /** + * @param {number} [x=0] - Initial X velocity + * @param {number} [y=0] - Initial Y velocity + * @param {number} [z=0] - Initial Z velocity + */ + constructor(x = 0, y = 0, z = 0) { + /** @type {THREE.Vector3} */ + this.velocity = new window.THREE.Vector3(x, y, z); + + /** @type {number} Maximum speed magnitude */ + this.maxSpeed = 0.15; + + /** @type {number} Acceleration rate */ + this.acceleration = 0.08; + + /** @type {number} Deceleration rate */ + this.deceleration = 0.12; + } + + /** + * Set velocity directly + * @param {number} x - X velocity + * @param {number} y - Y velocity + * @param {number} z - Z velocity + */ + set(x, y, z) { + this.velocity.set(x, y, z); + } + + /** + * Add to current velocity + * @param {number} x - X velocity to add + * @param {number} y - Y velocity to add + * @param {number} z - Z velocity to add + */ + add(x, y, z) { + this.velocity.add(new window.THREE.Vector3(x, y, z)); + } + + /** + * Multiply velocity by scalar + * @param {number} scalar - Multiplier + */ + multiplyScalar(scalar) { + this.velocity.multiplyScalar(scalar); + } + + /** + * Get the magnitude of velocity + * @returns {number} Velocity magnitude + */ + length() { + return this.velocity.length(); + } + + /** + * Reset velocity to zero + */ + reset() { + this.velocity.set(0, 0, 0); + } +} + diff --git a/src/ecs/System.js b/src/ecs/System.js new file mode 100644 index 0000000..90b2959 --- /dev/null +++ b/src/ecs/System.js @@ -0,0 +1,53 @@ +/** + * Base class for all systems. + * Systems contain logic that operates on entities with specific components. + * + * @typedef {import('./World.js').EntityId} EntityId + * @typedef {import('./World.js').ComponentClass} ComponentClass + */ +export class System { + constructor() { + /** @type {import('./World.js').World|null} */ + this.world = null; + + /** @type {boolean} */ + this.enabled = true; + } + + /** + * Called once when the system is added to the world + */ + init() {} + + /** + * Called every frame + * @param {number} deltaTime - Time since last frame in seconds + */ + update(_deltaTime) {} + + /** + * Called when the system is removed or world is cleaned up + */ + cleanup() {} + + /** + * Helper to get entities with specific components + * @param {...ComponentClass} componentClasses - The component classes to filter by + * @returns {EntityId[]} Array of entity IDs + */ + getEntities(...componentClasses) { + return this.world.getEntitiesWithComponents(...componentClasses); + } + + /** + * Helper to get a component from an entity + * @template T + * @param {EntityId} entityId - The entity ID + * @param {new (...args: any[]) => T} componentClass - The component class + * @returns {T|undefined} The component instance or undefined + */ + getComponent(entityId, componentClass) { + return this.world.getComponent(entityId, componentClass); + } +} + diff --git a/src/ecs/World.js b/src/ecs/World.js new file mode 100644 index 0000000..eaf2141 --- /dev/null +++ b/src/ecs/World.js @@ -0,0 +1,170 @@ +/** + * World manages all entities, components, and systems in the ECS architecture. + * + * @typedef {number} EntityId - Unique identifier for an entity + * @typedef {Function} ComponentClass - A component class constructor + * @typedef {Object} Component - A component instance (data only) + */ +export class World { + constructor() { + /** @type {Map>} */ + this.entities = new Map(); // entityId -> Set of component classes + + /** @type {Map>} */ + this.components = new Map(); // componentClass -> Map(entityId -> component) + + /** @type {import('./System.js').System[]} */ + this.systems = []; + + /** @type {EntityId} */ + this.nextEntityId = 1; + } + + /** + * Create a new entity and return its ID + * @returns {EntityId} The newly created entity ID + */ + createEntity() { + const id = this.nextEntityId++; + this.entities.set(id, new Set()); + return id; + } + + /** + * Remove an entity and all its components + * @param {EntityId} entityId - The entity to remove + */ + removeEntity(entityId) { + if (!this.entities.has(entityId)) return; + + // Remove all components for this entity + const componentClasses = this.entities.get(entityId); + for (const componentClass of componentClasses) { + const componentMap = this.components.get(componentClass); + if (componentMap) { + componentMap.delete(entityId); + } + } + + this.entities.delete(entityId); + } + + /** + * Add a component instance to an entity. Component class is inferred from the instance. + * @param {EntityId} entityId - The entity to add the component to + * @param {Component} component - The component instance (must have a constructor) + * @throws {Error} If entity doesn't exist + */ + addComponent(entityId, component) { + if (!this.entities.has(entityId)) { + throw new Error(`Entity ${entityId} does not exist`); + } + + const componentClass = component.constructor; + + // Track that this entity has this component class + this.entities.get(entityId).add(componentClass); + + // Store the component instance + if (!this.components.has(componentClass)) { + this.components.set(componentClass, new Map()); + } + this.components.get(componentClass).set(entityId, component); + } + + /** + * Get a component from an entity + * @template T + * @param {EntityId} entityId - The entity ID + * @param {new (...args: any[]) => T} componentClass - The component class + * @returns {T|undefined} The component instance or undefined + */ + getComponent(entityId, componentClass) { + const componentMap = this.components.get(componentClass); + return componentMap ? componentMap.get(entityId) : undefined; + } + + /** + * Check if an entity has a component + * @param {EntityId} entityId - The entity ID + * @param {ComponentClass} componentClass - The component class + * @returns {boolean} True if the entity has the component + */ + hasComponent(entityId, componentClass) { + return this.entities.get(entityId)?.has(componentClass) || false; + } + + /** + * Remove a component from an entity + * @param {EntityId} entityId - The entity ID + * @param {ComponentClass} componentClass - The component class to remove + */ + removeComponent(entityId, componentClass) { + const entityComponents = this.entities.get(entityId); + if (entityComponents) { + entityComponents.delete(componentClass); + } + + const componentMap = this.components.get(componentClass); + if (componentMap) { + componentMap.delete(entityId); + } + } + + /** + * Get all entities that have ALL of the specified component classes + * @param {...ComponentClass} componentClasses - The component classes to filter by + * @returns {EntityId[]} Array of entity IDs that have all specified components + */ + getEntitiesWithComponents(...componentClasses) { + const result = []; + + for (const [entityId, entityComponentClasses] of this.entities.entries()) { + const hasAll = componentClasses.every(cls => entityComponentClasses.has(cls)); + if (hasAll) { + result.push(entityId); + } + } + + return result; + } + + /** + * Add a system to the world + * @param {import('./System.js').System} system - The system to add + */ + addSystem(system) { + system.world = this; + this.systems.push(system); + if (system.init) { + system.init(); + } + } + + /** + * Update all systems with delta time + * @param {number} deltaTime - Time elapsed since last frame (in seconds) + */ + update(deltaTime) { + for (const system of this.systems) { + if (system.enabled !== false) { + system.update(deltaTime); + } + } + } + + /** + * Cleanup all systems and clear all entities/components + */ + cleanup() { + for (const system of this.systems) { + if (system.cleanup) { + system.cleanup(); + } + } + this.entities.clear(); + this.components.clear(); + this.systems = []; + } +} + diff --git a/src/game/EntityFactory.js b/src/game/EntityFactory.js new file mode 100644 index 0000000..31bbe19 --- /dev/null +++ b/src/game/EntityFactory.js @@ -0,0 +1,157 @@ +import { Transform } from '../components/Transform.js'; +import { Velocity } from '../components/Velocity.js'; +import { MeshComponent } from '../components/MeshComponent.js'; +import { Collidable } from '../components/Collidable.js'; +import { Health } from '../components/Health.js'; +import { PlayerTag, CoinTag, ObstacleTag, BoundaryConstrained } from '../components/Tags.js'; + +/** + * EntityFactory - creates pre-configured game entities with appropriate components. + * Centralizes entity creation logic for consistency. + * + * @typedef {import('../ecs/World.js').EntityId} EntityId + */ +export class EntityFactory { + /** + * @param {import('../ecs/World.js').World} world - The ECS world + * @param {THREE.Scene} scene - The Three.js scene + */ + constructor(world, scene) { + /** @type {import('../ecs/World.js').World} */ + this.world = world; + + /** @type {THREE.Scene} */ + this.scene = scene; + + /** @type {number} Size of the game ground/play area */ + this.groundSize = 30; + } + + /** + * Create the player entity + * @returns {EntityId} The player entity ID + */ + createPlayer() { + const entity = this.world.createEntity(); + + // Create mesh + const geometry = new window.THREE.BoxGeometry(1, 1, 1); + const material = new window.THREE.MeshStandardMaterial({ + color: 0x4169E1, + metalness: 0.3, + roughness: 0.4 + }); + const mesh = new window.THREE.Mesh(geometry, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.scene.add(mesh); + + // Add components + this.world.addComponent(entity, new Transform(0, 0.5, 0)); + this.world.addComponent(entity, new Velocity()); + this.world.addComponent(entity, new MeshComponent(mesh)); + this.world.addComponent(entity, new Collidable(0, 'player')); // Player center point (original behavior) + this.world.addComponent(entity, new Health(100)); + this.world.addComponent(entity, new PlayerTag()); + this.world.addComponent(entity, new BoundaryConstrained(this.groundSize)); + + return entity; + } + + /** + * Create a collectible coin entity + * @param {number} [index=0] - Unique index for animation offset + * @returns {EntityId} The coin entity ID + */ + createCoin(index = 0) { + const entity = this.world.createEntity(); + + // Create mesh + const geometry = new window.THREE.SphereGeometry(0.3, 16, 16); + const material = new window.THREE.MeshStandardMaterial({ + color: 0xFFD700, + metalness: 0.8, + roughness: 0.2, + emissive: 0xFFD700, + emissiveIntensity: 0.3 + }); + const mesh = new window.THREE.Mesh(geometry, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.scene.add(mesh); + + // Random position + const x = (Math.random() - 0.5) * (this.groundSize - 4); + const z = (Math.random() - 0.5) * (this.groundSize - 4); + + // Add components + this.world.addComponent(entity, new Transform(x, 0.5, z)); + this.world.addComponent(entity, new MeshComponent(mesh)); + this.world.addComponent(entity, new Collidable(0.8, 'coin')); + this.world.addComponent(entity, new CoinTag(index)); + + return entity; + } + + /** + * Create an obstacle entity + * @returns {EntityId} The obstacle entity ID + */ + createObstacle() { + const entity = this.world.createEntity(); + + // Create mesh + const geometry = new window.THREE.BoxGeometry(1.5, 2, 1.5); + const material = new window.THREE.MeshStandardMaterial({ + color: 0xFF4500, + metalness: 0.3, + roughness: 0.7 + }); + const mesh = new window.THREE.Mesh(geometry, material); + mesh.castShadow = true; + mesh.receiveShadow = true; + this.scene.add(mesh); + + // Random position (away from center) + let posX, posZ; + do { + posX = (Math.random() - 0.5) * (this.groundSize - 4); + posZ = (Math.random() - 0.5) * (this.groundSize - 4); + } while (Math.abs(posX) < 3 && Math.abs(posZ) < 3); + + // Random velocity + const velocity = new Velocity( + (Math.random() - 0.5) * 0.05, + 0, + (Math.random() - 0.5) * 0.05 + ); + + // Add components + this.world.addComponent(entity, new Transform(posX, 1, posZ)); + this.world.addComponent(entity, velocity); + this.world.addComponent(entity, new MeshComponent(mesh)); + this.world.addComponent(entity, new Collidable(1.5, 'obstacle')); + this.world.addComponent(entity, new ObstacleTag()); + this.world.addComponent(entity, new BoundaryConstrained(this.groundSize)); + + return entity; + } + + /** + * Remove entity and its mesh from scene + * @param {EntityId} entityId - The entity to destroy + */ + destroyEntity(entityId) { + // Remove mesh from scene if it exists + const meshComp = this.world.getComponent(entityId, MeshComponent); + if (meshComp) { + this.scene.remove(meshComp.mesh); + meshComp.mesh.geometry.dispose(); + meshComp.mesh.material.dispose(); + } + + // Remove entity from world + this.world.removeEntity(entityId); + } +} + diff --git a/src/game/Game.js b/src/game/Game.js new file mode 100644 index 0000000..3dd9dad --- /dev/null +++ b/src/game/Game.js @@ -0,0 +1,486 @@ +import { World } from '../ecs/World.js'; +import { EntityFactory } from './EntityFactory.js'; + +// Systems +import { InputSystem } from '../systems/InputSystem.js'; +import { PlayerControlSystem } from '../systems/PlayerControlSystem.js'; +import { MovementSystem } from '../systems/MovementSystem.js'; +import { BoundarySystem } from '../systems/BoundarySystem.js'; +import { CoinSystem } from '../systems/CoinSystem.js'; +import { ObstacleSystem } from '../systems/ObstacleSystem.js'; +import { CollisionSystem } from '../systems/CollisionSystem.js'; +import { RenderSystem } from '../systems/RenderSystem.js'; + +// Components +import { Transform } from '../components/Transform.js'; +import { Health } from '../components/Health.js'; + +/** + * Main Game class - manages the game loop and coordinates all systems. + * Orchestrates the ECS architecture and Three.js rendering. + * + * @typedef {import('../ecs/World.js').EntityId} EntityId + */ +export class Game { + constructor() { + /** @type {number} Size of the game play area */ + this.groundSize = 30; + + /** @type {number} Current game score */ + this.score = 0; + + /** @type {boolean} Whether the game is currently active */ + this.gameActive = true; + + /** @type {EntityId|null} The player entity ID */ + this.playerEntity = null; + + /** @type {EntityId[]} Array of coin entity IDs */ + this.coins = []; + + /** @type {EntityId[]} Array of obstacle entity IDs */ + this.obstacles = []; + + /** @type {number} Last frame timestamp for deltaTime calculation */ + this.lastTime = performance.now(); + + /** @type {number} Maximum deltaTime cap (in seconds) to prevent huge jumps */ + this.maxDeltaTime = 0.1; // 100ms cap + + /** @type {number} Smoothed FPS for display */ + this.smoothedFPS = 60; + + /** @type {number} Last time performance monitor was updated */ + this.lastPerfUpdate = performance.now(); + + /** @type {boolean} Whether the game is paused (e.g., tab not visible) */ + this.isPaused = false; + + /** @type {boolean} Whether performance monitor is visible */ + this.perfMonitorVisible = false; + + /** @type {Object} Shake detection state */ + this.shakeDetection = { + lastX: 0, + lastY: 0, + lastZ: 0, + shakeThreshold: 15, + shakeCount: 0, + lastShakeTime: 0 + }; + + this.init(); + this.setupEventListeners(); + this.animate(); + } + + /** + * Initialize the game (ECS, Three.js, entities) + */ + init() { + // Initialize ECS + this.world = new World(); + + // Setup Three.js + this.setupScene(); + this.setupCamera(); + this.setupRenderer(); + this.setupLights(); + this.setupGround(); + + // Create entity factory + this.entityFactory = new EntityFactory(this.world, this.scene); + + // Initialize systems + this.setupSystems(); + + // Create game entities + this.createGameEntities(); + } + + setupScene() { + this.scene = new window.THREE.Scene(); + this.scene.background = new window.THREE.Color(0x87CEEB); + this.scene.fog = new window.THREE.Fog(0x87CEEB, 0, 50); + } + + setupCamera() { + this.camera = new window.THREE.PerspectiveCamera( + 75, + window.innerWidth / window.innerHeight, + 0.1, + 1000 + ); + this.camera.position.set(0, 10, 15); + this.camera.lookAt(0, 0, 0); + } + + setupRenderer() { + this.renderer = new window.THREE.WebGLRenderer({ antialias: true }); + this.renderer.setSize(window.innerWidth, window.innerHeight); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = window.THREE.PCFSoftShadowMap; + + document.body.appendChild(this.renderer.domElement); + } + + setupLights() { + // Increased ambient light for brighter scene (was 0.6) + const ambientLight = new window.THREE.AmbientLight(0xffffff, 0.6); + this.scene.add(ambientLight); + + // Increased directional light for better clarity (was 0.8) + const directionalLight = new window.THREE.DirectionalLight(0xffffff, 3.0); + directionalLight.position.set(10, 20, 10); + directionalLight.castShadow = true; + directionalLight.shadow.mapSize.width = 2048; + directionalLight.shadow.mapSize.height = 2048; + directionalLight.shadow.camera.left = -20; + directionalLight.shadow.camera.right = 20; + directionalLight.shadow.camera.top = 20; + directionalLight.shadow.camera.bottom = -20; + directionalLight.shadow.camera.near = 0.5; + directionalLight.shadow.camera.far = 50; + this.scene.add(directionalLight); + } + + setupGround() { + const groundGeometry = new window.THREE.PlaneGeometry(this.groundSize, this.groundSize); + const groundMaterial = new window.THREE.MeshStandardMaterial({ + color: 0x90EE90, + roughness: 0.8 + }); + const ground = new window.THREE.Mesh(groundGeometry, groundMaterial); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + this.scene.add(ground); + + const gridHelper = new window.THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000); + gridHelper.material.opacity = 0.2; + gridHelper.material.transparent = true; + this.scene.add(gridHelper); + } + + setupSystems() { + // Input system (must be first) + this.inputSystem = new InputSystem(); + this.world.addSystem(this.inputSystem); + + // Player control + this.world.addSystem(new PlayerControlSystem(this.inputSystem)); + + // Movement and physics + this.world.addSystem(new MovementSystem()); + this.world.addSystem(new BoundarySystem()); + this.world.addSystem(new ObstacleSystem()); + + // Game-specific behavior + this.world.addSystem(new CoinSystem()); + + // Collision detection + this.collisionSystem = new CollisionSystem(); + this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => { + this.handleCollision(entity1, entity2, layer1, layer2); + }); + this.world.addSystem(this.collisionSystem); + + // Rendering (must be last to sync transforms) + this.world.addSystem(new RenderSystem(this.scene)); + } + + createGameEntities() { + // Create player + this.playerEntity = this.entityFactory.createPlayer(); + + // Create coins + for (let i = 0; i < 10; i++) { + const coin = this.entityFactory.createCoin(this.coins.length); + this.coins.push(coin); + } + + // Create obstacles + for (let i = 0; i < 8; i++) { + const obstacle = this.entityFactory.createObstacle(); + this.obstacles.push(obstacle); + } + } + + handleCollision(entity1, entity2, layer1, layer2) { + if (!this.gameActive) return; + + // Player-Coin collision + if ((layer1 === 'player' && layer2 === 'coin') || (layer1 === 'coin' && layer2 === 'player')) { + const coinEntity = layer1 === 'coin' ? entity1 : entity2; + this.collectCoin(coinEntity); + } + + // Player-Obstacle collision + if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) { + const playerEntity = layer1 === 'player' ? entity1 : entity2; + const obstacleEntity = layer1 === 'obstacle' ? entity1 : entity2; + this.handleObstacleCollision(playerEntity, obstacleEntity); + } + } + + collectCoin(coinEntity) { + // Remove coin + this.entityFactory.destroyEntity(coinEntity); + const index = this.coins.indexOf(coinEntity); + if (index > -1) { + this.coins.splice(index, 1); + } + + // Update score + this.score += 10; + this.updateUI(); + + // Spawn new coin + const newCoin = this.entityFactory.createCoin(this.coins.length); + this.coins.push(newCoin); + } + + handleObstacleCollision(playerEntity, obstacleEntity) { + const health = this.world.getComponent(playerEntity, Health); + const playerTransform = this.world.getComponent(playerEntity, Transform); + const obstacleTransform = this.world.getComponent(obstacleEntity, Transform); + + // Damage player + const isDead = health.damage(1); + + // Push player back + const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position); + pushDirection.y = 0; + pushDirection.normalize(); + playerTransform.position.add(pushDirection.multiplyScalar(0.3)); + playerTransform.position.y = 0.5; + + this.updateUI(); + + if (isDead) { + this.gameOver(); + } + } + + updateCamera() { + if (!this.playerEntity) return; + + const playerTransform = this.world.getComponent(this.playerEntity, Transform); + if (playerTransform) { + this.camera.position.x = playerTransform.position.x; + this.camera.position.z = playerTransform.position.z + 15; + this.camera.lookAt(playerTransform.position); + } + } + + updateUI() { + document.getElementById('score').textContent = this.score; + + const health = this.world.getComponent(this.playerEntity, Health); + if (health) { + document.getElementById('health').textContent = Math.max(0, health.currentHealth); + } + } + + gameOver() { + this.gameActive = false; + document.getElementById('finalScore').textContent = this.score; + document.getElementById('gameOver').style.display = 'block'; + } + + restart() { + // Clean up old entities + [...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin)); + [...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle)); + if (this.playerEntity) { + this.entityFactory.destroyEntity(this.playerEntity); + } + + this.coins = []; + this.obstacles = []; + + // Reset game state + this.score = 0; + this.gameActive = true; + this.lastTime = performance.now(); // Reset timer to prevent deltaTime spike + + // Recreate entities + this.createGameEntities(); + + // Hide game over screen + document.getElementById('gameOver').style.display = 'none'; + this.updateUI(); + } + + setupEventListeners() { + window.addEventListener('resize', () => this.onWindowResize()); + document.getElementById('restartBtn').addEventListener('click', () => this.restart()); + + // Toggle performance monitor with 'T' key + window.addEventListener('keydown', (e) => { + if (e.key.toLowerCase() === 't') { + this.togglePerformanceMonitor(); + } + }); + + // Shake detection for mobile + if (window.DeviceMotionEvent) { + window.addEventListener('devicemotion', (e) => this.handleDeviceMotion(e), false); + } + + // Pause game when tab loses focus + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.isPaused = true; + console.log('Game paused (tab hidden)'); + } else { + this.isPaused = false; + // Reset timer to prevent deltaTime spike + this.lastTime = performance.now(); + console.log('Game resumed'); + } + }); + + // Also handle window blur/focus as fallback + window.addEventListener('blur', () => { + this.isPaused = true; + }); + + window.addEventListener('focus', () => { + if (!document.hidden) { + this.isPaused = false; + this.lastTime = performance.now(); + } + }); + + // Load version + this.loadVersion(); + } + + /** + * Toggle performance monitor visibility + */ + togglePerformanceMonitor() { + this.perfMonitorVisible = !this.perfMonitorVisible; + const monitor = document.getElementById('perfMonitor'); + if (this.perfMonitorVisible) { + monitor.classList.add('visible'); + console.log('Performance monitor enabled'); + } else { + monitor.classList.remove('visible'); + console.log('Performance monitor disabled'); + } + } + + /** + * Handle device motion for shake detection + * @param {DeviceMotionEvent} event + */ + handleDeviceMotion(event) { + const acceleration = event.accelerationIncludingGravity; + if (!acceleration) return; + + const currentTime = Date.now(); + const timeDiff = currentTime - this.shakeDetection.lastShakeTime; + + if (timeDiff > 100) { // Check every 100ms + const { x = 0, y = 0, z = 0 } = acceleration; + + const deltaX = Math.abs(x - this.shakeDetection.lastX); + const deltaY = Math.abs(y - this.shakeDetection.lastY); + const deltaZ = Math.abs(z - this.shakeDetection.lastZ); + + if (deltaX + deltaY + deltaZ > this.shakeDetection.shakeThreshold) { + this.shakeDetection.shakeCount++; + + // Toggle after 2 shakes within 500ms + if (this.shakeDetection.shakeCount >= 2) { + this.togglePerformanceMonitor(); + this.shakeDetection.shakeCount = 0; + } + } else { + this.shakeDetection.shakeCount = 0; + } + + this.shakeDetection.lastX = x; + this.shakeDetection.lastY = y; + this.shakeDetection.lastZ = z; + this.shakeDetection.lastShakeTime = currentTime; + } + } + + loadVersion() { + fetch('/version.json') + .then(response => { + if (response.ok) { + return response.json(); + } + throw new Error('Version file not found'); + }) + .then(data => { + const versionElement = document.getElementById('versionNumber'); + if (versionElement && data.version) { + versionElement.textContent = data.version; + } + }) + .catch(error => { + console.debug('Version information not available:', error.message); + }); + } + + onWindowResize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + + /** + * Main game loop - calculates deltaTime and updates all systems + * @param {number} [currentTime] - Current timestamp from requestAnimationFrame + */ + animate(currentTime = performance.now()) { + requestAnimationFrame((time) => this.animate(time)); + + // If paused, skip updates but keep rendering + if (this.isPaused) { + this.renderer.render(this.scene, this.camera); + return; + } + + // Calculate deltaTime in seconds + const deltaTime = Math.min((currentTime - this.lastTime) / 1000, this.maxDeltaTime); + this.lastTime = currentTime; + + // Update performance monitor with smoothed values + if (this.perfMonitorVisible) { + // Calculate instant FPS from deltaTime + const instantFPS = 1 / deltaTime; + + // Smooth FPS using exponential moving average for stability + this.smoothedFPS = this.smoothedFPS * 0.9 + instantFPS * 0.1; + + // Update display every 100ms for real-time feel without flickering + if (currentTime - this.lastPerfUpdate >= 100) { + const frameTime = (deltaTime * 1000).toFixed(1); + const entityCount = this.world.entities.size; + + document.getElementById('fps').textContent = Math.round(this.smoothedFPS); + document.getElementById('frameTime').textContent = frameTime; + document.getElementById('entityCount').textContent = entityCount; + + this.lastPerfUpdate = currentTime; + } + } + + if (this.gameActive) { + // Update ECS world with actual deltaTime + this.world.update(deltaTime); + + // Update camera + this.updateCamera(); + } + + // Render scene + this.renderer.render(this.scene, this.camera); + } +} + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..a17285c --- /dev/null +++ b/src/main.js @@ -0,0 +1,18 @@ +import { Game } from './game/Game.js'; + +// Start the game immediately (script is at end of body, DOM is ready) +console.log('Starting 3D Coin Collector Game with ECS Architecture'); +console.log('Press "T" to toggle performance monitor (or shake device on mobile)'); + +// Wait a tick to ensure everything is loaded +setTimeout(() => { + try { + const game = new Game(); + + // Make game accessible from console for debugging + window.game = game; + } catch (error) { + console.error('Game initialization failed:', error); + } +}, 0); + diff --git a/src/systems/BoundarySystem.js b/src/systems/BoundarySystem.js new file mode 100644 index 0000000..4ee336e --- /dev/null +++ b/src/systems/BoundarySystem.js @@ -0,0 +1,43 @@ +import { System } from '../ecs/System.js'; +import { BoundaryConstrained } from '../components/Tags.js'; +import { Transform } from '../components/Transform.js'; +import { Velocity } from '../components/Velocity.js'; + +/** + * BoundarySystem - keeps entities within defined boundaries + */ +export class BoundarySystem extends System { + update(_deltaTime) { + const entities = this.getEntities(BoundaryConstrained, Transform); + + for (const entityId of entities) { + const boundary = this.getComponent(entityId, BoundaryConstrained); + const transform = this.getComponent(entityId, Transform); + const velocity = this.getComponent(entityId, Velocity); + + const limit = boundary.getBoundary(); + + // Clamp position to boundaries + const clamped = { + x: Math.max(-limit, Math.min(limit, transform.position.x)), + z: Math.max(-limit, Math.min(limit, transform.position.z)) + }; + + // If clamped, update position and stop velocity + if (clamped.x !== transform.position.x) { + transform.position.x = clamped.x; + if (velocity) { + velocity.velocity.x = 0; + } + } + + if (clamped.z !== transform.position.z) { + transform.position.z = clamped.z; + if (velocity) { + velocity.velocity.z = 0; + } + } + } + } +} + diff --git a/src/systems/CoinSystem.js b/src/systems/CoinSystem.js new file mode 100644 index 0000000..c697cf9 --- /dev/null +++ b/src/systems/CoinSystem.js @@ -0,0 +1,33 @@ +import { System } from '../ecs/System.js'; +import { CoinTag } from '../components/Tags.js'; +import { Transform } from '../components/Transform.js'; + +/** + * CoinSystem - handles coin-specific behavior (rotation and bobbing) + */ +export class CoinSystem extends System { + constructor() { + super(); + this.elapsedTime = 0; + } + + update(deltaTime) { + this.elapsedTime += deltaTime; + const coins = this.getEntities(CoinTag, Transform); + + for (const entityId of coins) { + const coinTag = this.getComponent(entityId, CoinTag); + const transform = this.getComponent(entityId, Transform); + + // Rotate coin (frame-rate independent) + // rotationSpeed is radians per second + transform.rotation.y += coinTag.rotationSpeed * 60 * deltaTime; + + // Bob up and down using accumulated time + const baseY = 0.5; + const timeScale = 3; // Speed multiplier (3 matches original 0.003 * 1000ms) + transform.position.y = baseY + Math.sin(this.elapsedTime * timeScale + coinTag.index) * coinTag.bobAmount; + } + } +} + diff --git a/src/systems/CollisionSystem.js b/src/systems/CollisionSystem.js new file mode 100644 index 0000000..15441c4 --- /dev/null +++ b/src/systems/CollisionSystem.js @@ -0,0 +1,80 @@ +import { System } from '../ecs/System.js'; +import { Transform } from '../components/Transform.js'; +import { Collidable } from '../components/Collidable.js'; + +/** + * CollisionSystem - detects and reports collisions + */ +export class CollisionSystem extends System { + constructor() { + super(); + this.collisionCallbacks = []; + } + + /** + * Register a callback for collision events + * @param {Function} callback - (entity1Id, entity2Id, layer1, layer2) => void + */ + onCollision(callback) { + this.collisionCallbacks.push(callback); + } + + /** + * Update collision detection + * Note: Entity list is captured at the start of update, but entities may be + * destroyed during collision callbacks, so we need defensive null checks. + * @param {number} _deltaTime - Time since last frame (unused - collision is instantaneous) + */ + update(_deltaTime) { + const entities = this.getEntities(Transform, Collidable); + + // Track checked pairs to avoid duplicate collision callbacks this frame + const checkedPairs = new Set(); + + // Check all pairs of collidable entities + for (let i = 0; i < entities.length; i++) { + for (let j = i + 1; j < entities.length; j++) { + const entity1 = entities[i]; + const entity2 = entities[j]; + + // Create unique pair ID + const pairId = `${Math.min(entity1, entity2)}-${Math.max(entity1, entity2)}`; + if (checkedPairs.has(pairId)) continue; + checkedPairs.add(pairId); + + const transform1 = this.getComponent(entity1, Transform); + const transform2 = this.getComponent(entity2, Transform); + const collidable1 = this.getComponent(entity1, Collidable); + const collidable2 = this.getComponent(entity2, Collidable); + + // Skip if entity was destroyed during collision handling this frame + if (!transform1 || !transform2 || !collidable1 || !collidable2) { + continue; + } + + // Calculate distance between entities + const distance = transform1.position.distanceTo(transform2.position); + + // Determine which radius to use (use non-player radius, or sum if both non-player) + let collisionRadius; + if (collidable1.layer === 'player') { + collisionRadius = collidable2.radius; // Use other entity's radius + } else if (collidable2.layer === 'player') { + collisionRadius = collidable1.radius; // Use other entity's radius + } else { + // Both are non-player, use sum of radii + collisionRadius = collidable1.radius + collidable2.radius; + } + + // Check if colliding + if (distance < collisionRadius) { + // Notify all collision callbacks + for (const callback of this.collisionCallbacks) { + callback(entity1, entity2, collidable1.layer, collidable2.layer); + } + } + } + } + } +} + diff --git a/src/systems/InputSystem.js b/src/systems/InputSystem.js new file mode 100644 index 0000000..b2511e8 --- /dev/null +++ b/src/systems/InputSystem.js @@ -0,0 +1,105 @@ +import { System } from '../ecs/System.js'; + +/** + * InputSystem - manages keyboard and touch input + */ +export class InputSystem extends System { + constructor() { + super(); + this.keys = {}; + this.touch = { + active: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + id: null + }; + } + + init() { + // Keyboard events + window.addEventListener('keydown', (e) => { + this.keys[e.key.toLowerCase()] = true; + }); + + window.addEventListener('keyup', (e) => { + this.keys[e.key.toLowerCase()] = false; + }); + + // Touch events + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false }); + canvas.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false }); + canvas.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false }); + canvas.addEventListener('touchcancel', (e) => this.handleTouchEnd(e), { passive: false }); + } + } + + handleTouchStart(e) { + e.preventDefault(); + if (this.touch.active) return; + + const touch = e.touches[0]; + const rect = e.target.getBoundingClientRect(); + + this.touch.startX = rect.left + rect.width / 2; + this.touch.startY = rect.top + rect.height / 2; + this.touch.currentX = touch.clientX; + this.touch.currentY = touch.clientY; + this.touch.id = touch.identifier; + this.touch.active = true; + } + + handleTouchMove(e) { + e.preventDefault(); + if (!this.touch.active) return; + + const touch = Array.from(e.touches).find(t => t.identifier === this.touch.id); + if (!touch) return; + + this.touch.currentX = touch.clientX; + this.touch.currentY = touch.clientY; + } + + handleTouchEnd(e) { + e.preventDefault(); + if (!this.touch.active) return; + + const touch = Array.from(e.changedTouches).find(t => t.identifier === this.touch.id); + if (!touch) return; + + this.touch.active = false; + this.touch.id = null; + } + + isKeyPressed(key) { + return this.keys[key] || this.keys['arrow' + key] || false; + } + + getTouchDirection() { + if (!this.touch.active) { + return { x: 0, y: 0 }; + } + + const deltaX = this.touch.currentX - this.touch.startX; + const deltaY = this.touch.currentY - this.touch.startY; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + const threshold = 10; + if (distance < threshold) { + return { x: 0, y: 0 }; + } + + const normalizedX = deltaX / distance; + const normalizedY = deltaY / distance; + + return { x: normalizedX, y: normalizedY }; + } + + update(_deltaTime) { + // Input is passive, just stores state for other systems to query + } +} + diff --git a/src/systems/MovementSystem.js b/src/systems/MovementSystem.js new file mode 100644 index 0000000..2e53d16 --- /dev/null +++ b/src/systems/MovementSystem.js @@ -0,0 +1,23 @@ +import { System } from '../ecs/System.js'; +import { Transform } from '../components/Transform.js'; +import { Velocity } from '../components/Velocity.js'; + +/** + * MovementSystem - applies velocity to transform (frame-rate independent) + */ +export class MovementSystem extends System { + update(deltaTime) { + const entities = this.getEntities(Transform, Velocity); + + for (const entityId of entities) { + const transform = this.getComponent(entityId, Transform); + const velocity = this.getComponent(entityId, Velocity); + + // Apply velocity scaled by deltaTime for frame-rate independence + // Velocity is in units per second, deltaTime converts to units per frame + const displacement = velocity.velocity.clone().multiplyScalar(deltaTime * 60); + transform.position.add(displacement); + } + } +} + diff --git a/src/systems/ObstacleSystem.js b/src/systems/ObstacleSystem.js new file mode 100644 index 0000000..92826a6 --- /dev/null +++ b/src/systems/ObstacleSystem.js @@ -0,0 +1,34 @@ +import { System } from '../ecs/System.js'; +import { ObstacleTag, BoundaryConstrained } from '../components/Tags.js'; +import { Transform } from '../components/Transform.js'; +import { Velocity } from '../components/Velocity.js'; + +/** + * ObstacleSystem - handles obstacle-specific behavior + */ +export class ObstacleSystem extends System { + update(_deltaTime) { + const obstacles = this.getEntities(ObstacleTag, Transform, Velocity, BoundaryConstrained); + + for (const entityId of obstacles) { + const transform = this.getComponent(entityId, Transform); + const velocity = this.getComponent(entityId, Velocity); + const boundary = this.getComponent(entityId, BoundaryConstrained); + + const boundaryLimit = boundary.getBoundary() - 1; + + // Bounce off boundaries + if (Math.abs(transform.position.x) > boundaryLimit) { + velocity.velocity.x *= -1; + // Clamp position + transform.position.x = Math.sign(transform.position.x) * boundaryLimit; + } + if (Math.abs(transform.position.z) > boundaryLimit) { + velocity.velocity.z *= -1; + // Clamp position + transform.position.z = Math.sign(transform.position.z) * boundaryLimit; + } + } + } +} + diff --git a/src/systems/PlayerControlSystem.js b/src/systems/PlayerControlSystem.js new file mode 100644 index 0000000..5c71304 --- /dev/null +++ b/src/systems/PlayerControlSystem.js @@ -0,0 +1,76 @@ +import { System } from '../ecs/System.js'; +import { PlayerTag } from '../components/Tags.js'; +import { Velocity } from '../components/Velocity.js'; +import { Transform } from '../components/Transform.js'; + +/** + * PlayerControlSystem - handles player input and applies to velocity + */ +export class PlayerControlSystem extends System { + constructor(inputSystem) { + super(); + this.inputSystem = inputSystem; + } + + update(deltaTime) { + const players = this.getEntities(PlayerTag, Velocity, Transform); + + for (const entityId of players) { + const velocity = this.getComponent(entityId, Velocity); + const transform = this.getComponent(entityId, Transform); + + // Calculate target velocity from input + const targetVelocity = new window.THREE.Vector3(0, 0, 0); + + // Keyboard input + if (this.inputSystem.isKeyPressed('w') || this.inputSystem.isKeyPressed('up')) { + targetVelocity.z -= velocity.maxSpeed; + } + if (this.inputSystem.isKeyPressed('s') || this.inputSystem.isKeyPressed('down')) { + targetVelocity.z += velocity.maxSpeed; + } + if (this.inputSystem.isKeyPressed('a') || this.inputSystem.isKeyPressed('left')) { + targetVelocity.x -= velocity.maxSpeed; + } + if (this.inputSystem.isKeyPressed('d') || this.inputSystem.isKeyPressed('right')) { + targetVelocity.x += velocity.maxSpeed; + } + + // Touch input + const touch = this.inputSystem.getTouchDirection(); + if (Math.abs(touch.x) > 0.3) { + targetVelocity.x = touch.x * velocity.maxSpeed; + } + if (Math.abs(touch.y) > 0.3) { + targetVelocity.z = touch.y * velocity.maxSpeed; + } + + // Apply smooth acceleration/deceleration + const isMoving = targetVelocity.length() > 0; + const accelRate = isMoving ? velocity.acceleration : velocity.deceleration; + + // Smooth interpolation for each axis + const velDiffX = targetVelocity.x - velocity.velocity.x; + const velDiffZ = targetVelocity.z - velocity.velocity.z; + + if (Math.abs(velDiffX) > 0.001) { + velocity.velocity.x += velDiffX * accelRate; + } else { + velocity.velocity.x = targetVelocity.x; + } + + if (Math.abs(velDiffZ) > 0.001) { + velocity.velocity.z += velDiffZ * accelRate; + } else { + velocity.velocity.z = targetVelocity.z; + } + + // Rotate player when moving (frame-rate independent) + // Original was 0.1 per frame at ~60fps = 6 rad/s + if (velocity.velocity.length() > 0.01) { + transform.rotation.y += 6 * deltaTime; + } + } + } +} + diff --git a/src/systems/RenderSystem.js b/src/systems/RenderSystem.js new file mode 100644 index 0000000..c3f89d1 --- /dev/null +++ b/src/systems/RenderSystem.js @@ -0,0 +1,28 @@ +import { System } from '../ecs/System.js'; +import { Transform } from '../components/Transform.js'; +import { MeshComponent } from '../components/MeshComponent.js'; + +/** + * RenderSystem - syncs Three.js mesh positions with Transform components + */ +export class RenderSystem extends System { + constructor(scene) { + super(); + this.scene = scene; + } + + update(_deltaTime) { + const entities = this.getEntities(Transform, MeshComponent); + + for (const entityId of entities) { + const transform = this.getComponent(entityId, Transform); + const meshComp = this.getComponent(entityId, MeshComponent); + + // Sync mesh transform with component + meshComp.mesh.position.copy(transform.position); + meshComp.mesh.rotation.copy(transform.rotation); + meshComp.mesh.scale.copy(transform.scale); + } + } +} +