From 7a5a6c6177b0fb5e35648fb88f366d0aab3b0deb Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 26 Nov 2025 16:52:51 -0500 Subject: [PATCH 1/3] chore: rrevent caching of version.json and add cache-busting to fetch request --- nginx.conf | 7 +++++++ src/game/Game.js | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 2c3229d..08a7ce9 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,6 +9,13 @@ server { try_files $uri $uri/ =404; } + # Prevent caching of version.json + location = /version.json { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + # Enable gzip compression gzip on; gzip_types text/html text/css application/javascript; diff --git a/src/game/Game.js b/src/game/Game.js index 76aef4c..0c96122 100644 --- a/src/game/Game.js +++ b/src/game/Game.js @@ -509,7 +509,11 @@ export class Game { } loadVersion() { - fetch('/version.json') + // Add cache-busting query parameter to ensure fresh version data + const cacheBuster = `?t=${Date.now()}`; + fetch(`/version.json${cacheBuster}`, { + cache: 'no-store' + }) .then(response => { if (response.ok) { return response.json(); From 7ea49a1c9e0b323a87398c4ac002a65f99ef6bc7 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 26 Nov 2025 17:00:48 -0500 Subject: [PATCH 2/3] chore: prevent caching of JavaScript and HTML files in nginx configuration --- nginx.conf | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 08a7ce9..eee0bb3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,13 +9,27 @@ server { try_files $uri $uri/ =404; } - # Prevent caching of version.json + # Prevent caching of JavaScript files and version.json + location ~* \.(js|mjs)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + add_header Vary "Accept-Encoding"; + } + location = /version.json { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires "0"; } + # Prevent caching of HTML + location ~* \.html$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + # Enable gzip compression gzip on; gzip_types text/html text/css application/javascript; From 4220e216e1ff5b30d933ba12a708769cbf30fc20 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 26 Nov 2025 17:01:30 -0500 Subject: [PATCH 3/3] feat: Introduce CoinType, ObstacleType, PowerUp components and systems - Added CoinType component to define different coin types and their score values. - Implemented ObstacleType component to manage various obstacle behaviors. - Created PowerUp component to handle power-up types and durations. - Integrated ParticleSystem for visual effects upon collecting coins and power-ups. - Updated EntityFactory to create coins, obstacles, and power-ups with respective types. - Enhanced Game class to manage power-up collection and effects, including score multipliers and health restoration. This update enriches gameplay by adding collectible items with distinct behaviors and effects, enhancing player interaction and strategy. --- src/components/CoinType.js | 39 +++++ src/components/ObstacleType.js | 25 +++ src/components/ParticleEmitter.js | 28 +++ src/components/PowerUp.js | 42 +++++ src/game/EntityFactory.js | 172 +++++++++++++++++-- src/game/Game.js | 265 +++++++++++++++++++++++++++-- src/game/GameConfig.js | 22 +++ src/systems/ObstacleSystem.js | 51 +++++- src/systems/ParticleSystem.js | 106 ++++++++++++ src/systems/PlayerControlSystem.js | 35 +++- src/systems/PowerUpSystem.js | 133 +++++++++++++++ 11 files changed, 885 insertions(+), 33 deletions(-) create mode 100644 src/components/CoinType.js create mode 100644 src/components/ObstacleType.js create mode 100644 src/components/ParticleEmitter.js create mode 100644 src/components/PowerUp.js create mode 100644 src/systems/ParticleSystem.js create mode 100644 src/systems/PowerUpSystem.js diff --git a/src/components/CoinType.js b/src/components/CoinType.js new file mode 100644 index 0000000..fc989e6 --- /dev/null +++ b/src/components/CoinType.js @@ -0,0 +1,39 @@ +/** + * CoinType component - defines coin value and type + */ +export class CoinType { + /** + * @param {string} type - Type of coin: 'gold', 'silver', 'diamond', 'health' + */ + constructor(type = 'gold') { + /** @type {string} Coin type */ + this.type = type; + + /** @type {number} Score value */ + this.scoreValue = this.getScoreValue(type); + + /** @type {number} Health restore amount (0 for non-health coins) */ + this.healthRestore = type === 'health' ? 10 : 0; + } + + /** + * Get score value based on coin type + * @param {string} type - Coin type + * @returns {number} Score value + */ + getScoreValue(type) { + switch (type) { + case 'diamond': + return 50; + case 'gold': + return 10; + case 'silver': + return 5; + case 'health': + return 0; // Health coins don't give score + default: + return 10; + } + } +} + diff --git a/src/components/ObstacleType.js b/src/components/ObstacleType.js new file mode 100644 index 0000000..c003531 --- /dev/null +++ b/src/components/ObstacleType.js @@ -0,0 +1,25 @@ +/** + * ObstacleType component - defines obstacle behavior type + */ +export class ObstacleType { + /** + * @param {string} type - Type of obstacle: 'normal', 'fast', 'chasing', 'spinning' + */ + constructor(type = 'normal') { + /** @type {string} Obstacle type */ + this.type = type; + + /** @type {number} Speed multiplier (1.0 = normal) */ + this.speedMultiplier = type === 'fast' ? 1.5 : 1.0; + + /** @type {boolean} Whether this obstacle chases the player */ + this.chases = type === 'chasing'; + + /** @type {boolean} Whether this obstacle spins */ + this.spins = type === 'spinning'; + + /** @type {number} Rotation speed multiplier */ + this.rotationSpeed = type === 'spinning' ? 3.0 : 1.0; + } +} + diff --git a/src/components/ParticleEmitter.js b/src/components/ParticleEmitter.js new file mode 100644 index 0000000..8afac89 --- /dev/null +++ b/src/components/ParticleEmitter.js @@ -0,0 +1,28 @@ +/** + * ParticleEmitter component - emits particles for visual effects + */ +export class ParticleEmitter { + /** + * @param {number} count - Number of particles to emit + * @param {number} lifetime - Lifetime of particles in seconds + * @param {number} color - Particle color (hex) + * @param {number} [speed=5] - Particle speed + */ + constructor(count, lifetime, color, speed = 5) { + /** @type {number} Number of particles */ + this.count = count; + + /** @type {number} Lifetime in seconds */ + this.lifetime = lifetime; + + /** @type {number} Particle color */ + this.color = color; + + /** @type {number} Particle speed */ + this.speed = speed; + + /** @type {boolean} Whether emitter is active */ + this.active = true; + } +} + diff --git a/src/components/PowerUp.js b/src/components/PowerUp.js new file mode 100644 index 0000000..adec6df --- /dev/null +++ b/src/components/PowerUp.js @@ -0,0 +1,42 @@ +/** + * PowerUp component - defines power-up type and duration + */ +export class PowerUp { + /** + * @param {string} type - Type of power-up: 'speed', 'shield', 'multiplier', 'magnet' + * @param {number} [duration=10] - Duration in seconds + */ + constructor(type, duration = 10) { + /** @type {string} Power-up type */ + this.type = type; + + /** @type {number} Duration in seconds */ + this.duration = duration; + + /** @type {number} Time remaining */ + this.timeRemaining = duration; + + /** @type {boolean} Whether power-up is active */ + this.active = true; + } + + /** + * Update power-up timer + * @param {number} deltaTime - Time since last frame in seconds + * @returns {boolean} True if still active + */ + update(deltaTime) { + if (!this.active) return false; + + this.timeRemaining -= deltaTime; + + if (this.timeRemaining <= 0) { + this.active = false; + this.timeRemaining = 0; + return false; + } + + return true; + } +} + diff --git a/src/game/EntityFactory.js b/src/game/EntityFactory.js index f398994..0b0c505 100644 --- a/src/game/EntityFactory.js +++ b/src/game/EntityFactory.js @@ -4,6 +4,9 @@ import { MeshComponent } from '../components/MeshComponent.js'; import { Collidable } from '../components/Collidable.js'; import { Health } from '../components/Health.js'; import { Invincibility } from '../components/Invincibility.js'; +import { ObstacleType } from '../components/ObstacleType.js'; +import { CoinType } from '../components/CoinType.js'; +import { PowerUp } from '../components/PowerUp.js'; import { PlayerTag, CoinTag, ObstacleTag, BoundaryConstrained } from '../components/Tags.js'; import { GameConfig } from './GameConfig.js'; @@ -65,19 +68,58 @@ export class EntityFactory { /** * Create a collectible coin entity * @param {number} [index=0] - Unique index for animation offset + * @param {string} [type] - Optional coin type ('gold', 'silver', 'diamond', 'health') * @returns {EntityId} The coin entity ID */ - createCoin(index = 0) { + createCoin(index = 0, type = null) { const entity = this.world.createEntity(); - // Create mesh - const geometry = new window.THREE.SphereGeometry(0.3, 16, 16); + // Determine coin type (weighted random if not specified) + let coinType = type; + if (!coinType) { + const rand = Math.random(); + if (rand < 0.6) { + coinType = 'gold'; // 60% gold + } else if (rand < 0.85) { + coinType = 'silver'; // 25% silver + } else if (rand < 0.95) { + coinType = 'diamond'; // 10% diamond + } else { + coinType = 'health'; // 5% health + } + } + + const typeComponent = new CoinType(coinType); + + // Create mesh with different colors/sizes based on type + let size = 0.3; + let color = 0xFFD700; // Gold + let emissive = 0xFFD700; + let emissiveIntensity = 0.3; + + if (coinType === 'silver') { + color = 0xC0C0C0; + emissive = 0xC0C0C0; + size = 0.25; + } else if (coinType === 'diamond') { + color = 0x00FFFF; + emissive = 0x00FFFF; + emissiveIntensity = 0.6; + size = 0.4; + } else if (coinType === 'health') { + color = 0x00FF00; + emissive = 0x00FF00; + emissiveIntensity = 0.4; + size = 0.35; + } + + const geometry = new window.THREE.SphereGeometry(size, 16, 16); const material = new window.THREE.MeshStandardMaterial({ - color: 0xFFD700, + color: color, metalness: 0.8, roughness: 0.2, - emissive: 0xFFD700, - emissiveIntensity: 0.3 + emissive: emissive, + emissiveIntensity: emissiveIntensity }); const mesh = new window.THREE.Mesh(geometry, material); mesh.castShadow = true; @@ -92,6 +134,7 @@ export class EntityFactory { 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, typeComponent); this.world.addComponent(entity, new CoinTag(index)); return entity; @@ -99,15 +142,42 @@ export class EntityFactory { /** * Create an obstacle entity + * @param {string} [type] - Optional obstacle type ('normal', 'fast', 'chasing', 'spinning') * @returns {EntityId} The obstacle entity ID */ - createObstacle() { + createObstacle(type = null) { const entity = this.world.createEntity(); - // Create mesh + // Determine obstacle type (weighted random if not specified) + let obstacleType = type; + if (!obstacleType) { + const rand = Math.random(); + if (rand < 0.5) { + obstacleType = 'normal'; + } else if (rand < 0.7) { + obstacleType = 'fast'; + } else if (rand < 0.85) { + obstacleType = 'chasing'; + } else { + obstacleType = 'spinning'; + } + } + + const typeComponent = new ObstacleType(obstacleType); + + // Create mesh with different colors based on type const geometry = new window.THREE.BoxGeometry(1.5, 2, 1.5); + let color = 0xFF4500; // Default orange-red + if (obstacleType === 'fast') { + color = 0xFF0000; // Red + } else if (obstacleType === 'chasing') { + color = 0x8B0000; // Dark red + } else if (obstacleType === 'spinning') { + color = 0xFF6347; // Tomato + } + const material = new window.THREE.MeshStandardMaterial({ - color: 0xFF4500, + color: color, metalness: 0.3, roughness: 0.7 }); @@ -123,11 +193,12 @@ export class EntityFactory { posZ = (Math.random() - 0.5) * (this.groundSize - 4); } while (Math.abs(posX) < 3 && Math.abs(posZ) < 3); - // Random velocity + // Base velocity (will be modified by ObstacleSystem for different types) + const baseSpeed = 0.05; const velocity = new Velocity( - (Math.random() - 0.5) * 0.05, + (Math.random() - 0.5) * baseSpeed * typeComponent.speedMultiplier, 0, - (Math.random() - 0.5) * 0.05 + (Math.random() - 0.5) * baseSpeed * typeComponent.speedMultiplier ); // Add components @@ -135,12 +206,89 @@ export class EntityFactory { 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, typeComponent); this.world.addComponent(entity, new ObstacleTag()); this.world.addComponent(entity, new BoundaryConstrained(this.groundSize)); return entity; } + /** + * Create a power-up entity + * @param {string} [type] - Optional power-up type ('speed', 'shield', 'multiplier', 'magnet') + * @returns {EntityId} The power-up entity ID + */ + createPowerUp(type = null) { + const entity = this.world.createEntity(); + + // Determine power-up type (random if not specified) + let powerUpType = type; + if (!powerUpType) { + const rand = Math.random(); + if (rand < 0.25) { + powerUpType = 'speed'; + } else if (rand < 0.5) { + powerUpType = 'shield'; + } else if (rand < 0.75) { + powerUpType = 'multiplier'; + } else { + powerUpType = 'magnet'; + } + } + + // Get duration based on type + let duration = 10; + let color = 0x00FF00; + let size = 0.4; + + switch (powerUpType) { + case 'speed': + duration = GameConfig.POWERUP_DURATION_SPEED; + color = 0x00FFFF; // Cyan + break; + case 'shield': + duration = GameConfig.POWERUP_DURATION_SHIELD; + color = 0x0000FF; // Blue + break; + case 'multiplier': + duration = GameConfig.POWERUP_DURATION_MULTIPLIER; + color = 0xFF00FF; // Magenta + break; + case 'magnet': + duration = GameConfig.POWERUP_DURATION_MAGNET; + color = 0xFFFF00; // Yellow + break; + } + + const powerUpComponent = new PowerUp(powerUpType, duration); + + // Create mesh + const geometry = new window.THREE.OctahedronGeometry(size, 0); + const material = new window.THREE.MeshStandardMaterial({ + color: color, + metalness: 0.9, + roughness: 0.1, + emissive: color, + emissiveIntensity: 0.5 + }); + 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, 1, z)); + this.world.addComponent(entity, new MeshComponent(mesh)); + this.world.addComponent(entity, new Collidable(0.6, 'powerup')); + this.world.addComponent(entity, powerUpComponent); + + return entity; + } + /** * Remove entity and its mesh from scene * @param {EntityId} entityId - The entity to destroy diff --git a/src/game/Game.js b/src/game/Game.js index 0c96122..897b46a 100644 --- a/src/game/Game.js +++ b/src/game/Game.js @@ -11,12 +11,16 @@ import { CoinSystem } from '../systems/CoinSystem.js'; import { ObstacleSystem } from '../systems/ObstacleSystem.js'; import { CollisionSystem } from '../systems/CollisionSystem.js'; import { InvincibilitySystem } from '../systems/InvincibilitySystem.js'; +import { ParticleSystem } from '../systems/ParticleSystem.js'; +import { PowerUpSystem } from '../systems/PowerUpSystem.js'; import { RenderSystem } from '../systems/RenderSystem.js'; // Components import { Transform } from '../components/Transform.js'; import { Health } from '../components/Health.js'; import { Invincibility } from '../components/Invincibility.js'; +import { CoinType } from '../components/CoinType.js'; +import { PowerUp } from '../components/PowerUp.js'; /** * Main Game class - manages the game loop and coordinates all systems. @@ -46,6 +50,9 @@ export class Game { /** @type {EntityId[]} Array of obstacle entity IDs */ this.obstacles = []; + + /** @type {EntityId[]} Array of power-up entity IDs */ + this.powerUps = []; /** @type {number} Last frame timestamp for deltaTime calculation */ this.lastTime = performance.now(); @@ -89,6 +96,23 @@ export class Game { /** @type {number} Time since last health regeneration */ this.healthRegenTimer = 0; + // Screen shake state + /** @type {number} Remaining screen shake time */ + this.screenShakeTime = 0; + + /** @type {import('three').Vector3} Original camera position offset */ + this.cameraBaseOffset = new window.THREE.Vector3(0, 10, 15); + + // Difficulty scaling state + /** @type {number} Game start time */ + this.gameStartTime = performance.now() / 1000; + + /** @type {number} Last score when obstacle was added */ + this.lastDifficultyScore = 0; + + /** @type {number} Last time when obstacle was added */ + this.lastDifficultyTime = 0; + this.init(); this.setupEventListeners(); this.animate(); @@ -186,13 +210,17 @@ export class Game { this.inputSystem = new InputSystem(); this.world.addSystem(this.inputSystem); - // Player control - this.world.addSystem(new PlayerControlSystem(this.inputSystem)); + // Player control (will set power-up system after it's created) + this.playerControlSystem = new PlayerControlSystem(this.inputSystem); + this.world.addSystem(this.playerControlSystem); // Movement and physics this.world.addSystem(new MovementSystem()); this.world.addSystem(new BoundarySystem()); - this.world.addSystem(new ObstacleSystem()); + + // Obstacle system (will set player entity after player is created) + this.obstacleSystem = new ObstacleSystem(); + this.world.addSystem(this.obstacleSystem); // Game-specific behavior this.world.addSystem(new CoinSystem()); @@ -200,6 +228,19 @@ export class Game { // Invincibility system (before collision to update state) this.world.addSystem(new InvincibilitySystem()); + // Particle system + this.particleSystem = new ParticleSystem(this.scene); + this.world.addSystem(this.particleSystem); + + // Power-up system (will set player entity after player is created) + this.powerUpSystem = new PowerUpSystem(); + this.world.addSystem(this.powerUpSystem); + + // Connect power-up system to player control system + if (this.playerControlSystem) { + this.playerControlSystem.setPowerUpSystem(this.powerUpSystem); + } + // Collision detection this.collisionSystem = new CollisionSystem(); this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => { @@ -214,6 +255,14 @@ export class Game { createGameEntities() { // Create player this.playerEntity = this.entityFactory.createPlayer(); + + // Set player entity in systems that need it + if (this.obstacleSystem) { + this.obstacleSystem.setPlayerEntity(this.playerEntity); + } + if (this.powerUpSystem) { + this.powerUpSystem.setPlayerEntity(this.playerEntity); + } // Create coins for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) { @@ -221,7 +270,7 @@ export class Game { this.coins.push(coin); } - // Create obstacles + // Create obstacles (mix of types) for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) { const obstacle = this.entityFactory.createObstacle(); this.obstacles.push(obstacle); @@ -237,6 +286,12 @@ export class Game { this.collectCoin(coinEntity); } + // Player-PowerUp collision + if ((layer1 === 'player' && layer2 === 'powerup') || (layer1 === 'powerup' && layer2 === 'player')) { + const powerUpEntity = layer1 === 'powerup' ? entity1 : entity2; + this.collectPowerUp(powerUpEntity); + } + // Player-Obstacle collision if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) { const playerEntity = layer1 === 'player' ? entity1 : entity2; @@ -246,6 +301,11 @@ export class Game { } collectCoin(coinEntity) { + // Get coin position and type before destroying + const coinTransform = this.world.getComponent(coinEntity, Transform); + const coinType = this.world.getComponent(coinEntity, CoinType); + const coinPosition = coinTransform ? coinTransform.position.clone() : null; + // Remove coin this.entityFactory.destroyEntity(coinEntity); const index = this.coins.indexOf(coinEntity); @@ -253,7 +313,42 @@ export class Game { this.coins.splice(index, 1); } - // Update combo system + // Determine particle color based on coin type + let particleColor = 0xFFD700; // Default gold + if (coinType) { + if (coinType.type === 'silver') { + particleColor = 0xC0C0C0; + } else if (coinType.type === 'diamond') { + particleColor = 0x00FFFF; + } else if (coinType.type === 'health') { + particleColor = 0x00FF00; + } + } + + // Emit particles for coin collection + if (coinPosition && this.particleSystem) { + this.particleSystem.emit( + coinPosition, + GameConfig.PARTICLE_COUNT_COIN, + particleColor, + 8 + ); + } + + // Handle health coin + if (coinType && coinType.type === 'health') { + const health = this.world.getComponent(this.playerEntity, Health); + if (health) { + health.heal(coinType.healthRestore); + this.updateUI(); + } + // Health coins don't contribute to combo or score + const newCoin = this.entityFactory.createCoin(this.coins.length); + this.coins.push(newCoin); + return; + } + + // Update combo system (only for score coins) const currentTime = performance.now() / 1000; // Convert to seconds const timeSinceLastCoin = currentTime - this.lastCoinTime; @@ -272,9 +367,10 @@ export class Game { this.lastCoinTime = currentTime; - // Calculate score with combo multiplier - const baseScore = GameConfig.COMBO_BASE_SCORE; - const scoreGain = baseScore * this.comboMultiplier; + // Calculate score with combo multiplier and power-up multiplier (use coin's base value) + const baseScore = coinType ? coinType.scoreValue : GameConfig.COMBO_BASE_SCORE; + const powerUpMultiplier = this.powerUpSystem ? this.powerUpSystem.scoreMultiplier : 1.0; + const scoreGain = baseScore * this.comboMultiplier * powerUpMultiplier; this.score += scoreGain; // Check for new high score @@ -285,12 +381,85 @@ export class Game { this.updateUI(); - // Spawn new coin - const newCoin = this.entityFactory.createCoin(this.coins.length); - this.coins.push(newCoin); + // Spawn new coin or power-up (based on chance) + if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) { + const powerUp = this.entityFactory.createPowerUp(); + this.powerUps.push(powerUp); + } else { + const newCoin = this.entityFactory.createCoin(this.coins.length); + this.coins.push(newCoin); + } + } + + collectPowerUp(powerUpEntity) { + // Get power-up position and type before destroying + const powerUpTransform = this.world.getComponent(powerUpEntity, Transform); + const powerUp = this.world.getComponent(powerUpEntity, PowerUp); + const powerUpPosition = powerUpTransform ? powerUpTransform.position.clone() : null; + + // Remove power-up + this.entityFactory.destroyEntity(powerUpEntity); + const index = this.powerUps.indexOf(powerUpEntity); + if (index > -1) { + this.powerUps.splice(index, 1); + } + + // Activate power-up effect + if (powerUp && this.powerUpSystem) { + this.powerUpSystem.activatePowerUp(powerUp.type, powerUp.duration); + + // Special handling for shield - activate invincibility + if (powerUp.type === 'shield') { + const invincibility = this.world.getComponent(this.playerEntity, Invincibility); + if (invincibility) { + invincibility.activate(powerUp.duration); + } + } + } + + // Emit particles + if (powerUpPosition && this.particleSystem) { + let particleColor = 0x00FF00; + if (powerUp) { + switch (powerUp.type) { + case 'speed': + particleColor = 0x00FFFF; + break; + case 'shield': + particleColor = 0x0000FF; + break; + case 'multiplier': + particleColor = 0xFF00FF; + break; + case 'magnet': + particleColor = 0xFFFF00; + break; + } + } + this.particleSystem.emit( + powerUpPosition, + GameConfig.PARTICLE_COUNT_COIN, + particleColor, + 10 + ); + } + + // Spawn new coin or power-up + if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) { + const newPowerUp = this.entityFactory.createPowerUp(); + this.powerUps.push(newPowerUp); + } else { + const newCoin = this.entityFactory.createCoin(this.coins.length); + this.coins.push(newCoin); + } } handleObstacleCollision(playerEntity, obstacleEntity) { + // Check if player has shield power-up active + if (this.powerUpSystem && this.powerUpSystem.isActive('shield')) { + return; // No damage if shield is active + } + // Check if player is invincible const invincibility = this.world.getComponent(playerEntity, Invincibility); if (invincibility && invincibility.getIsInvincible()) { @@ -309,6 +478,19 @@ export class Game { invincibility.activate(GameConfig.INVINCIBILITY_DURATION); } + // Screen shake on damage + this.screenShakeTime = GameConfig.SCREEN_SHAKE_DURATION; + + // Emit damage particles + if (this.particleSystem && playerTransform) { + this.particleSystem.emit( + playerTransform.position.clone().add(new window.THREE.Vector3(0, 0.5, 0)), + GameConfig.PARTICLE_COUNT_DAMAGE, + 0xFF0000, // Red color + 6 + ); + } + // Push player back const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position); pushDirection.y = 0; @@ -332,8 +514,20 @@ export class Game { 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; + // Base camera position + let cameraX = playerTransform.position.x; + let cameraZ = playerTransform.position.z + 15; + let cameraY = 10; + + // Apply screen shake + if (this.screenShakeTime > 0) { + const intensity = (this.screenShakeTime / GameConfig.SCREEN_SHAKE_DURATION) * GameConfig.SCREEN_SHAKE_INTENSITY; + cameraX += (Math.random() - 0.5) * intensity; + cameraY += (Math.random() - 0.5) * intensity; + cameraZ += (Math.random() - 0.5) * intensity; + } + + this.camera.position.set(cameraX, cameraY, cameraZ); this.camera.lookAt(playerTransform.position); } } @@ -383,12 +577,14 @@ export class Game { // Clean up old entities [...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin)); [...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle)); + [...this.powerUps].forEach(powerUp => this.entityFactory.destroyEntity(powerUp)); if (this.playerEntity) { this.entityFactory.destroyEntity(this.playerEntity); } this.coins = []; this.obstacles = []; + this.powerUps = []; // Reset game state this.score = 0; @@ -402,6 +598,14 @@ export class Game { // Reset health regeneration timer this.healthRegenTimer = 0; + + // Reset screen shake + this.screenShakeTime = 0; + + // Reset difficulty scaling + this.gameStartTime = performance.now() / 1000; + this.lastDifficultyScore = 0; + this.lastDifficultyTime = 0; // Recreate entities this.createGameEntities(); @@ -531,6 +735,33 @@ export class Game { }); } + /** + * Update difficulty scaling - spawn more obstacles over time + * @param {number} deltaTime - Time since last frame in seconds + */ + updateDifficulty(deltaTime) { + const currentTime = performance.now() / 1000; + const elapsedTime = currentTime - this.gameStartTime; + + // Check if we should add an obstacle based on score + const scoreDiff = this.score - this.lastDifficultyScore; + if (scoreDiff >= GameConfig.DIFFICULTY_SCORE_INTERVAL && + this.obstacles.length < GameConfig.MAX_OBSTACLES) { + const newObstacle = this.entityFactory.createObstacle(); + this.obstacles.push(newObstacle); + this.lastDifficultyScore = this.score; + } + + // Check if we should add an obstacle based on time + const timeDiff = currentTime - this.lastDifficultyTime; + if (timeDiff >= GameConfig.DIFFICULTY_TIME_INTERVAL && + this.obstacles.length < GameConfig.MAX_OBSTACLES) { + const newObstacle = this.entityFactory.createObstacle(); + this.obstacles.push(newObstacle); + this.lastDifficultyTime = currentTime; + } + } + /** * Load high score from localStorage * @returns {number} High score value @@ -602,6 +833,11 @@ export class Game { } if (this.gameActive) { + // Update screen shake + if (this.screenShakeTime > 0) { + this.screenShakeTime = Math.max(0, this.screenShakeTime - deltaTime); + } + // Update combo timer this.comboTimer = Math.max(0, this.comboTimer - deltaTime); if (this.comboTimer <= 0 && this.comboMultiplier > 1) { @@ -620,6 +856,9 @@ export class Game { this.healthRegenTimer = 0; } + // Difficulty scaling - add obstacles over time + this.updateDifficulty(deltaTime); + // Update ECS world with actual deltaTime this.world.update(deltaTime); diff --git a/src/game/GameConfig.js b/src/game/GameConfig.js index 3fd6de1..f9b0ee1 100644 --- a/src/game/GameConfig.js +++ b/src/game/GameConfig.js @@ -18,9 +18,31 @@ export const GameConfig = { INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage INVINCIBILITY_FLASH_RATE: 0.1, // Seconds between flash toggles + // Screen Shake + SCREEN_SHAKE_DURATION: 0.3, // Seconds of screen shake after damage + SCREEN_SHAKE_INTENSITY: 0.5, // Camera shake intensity + + // Particle Effects + PARTICLE_COUNT_COIN: 20, // Particles when collecting coin + PARTICLE_COUNT_DAMAGE: 15, // Particles when taking damage + PARTICLE_LIFETIME: 1.0, // Seconds particles live + + // Power-Ups + POWERUP_SPAWN_CHANCE: 0.3, // Chance to spawn power-up instead of coin (30%) + POWERUP_DURATION_SPEED: 10, // Speed boost duration + POWERUP_DURATION_SHIELD: 15, // Shield duration + POWERUP_DURATION_MULTIPLIER: 20, // Score multiplier duration + POWERUP_DURATION_MAGNET: 15, // Magnet duration + POWERUP_SPEED_MULTIPLIER: 1.5, // Speed boost multiplier + POWERUP_SCORE_MULTIPLIER: 2.0, // Score multiplier value + POWERUP_MAGNET_RANGE: 5.0, // Magnet attraction range + // Difficulty INITIAL_OBSTACLE_COUNT: 8, INITIAL_COIN_COUNT: 10, + DIFFICULTY_SCORE_INTERVAL: 100, // Add obstacle every N points + DIFFICULTY_TIME_INTERVAL: 30, // Add obstacle every N seconds + MAX_OBSTACLES: 20, // Maximum obstacles on screen // Arena GROUND_SIZE: 30, diff --git a/src/systems/ObstacleSystem.js b/src/systems/ObstacleSystem.js index 92826a6..263a93b 100644 --- a/src/systems/ObstacleSystem.js +++ b/src/systems/ObstacleSystem.js @@ -2,18 +2,67 @@ 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'; +import { ObstacleType } from '../components/ObstacleType.js'; +import { MeshComponent } from '../components/MeshComponent.js'; +import { PlayerTag } from '../components/Tags.js'; /** * ObstacleSystem - handles obstacle-specific behavior */ export class ObstacleSystem extends System { - update(_deltaTime) { + /** + * @param {EntityId} [playerEntity=null] - Player entity ID for chasing behavior + */ + constructor(playerEntity = null) { + super(); + /** @type {EntityId|null} */ + this.playerEntity = playerEntity; + } + + /** + * Set the player entity for chasing obstacles + * @param {EntityId} playerEntity + */ + setPlayerEntity(playerEntity) { + this.playerEntity = playerEntity; + } + + update(deltaTime) { const obstacles = this.getEntities(ObstacleTag, Transform, Velocity, BoundaryConstrained); + // Get player position if available + let playerPosition = null; + if (this.playerEntity) { + const playerTransform = this.getComponent(this.playerEntity, Transform); + if (playerTransform) { + playerPosition = playerTransform.position; + } + } + for (const entityId of obstacles) { const transform = this.getComponent(entityId, Transform); const velocity = this.getComponent(entityId, Velocity); const boundary = this.getComponent(entityId, BoundaryConstrained); + const obstacleType = this.getComponent(entityId, ObstacleType); + const meshComp = this.getComponent(entityId, MeshComponent); + + // Handle different obstacle types + if (obstacleType) { + // Chasing obstacles - move toward player + if (obstacleType.chases && playerPosition) { + const direction = playerPosition.clone().sub(transform.position); + direction.y = 0; + direction.normalize(); + const chaseSpeed = 0.08 * obstacleType.speedMultiplier; + velocity.velocity.x = direction.x * chaseSpeed; + velocity.velocity.z = direction.z * chaseSpeed; + } + + // Spinning obstacles - rotate faster + if (obstacleType.spins && meshComp) { + transform.rotation.y += 6 * obstacleType.rotationSpeed * deltaTime; + } + } const boundaryLimit = boundary.getBoundary() - 1; diff --git a/src/systems/ParticleSystem.js b/src/systems/ParticleSystem.js new file mode 100644 index 0000000..d1f2e7d --- /dev/null +++ b/src/systems/ParticleSystem.js @@ -0,0 +1,106 @@ +import { System } from '../ecs/System.js'; +import { Transform } from '../components/Transform.js'; +import { ParticleEmitter } from '../components/ParticleEmitter.js'; +import { GameConfig } from '../game/GameConfig.js'; + +/** + * ParticleSystem - manages particle effects for visual feedback + */ +export class ParticleSystem extends System { + constructor(scene) { + super(); + /** @type {import('three').Scene} */ + this.scene = scene; + + /** @type {Array<{mesh: import('three').Mesh, velocity: import('three').Vector3, lifetime: number, maxLifetime: number}>} */ + this.particles = []; + } + + /** + * Create particles at a position + * @param {import('three').Vector3} position - Position to emit from + * @param {number} count - Number of particles + * @param {number} color - Color (hex) + * @param {number} [speed=5] - Particle speed + */ + emit(position, count, color, speed = 5) { + for (let i = 0; i < count; i++) { + const geometry = new window.THREE.SphereGeometry(0.1, 8, 8); + const material = new window.THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 1.0 + }); + const mesh = new window.THREE.Mesh(geometry, material); + mesh.position.copy(position); + this.scene.add(mesh); + + // Random velocity direction + const velocity = new window.THREE.Vector3( + (Math.random() - 0.5) * speed, + Math.random() * speed * 0.5 + speed * 0.5, + (Math.random() - 0.5) * speed + ); + + this.particles.push({ + mesh: mesh, + velocity: velocity, + lifetime: GameConfig.PARTICLE_LIFETIME, + maxLifetime: GameConfig.PARTICLE_LIFETIME + }); + } + } + + /** + * Update particles + * @param {number} deltaTime - Time since last frame in seconds + */ + update(deltaTime) { + // Update existing particles + for (let i = this.particles.length - 1; i >= 0; i--) { + const particle = this.particles[i]; + + // Update position + particle.mesh.position.add( + particle.velocity.clone().multiplyScalar(deltaTime) + ); + + // Apply gravity + particle.velocity.y -= 9.8 * deltaTime; + + // Update lifetime + particle.lifetime -= deltaTime; + + // Fade out + const alpha = particle.lifetime / particle.maxLifetime; + particle.mesh.material.opacity = alpha; + + // Remove dead particles + if (particle.lifetime <= 0) { + this.scene.remove(particle.mesh); + particle.mesh.geometry.dispose(); + particle.mesh.material.dispose(); + this.particles.splice(i, 1); + } + } + + // Process particle emitters from entities + const entities = this.getEntities(Transform, ParticleEmitter); + for (const entityId of entities) { + const transform = this.getComponent(entityId, Transform); + const emitter = this.getComponent(entityId, ParticleEmitter); + + if (emitter && emitter.active) { + this.emit( + transform.position, + emitter.count, + emitter.color, + emitter.speed + ); + // Deactivate after emitting once + emitter.active = false; + } + } + } +} + diff --git a/src/systems/PlayerControlSystem.js b/src/systems/PlayerControlSystem.js index 5c71304..f6b75df 100644 --- a/src/systems/PlayerControlSystem.js +++ b/src/systems/PlayerControlSystem.js @@ -3,13 +3,30 @@ import { PlayerTag } from '../components/Tags.js'; import { Velocity } from '../components/Velocity.js'; import { Transform } from '../components/Transform.js'; +/** + * @typedef {import('./PowerUpSystem.js').PowerUpSystem} PowerUpSystem + */ + /** * PlayerControlSystem - handles player input and applies to velocity */ export class PlayerControlSystem extends System { - constructor(inputSystem) { + /** + * @param {InputSystem} inputSystem - Input system for reading controls + * @param {PowerUpSystem} [powerUpSystem=null] - Power-up system for speed multiplier + */ + constructor(inputSystem, powerUpSystem = null) { super(); this.inputSystem = inputSystem; + this.powerUpSystem = powerUpSystem; + } + + /** + * Set the power-up system + * @param {PowerUpSystem} powerUpSystem + */ + setPowerUpSystem(powerUpSystem) { + this.powerUpSystem = powerUpSystem; } update(deltaTime) { @@ -19,30 +36,34 @@ export class PlayerControlSystem extends System { const velocity = this.getComponent(entityId, Velocity); const transform = this.getComponent(entityId, Transform); + // Get speed multiplier from power-up system + const speedMultiplier = this.powerUpSystem ? this.powerUpSystem.speedMultiplier : 1.0; + const effectiveMaxSpeed = velocity.maxSpeed * speedMultiplier; + // 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; + targetVelocity.z -= effectiveMaxSpeed; } if (this.inputSystem.isKeyPressed('s') || this.inputSystem.isKeyPressed('down')) { - targetVelocity.z += velocity.maxSpeed; + targetVelocity.z += effectiveMaxSpeed; } if (this.inputSystem.isKeyPressed('a') || this.inputSystem.isKeyPressed('left')) { - targetVelocity.x -= velocity.maxSpeed; + targetVelocity.x -= effectiveMaxSpeed; } if (this.inputSystem.isKeyPressed('d') || this.inputSystem.isKeyPressed('right')) { - targetVelocity.x += velocity.maxSpeed; + targetVelocity.x += effectiveMaxSpeed; } // Touch input const touch = this.inputSystem.getTouchDirection(); if (Math.abs(touch.x) > 0.3) { - targetVelocity.x = touch.x * velocity.maxSpeed; + targetVelocity.x = touch.x * effectiveMaxSpeed; } if (Math.abs(touch.y) > 0.3) { - targetVelocity.z = touch.y * velocity.maxSpeed; + targetVelocity.z = touch.y * effectiveMaxSpeed; } // Apply smooth acceleration/deceleration diff --git a/src/systems/PowerUpSystem.js b/src/systems/PowerUpSystem.js new file mode 100644 index 0000000..5e4468a --- /dev/null +++ b/src/systems/PowerUpSystem.js @@ -0,0 +1,133 @@ +import { System } from '../ecs/System.js'; +import { PowerUp } from '../components/PowerUp.js'; +import { Transform } from '../components/Transform.js'; +import { Velocity } from '../components/Velocity.js'; +import { PlayerTag } from '../components/Tags.js'; +import { GameConfig } from '../game/GameConfig.js'; + +/** + * PowerUpSystem - manages active power-up effects on the player + */ +export class PowerUpSystem extends System { + /** + * @param {EntityId} [playerEntity=null] - Player entity ID + */ + constructor(playerEntity = null) { + super(); + /** @type {EntityId|null} */ + this.playerEntity = playerEntity; + + /** @type {Object} Active power-ups by type */ + this.activePowerUps = {}; + + /** @type {number} Base speed multiplier (1.0 = normal) */ + this.speedMultiplier = 1.0; + + /** @type {number} Score multiplier (1.0 = normal) */ + this.scoreMultiplier = 1.0; + + /** @type {boolean} Whether magnet is active */ + this.magnetActive = false; + } + + /** + * Set the player entity + * @param {EntityId} playerEntity + */ + setPlayerEntity(playerEntity) { + this.playerEntity = playerEntity; + } + + /** + * Activate a power-up + * @param {string} type - Power-up type + * @param {number} duration - Duration in seconds + */ + activatePowerUp(type, duration) { + this.activePowerUps[type] = new PowerUp(type, duration); + this.updateEffects(); + } + + /** + * Update active power-up effects + */ + updateEffects() { + // Reset all effects + this.speedMultiplier = 1.0; + this.scoreMultiplier = 1.0; + this.magnetActive = false; + + // Apply active power-ups + for (const [type, powerUp] of Object.entries(this.activePowerUps)) { + if (powerUp.active && powerUp.timeRemaining > 0) { + switch (type) { + case 'speed': + this.speedMultiplier = GameConfig.POWERUP_SPEED_MULTIPLIER; + break; + case 'multiplier': + this.scoreMultiplier = GameConfig.POWERUP_SCORE_MULTIPLIER; + break; + case 'magnet': + this.magnetActive = true; + break; + case 'shield': + // Shield is handled separately in collision system + break; + } + } + } + } + + /** + * Check if a power-up type is active + * @param {string} type - Power-up type + * @returns {boolean} + */ + isActive(type) { + const powerUp = this.activePowerUps[type]; + return powerUp && powerUp.active && powerUp.timeRemaining > 0; + } + + /** + * Update power-ups and apply magnet effect + * @param {number} deltaTime - Time since last frame in seconds + */ + update(deltaTime) { + // Update all active power-ups + for (const [type, powerUp] of Object.entries(this.activePowerUps)) { + if (!powerUp.update(deltaTime)) { + // Power-up expired + delete this.activePowerUps[type]; + } + } + + // Update effects + this.updateEffects(); + + // Apply magnet effect - attract coins to player + if (this.magnetActive && this.playerEntity) { + const playerTransform = this.getComponent(this.playerEntity, Transform); + if (!playerTransform) return; + + // Get all coins + const coins = this.getEntities(Transform); + for (const coinId of coins) { + const coinTransform = this.getComponent(coinId, Transform); + if (!coinTransform) continue; + + const distance = playerTransform.position.distanceTo(coinTransform.position); + + if (distance < GameConfig.POWERUP_MAGNET_RANGE && distance > 0.5) { + // Attract coin to player + const direction = playerTransform.position.clone().sub(coinTransform.position); + direction.y = 0; + direction.normalize(); + + const attractSpeed = 0.15; + coinTransform.position.add(direction.multiplyScalar(attractSpeed * deltaTime * 60)); + } + } + } + } +} +