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.
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 17:01:30 -05:00
parent 7ea49a1c9e
commit 4220e216e1
11 changed files with 885 additions and 33 deletions

View file

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

View file

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

View file

@ -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,