Compare commits

..

3 commits

Author SHA1 Message Date
4220e216e1 feat: Introduce CoinType, ObstacleType, PowerUp components and systems
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 9s
- 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.
2025-11-26 17:01:30 -05:00
7ea49a1c9e chore: prevent caching of JavaScript and HTML files in nginx configuration 2025-11-26 17:00:48 -05:00
7a5a6c6177 chore: rrevent caching of version.json and add cache-busting to fetch request 2025-11-26 16:52:51 -05:00
12 changed files with 911 additions and 34 deletions

View file

@ -9,6 +9,27 @@ server {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
# 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 # Enable gzip compression
gzip on; gzip on;
gzip_types text/html text/css application/javascript; gzip_types text/html text/css application/javascript;

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

42
src/components/PowerUp.js Normal file
View file

@ -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;
}
}

View file

@ -4,6 +4,9 @@ import { MeshComponent } from '../components/MeshComponent.js';
import { Collidable } from '../components/Collidable.js'; import { Collidable } from '../components/Collidable.js';
import { Health } from '../components/Health.js'; import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.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 { PlayerTag, CoinTag, ObstacleTag, BoundaryConstrained } from '../components/Tags.js';
import { GameConfig } from './GameConfig.js'; import { GameConfig } from './GameConfig.js';
@ -65,19 +68,58 @@ export class EntityFactory {
/** /**
* Create a collectible coin entity * Create a collectible coin entity
* @param {number} [index=0] - Unique index for animation offset * @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 * @returns {EntityId} The coin entity ID
*/ */
createCoin(index = 0) { createCoin(index = 0, type = null) {
const entity = this.world.createEntity(); const entity = this.world.createEntity();
// Create mesh // Determine coin type (weighted random if not specified)
const geometry = new window.THREE.SphereGeometry(0.3, 16, 16); 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({ const material = new window.THREE.MeshStandardMaterial({
color: 0xFFD700, color: color,
metalness: 0.8, metalness: 0.8,
roughness: 0.2, roughness: 0.2,
emissive: 0xFFD700, emissive: emissive,
emissiveIntensity: 0.3 emissiveIntensity: emissiveIntensity
}); });
const mesh = new window.THREE.Mesh(geometry, material); const mesh = new window.THREE.Mesh(geometry, material);
mesh.castShadow = true; 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 Transform(x, 0.5, z));
this.world.addComponent(entity, new MeshComponent(mesh)); this.world.addComponent(entity, new MeshComponent(mesh));
this.world.addComponent(entity, new Collidable(0.8, 'coin')); this.world.addComponent(entity, new Collidable(0.8, 'coin'));
this.world.addComponent(entity, typeComponent);
this.world.addComponent(entity, new CoinTag(index)); this.world.addComponent(entity, new CoinTag(index));
return entity; return entity;
@ -99,15 +142,42 @@ export class EntityFactory {
/** /**
* Create an obstacle entity * Create an obstacle entity
* @param {string} [type] - Optional obstacle type ('normal', 'fast', 'chasing', 'spinning')
* @returns {EntityId} The obstacle entity ID * @returns {EntityId} The obstacle entity ID
*/ */
createObstacle() { createObstacle(type = null) {
const entity = this.world.createEntity(); 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); 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({ const material = new window.THREE.MeshStandardMaterial({
color: 0xFF4500, color: color,
metalness: 0.3, metalness: 0.3,
roughness: 0.7 roughness: 0.7
}); });
@ -123,11 +193,12 @@ export class EntityFactory {
posZ = (Math.random() - 0.5) * (this.groundSize - 4); posZ = (Math.random() - 0.5) * (this.groundSize - 4);
} while (Math.abs(posX) < 3 && Math.abs(posZ) < 3); } 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( const velocity = new Velocity(
(Math.random() - 0.5) * 0.05, (Math.random() - 0.5) * baseSpeed * typeComponent.speedMultiplier,
0, 0,
(Math.random() - 0.5) * 0.05 (Math.random() - 0.5) * baseSpeed * typeComponent.speedMultiplier
); );
// Add components // Add components
@ -135,12 +206,89 @@ export class EntityFactory {
this.world.addComponent(entity, velocity); this.world.addComponent(entity, velocity);
this.world.addComponent(entity, new MeshComponent(mesh)); this.world.addComponent(entity, new MeshComponent(mesh));
this.world.addComponent(entity, new Collidable(1.5, 'obstacle')); 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 ObstacleTag());
this.world.addComponent(entity, new BoundaryConstrained(this.groundSize)); this.world.addComponent(entity, new BoundaryConstrained(this.groundSize));
return entity; 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 * Remove entity and its mesh from scene
* @param {EntityId} entityId - The entity to destroy * @param {EntityId} entityId - The entity to destroy

View file

@ -11,12 +11,16 @@ import { CoinSystem } from '../systems/CoinSystem.js';
import { ObstacleSystem } from '../systems/ObstacleSystem.js'; import { ObstacleSystem } from '../systems/ObstacleSystem.js';
import { CollisionSystem } from '../systems/CollisionSystem.js'; import { CollisionSystem } from '../systems/CollisionSystem.js';
import { InvincibilitySystem } from '../systems/InvincibilitySystem.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'; import { RenderSystem } from '../systems/RenderSystem.js';
// Components // Components
import { Transform } from '../components/Transform.js'; import { Transform } from '../components/Transform.js';
import { Health } from '../components/Health.js'; import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.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. * 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 */ /** @type {EntityId[]} Array of obstacle entity IDs */
this.obstacles = []; this.obstacles = [];
/** @type {EntityId[]} Array of power-up entity IDs */
this.powerUps = [];
/** @type {number} Last frame timestamp for deltaTime calculation */ /** @type {number} Last frame timestamp for deltaTime calculation */
this.lastTime = performance.now(); this.lastTime = performance.now();
@ -89,6 +96,23 @@ export class Game {
/** @type {number} Time since last health regeneration */ /** @type {number} Time since last health regeneration */
this.healthRegenTimer = 0; 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.init();
this.setupEventListeners(); this.setupEventListeners();
this.animate(); this.animate();
@ -186,13 +210,17 @@ export class Game {
this.inputSystem = new InputSystem(); this.inputSystem = new InputSystem();
this.world.addSystem(this.inputSystem); this.world.addSystem(this.inputSystem);
// Player control // Player control (will set power-up system after it's created)
this.world.addSystem(new PlayerControlSystem(this.inputSystem)); this.playerControlSystem = new PlayerControlSystem(this.inputSystem);
this.world.addSystem(this.playerControlSystem);
// Movement and physics // Movement and physics
this.world.addSystem(new MovementSystem()); this.world.addSystem(new MovementSystem());
this.world.addSystem(new BoundarySystem()); 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 // Game-specific behavior
this.world.addSystem(new CoinSystem()); this.world.addSystem(new CoinSystem());
@ -200,6 +228,19 @@ export class Game {
// Invincibility system (before collision to update state) // Invincibility system (before collision to update state)
this.world.addSystem(new InvincibilitySystem()); 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 // Collision detection
this.collisionSystem = new CollisionSystem(); this.collisionSystem = new CollisionSystem();
this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => { this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => {
@ -214,6 +255,14 @@ export class Game {
createGameEntities() { createGameEntities() {
// Create player // Create player
this.playerEntity = this.entityFactory.createPlayer(); 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 // Create coins
for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) { for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) {
@ -221,7 +270,7 @@ export class Game {
this.coins.push(coin); this.coins.push(coin);
} }
// Create obstacles // Create obstacles (mix of types)
for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) { for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) {
const obstacle = this.entityFactory.createObstacle(); const obstacle = this.entityFactory.createObstacle();
this.obstacles.push(obstacle); this.obstacles.push(obstacle);
@ -237,6 +286,12 @@ export class Game {
this.collectCoin(coinEntity); 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 // Player-Obstacle collision
if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) { if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) {
const playerEntity = layer1 === 'player' ? entity1 : entity2; const playerEntity = layer1 === 'player' ? entity1 : entity2;
@ -246,6 +301,11 @@ export class Game {
} }
collectCoin(coinEntity) { 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 // Remove coin
this.entityFactory.destroyEntity(coinEntity); this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity); const index = this.coins.indexOf(coinEntity);
@ -253,7 +313,42 @@ export class Game {
this.coins.splice(index, 1); 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 currentTime = performance.now() / 1000; // Convert to seconds
const timeSinceLastCoin = currentTime - this.lastCoinTime; const timeSinceLastCoin = currentTime - this.lastCoinTime;
@ -272,9 +367,10 @@ export class Game {
this.lastCoinTime = currentTime; this.lastCoinTime = currentTime;
// Calculate score with combo multiplier // Calculate score with combo multiplier and power-up multiplier (use coin's base value)
const baseScore = GameConfig.COMBO_BASE_SCORE; const baseScore = coinType ? coinType.scoreValue : GameConfig.COMBO_BASE_SCORE;
const scoreGain = baseScore * this.comboMultiplier; const powerUpMultiplier = this.powerUpSystem ? this.powerUpSystem.scoreMultiplier : 1.0;
const scoreGain = baseScore * this.comboMultiplier * powerUpMultiplier;
this.score += scoreGain; this.score += scoreGain;
// Check for new high score // Check for new high score
@ -285,12 +381,85 @@ export class Game {
this.updateUI(); this.updateUI();
// Spawn new coin // Spawn new coin or power-up (based on chance)
const newCoin = this.entityFactory.createCoin(this.coins.length); if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) {
this.coins.push(newCoin); 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) { 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 // Check if player is invincible
const invincibility = this.world.getComponent(playerEntity, Invincibility); const invincibility = this.world.getComponent(playerEntity, Invincibility);
if (invincibility && invincibility.getIsInvincible()) { if (invincibility && invincibility.getIsInvincible()) {
@ -309,6 +478,19 @@ export class Game {
invincibility.activate(GameConfig.INVINCIBILITY_DURATION); 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 // Push player back
const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position); const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position);
pushDirection.y = 0; pushDirection.y = 0;
@ -332,8 +514,20 @@ export class Game {
const playerTransform = this.world.getComponent(this.playerEntity, Transform); const playerTransform = this.world.getComponent(this.playerEntity, Transform);
if (playerTransform) { if (playerTransform) {
this.camera.position.x = playerTransform.position.x; // Base camera position
this.camera.position.z = playerTransform.position.z + 15; 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); this.camera.lookAt(playerTransform.position);
} }
} }
@ -383,12 +577,14 @@ export class Game {
// Clean up old entities // Clean up old entities
[...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin)); [...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin));
[...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle)); [...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle));
[...this.powerUps].forEach(powerUp => this.entityFactory.destroyEntity(powerUp));
if (this.playerEntity) { if (this.playerEntity) {
this.entityFactory.destroyEntity(this.playerEntity); this.entityFactory.destroyEntity(this.playerEntity);
} }
this.coins = []; this.coins = [];
this.obstacles = []; this.obstacles = [];
this.powerUps = [];
// Reset game state // Reset game state
this.score = 0; this.score = 0;
@ -402,6 +598,14 @@ export class Game {
// Reset health regeneration timer // Reset health regeneration timer
this.healthRegenTimer = 0; 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 // Recreate entities
this.createGameEntities(); this.createGameEntities();
@ -509,7 +713,11 @@ export class Game {
} }
loadVersion() { 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 => { .then(response => {
if (response.ok) { if (response.ok) {
return response.json(); return response.json();
@ -527,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 * Load high score from localStorage
* @returns {number} High score value * @returns {number} High score value
@ -598,6 +833,11 @@ export class Game {
} }
if (this.gameActive) { if (this.gameActive) {
// Update screen shake
if (this.screenShakeTime > 0) {
this.screenShakeTime = Math.max(0, this.screenShakeTime - deltaTime);
}
// Update combo timer // Update combo timer
this.comboTimer = Math.max(0, this.comboTimer - deltaTime); this.comboTimer = Math.max(0, this.comboTimer - deltaTime);
if (this.comboTimer <= 0 && this.comboMultiplier > 1) { if (this.comboTimer <= 0 && this.comboMultiplier > 1) {
@ -616,6 +856,9 @@ export class Game {
this.healthRegenTimer = 0; this.healthRegenTimer = 0;
} }
// Difficulty scaling - add obstacles over time
this.updateDifficulty(deltaTime);
// Update ECS world with actual deltaTime // Update ECS world with actual deltaTime
this.world.update(deltaTime); this.world.update(deltaTime);

View file

@ -18,9 +18,31 @@ export const GameConfig = {
INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage
INVINCIBILITY_FLASH_RATE: 0.1, // Seconds between flash toggles 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 // Difficulty
INITIAL_OBSTACLE_COUNT: 8, INITIAL_OBSTACLE_COUNT: 8,
INITIAL_COIN_COUNT: 10, 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 // Arena
GROUND_SIZE: 30, GROUND_SIZE: 30,

View file

@ -2,18 +2,67 @@ import { System } from '../ecs/System.js';
import { ObstacleTag, BoundaryConstrained } from '../components/Tags.js'; import { ObstacleTag, BoundaryConstrained } from '../components/Tags.js';
import { Transform } from '../components/Transform.js'; import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.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 * ObstacleSystem - handles obstacle-specific behavior
*/ */
export class ObstacleSystem extends System { 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); 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) { for (const entityId of obstacles) {
const transform = this.getComponent(entityId, Transform); const transform = this.getComponent(entityId, Transform);
const velocity = this.getComponent(entityId, Velocity); const velocity = this.getComponent(entityId, Velocity);
const boundary = this.getComponent(entityId, BoundaryConstrained); 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; const boundaryLimit = boundary.getBoundary() - 1;

View file

@ -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;
}
}
}
}

View file

@ -3,13 +3,30 @@ import { PlayerTag } from '../components/Tags.js';
import { Velocity } from '../components/Velocity.js'; import { Velocity } from '../components/Velocity.js';
import { Transform } from '../components/Transform.js'; import { Transform } from '../components/Transform.js';
/**
* @typedef {import('./PowerUpSystem.js').PowerUpSystem} PowerUpSystem
*/
/** /**
* PlayerControlSystem - handles player input and applies to velocity * PlayerControlSystem - handles player input and applies to velocity
*/ */
export class PlayerControlSystem extends System { 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(); super();
this.inputSystem = inputSystem; this.inputSystem = inputSystem;
this.powerUpSystem = powerUpSystem;
}
/**
* Set the power-up system
* @param {PowerUpSystem} powerUpSystem
*/
setPowerUpSystem(powerUpSystem) {
this.powerUpSystem = powerUpSystem;
} }
update(deltaTime) { update(deltaTime) {
@ -19,30 +36,34 @@ export class PlayerControlSystem extends System {
const velocity = this.getComponent(entityId, Velocity); const velocity = this.getComponent(entityId, Velocity);
const transform = this.getComponent(entityId, Transform); 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 // Calculate target velocity from input
const targetVelocity = new window.THREE.Vector3(0, 0, 0); const targetVelocity = new window.THREE.Vector3(0, 0, 0);
// Keyboard input // Keyboard input
if (this.inputSystem.isKeyPressed('w') || this.inputSystem.isKeyPressed('up')) { 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')) { 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')) { 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')) { if (this.inputSystem.isKeyPressed('d') || this.inputSystem.isKeyPressed('right')) {
targetVelocity.x += velocity.maxSpeed; targetVelocity.x += effectiveMaxSpeed;
} }
// Touch input // Touch input
const touch = this.inputSystem.getTouchDirection(); const touch = this.inputSystem.getTouchDirection();
if (Math.abs(touch.x) > 0.3) { 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) { if (Math.abs(touch.y) > 0.3) {
targetVelocity.z = touch.y * velocity.maxSpeed; targetVelocity.z = touch.y * effectiveMaxSpeed;
} }
// Apply smooth acceleration/deceleration // Apply smooth acceleration/deceleration

View file

@ -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<string, PowerUp>} 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));
}
}
}
}
}