Feature/Gameloop enhancements (#17)
Reviewed-on: #17 Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com> Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
This commit is contained in:
parent
77bd5bb395
commit
323f0be900
15 changed files with 1629 additions and 37 deletions
23
index.html
23
index.html
|
|
@ -96,6 +96,20 @@
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
#soundStatus {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 100px;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 100;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
#soundStatus:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
#perfMonitor {
|
#perfMonitor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
|
|
@ -125,6 +139,11 @@
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
#soundStatus {
|
||||||
|
top: 10px;
|
||||||
|
right: 60px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
#perfMonitor {
|
#perfMonitor {
|
||||||
top: 40px;
|
top: 40px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
|
|
@ -144,6 +163,8 @@
|
||||||
|
|
||||||
<div id="version">v<span id="versionNumber">-</span></div>
|
<div id="version">v<span id="versionNumber">-</span></div>
|
||||||
|
|
||||||
|
<div id="soundStatus" title="Sound ON (Press M to mute)">🔊</div>
|
||||||
|
|
||||||
<div id="perfMonitor">
|
<div id="perfMonitor">
|
||||||
<div><span class="label">FPS:</span> <span id="fps">60</span></div>
|
<div><span class="label">FPS:</span> <span id="fps">60</span></div>
|
||||||
<div><span class="label">Frame:</span> <span id="frameTime">16.7</span>ms</div>
|
<div><span class="label">Frame:</span> <span id="frameTime">16.7</span>ms</div>
|
||||||
|
|
@ -159,7 +180,7 @@
|
||||||
|
|
||||||
<div id="instructions">
|
<div id="instructions">
|
||||||
<p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p>
|
<p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p>
|
||||||
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" or shake device to toggle performance monitor</p>
|
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" to toggle performance monitor | Press "M" to toggle sound</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
|
|
||||||
21
nginx.conf
21
nginx.conf
|
|
@ -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;
|
||||||
|
|
|
||||||
39
src/components/CoinType.js
Normal file
39
src/components/CoinType.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
25
src/components/ObstacleType.js
Normal file
25
src/components/ObstacleType.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
28
src/components/ParticleEmitter.js
Normal file
28
src/components/ParticleEmitter.js
Normal 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
42
src/components/PowerUp.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
21
src/components/SoundEvent.js
Normal file
21
src/components/SoundEvent.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* SoundEvent component - represents a sound that should be played
|
||||||
|
* Entities can add this component to trigger sounds, which SoundSystem will process
|
||||||
|
*/
|
||||||
|
export class SoundEvent {
|
||||||
|
/**
|
||||||
|
* @param {string} type - Sound type: 'coin', 'damage', 'powerup', 'combo', 'health', 'gameover', 'highscore'
|
||||||
|
* @param {number} [value] - Optional value (e.g., combo multiplier)
|
||||||
|
*/
|
||||||
|
constructor(type, value = null) {
|
||||||
|
/** @type {string} Sound event type */
|
||||||
|
this.type = type;
|
||||||
|
|
||||||
|
/** @type {number|null} Optional value for the sound (e.g., combo multiplier) */
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
/** @type {boolean} Whether this event has been processed */
|
||||||
|
this.processed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
362
src/game/Game.js
362
src/game/Game.js
|
|
@ -11,12 +11,18 @@ 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 { SoundSystem } from '../systems/SoundSystem.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';
|
||||||
|
import { SoundEvent } from '../components/SoundEvent.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 +52,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 +98,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 +212,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 +230,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) => {
|
||||||
|
|
@ -207,6 +250,10 @@ export class Game {
|
||||||
});
|
});
|
||||||
this.world.addSystem(this.collisionSystem);
|
this.world.addSystem(this.collisionSystem);
|
||||||
|
|
||||||
|
// Sound system (processes SoundEvent components)
|
||||||
|
this.soundSystem = new SoundSystem();
|
||||||
|
this.world.addSystem(this.soundSystem);
|
||||||
|
|
||||||
// Rendering (must be last to sync transforms)
|
// Rendering (must be last to sync transforms)
|
||||||
this.world.addSystem(new RenderSystem(this.scene));
|
this.world.addSystem(new RenderSystem(this.scene));
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +261,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 +276,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 +292,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 +307,19 @@ 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;
|
||||||
|
|
||||||
|
// Emit sound event BEFORE destroying the entity
|
||||||
|
const soundEntity = this.world.createEntity();
|
||||||
|
if (coinType && coinType.type === 'health') {
|
||||||
|
this.world.addComponent(soundEntity, new SoundEvent('health'));
|
||||||
|
} else {
|
||||||
|
this.world.addComponent(soundEntity, new SoundEvent('coin'));
|
||||||
|
}
|
||||||
|
|
||||||
// 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,17 +327,60 @@ 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;
|
||||||
|
|
||||||
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
|
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
|
||||||
// Maintain combo
|
// Maintain combo
|
||||||
|
const oldMultiplier = this.comboMultiplier;
|
||||||
this.comboMultiplier = Math.min(
|
this.comboMultiplier = Math.min(
|
||||||
this.comboMultiplier + 1,
|
this.comboMultiplier + 1,
|
||||||
GameConfig.COMBO_MULTIPLIER_MAX
|
GameConfig.COMBO_MULTIPLIER_MAX
|
||||||
);
|
);
|
||||||
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
|
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
|
||||||
|
|
||||||
|
// Emit combo sound event if multiplier increased
|
||||||
|
if (this.comboMultiplier > oldMultiplier) {
|
||||||
|
// Create temporary entity for combo sound
|
||||||
|
const comboEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(comboEntity, new SoundEvent('combo', this.comboMultiplier));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset combo
|
// Reset combo
|
||||||
this.comboMultiplier = 1;
|
this.comboMultiplier = 1;
|
||||||
|
|
@ -272,25 +389,106 @@ 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
|
||||||
if (this.score > this.highScore) {
|
if (this.score > this.highScore) {
|
||||||
this.highScore = this.score;
|
this.highScore = this.score;
|
||||||
this.saveHighScore(this.highScore);
|
this.saveHighScore(this.highScore);
|
||||||
|
// Emit high score sound event
|
||||||
|
const highScoreEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(highScoreEntity, new SoundEvent('highscore'));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 power-up sound event
|
||||||
|
const powerUpSoundEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(powerUpSoundEntity, new SoundEvent('powerup'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +507,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;
|
||||||
|
|
@ -320,6 +531,10 @@ export class Game {
|
||||||
this.comboMultiplier = 1;
|
this.comboMultiplier = 1;
|
||||||
this.comboTimer = 0;
|
this.comboTimer = 0;
|
||||||
|
|
||||||
|
// Emit damage sound event
|
||||||
|
const damageSoundEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(damageSoundEntity, new SoundEvent('damage'));
|
||||||
|
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
|
|
||||||
if (isDead) {
|
if (isDead) {
|
||||||
|
|
@ -332,8 +547,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -376,6 +603,10 @@ export class Game {
|
||||||
newHighScoreEl.style.display = 'none';
|
newHighScoreEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit game over sound event
|
||||||
|
const gameOverEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(gameOverEntity, new SoundEvent('gameover'));
|
||||||
|
|
||||||
document.getElementById('gameOver').style.display = 'block';
|
document.getElementById('gameOver').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,12 +614,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 +635,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();
|
||||||
|
|
@ -420,6 +661,10 @@ export class Game {
|
||||||
if (e.key.toLowerCase() === 't') {
|
if (e.key.toLowerCase() === 't') {
|
||||||
this.togglePerformanceMonitor();
|
this.togglePerformanceMonitor();
|
||||||
}
|
}
|
||||||
|
// Toggle sound with 'M' key
|
||||||
|
if (e.key.toLowerCase() === 'm') {
|
||||||
|
this.toggleSound();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shake detection for mobile
|
// Shake detection for mobile
|
||||||
|
|
@ -454,6 +699,28 @@ export class Game {
|
||||||
|
|
||||||
// Load version
|
// Load version
|
||||||
this.loadVersion();
|
this.loadVersion();
|
||||||
|
|
||||||
|
// Setup sound status click handler
|
||||||
|
const soundStatusEl = document.getElementById('soundStatus');
|
||||||
|
if (soundStatusEl) {
|
||||||
|
soundStatusEl.addEventListener('click', async () => {
|
||||||
|
this.toggleSound();
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sound system on first click anywhere
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
if (this.soundSystem && this.soundSystem.isEnabled()) {
|
||||||
|
console.log('Sound system ready, state:', this.soundSystem.getState());
|
||||||
|
// Start background music after initialization
|
||||||
|
if (this.soundSystem.isEnabled()) {
|
||||||
|
this.soundSystem.startBackgroundMusic().catch(err => {
|
||||||
|
console.warn('Failed to start background music:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -471,6 +738,31 @@ export class Game {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle sound on/off
|
||||||
|
*/
|
||||||
|
toggleSound() {
|
||||||
|
if (this.soundSystem) {
|
||||||
|
const enabled = this.soundSystem.toggle();
|
||||||
|
const soundStatusEl = document.getElementById('soundStatus');
|
||||||
|
if (soundStatusEl) {
|
||||||
|
soundStatusEl.textContent = enabled ? '🔊' : '🔇';
|
||||||
|
soundStatusEl.title = enabled ? 'Sound ON (Press M to mute)' : 'Sound OFF (Press M to unmute)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start/stop background music based on sound state
|
||||||
|
if (enabled) {
|
||||||
|
this.soundSystem.startBackgroundMusic().catch(err => {
|
||||||
|
console.warn('Failed to start background music:', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.soundSystem.stopBackgroundMusic();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sound', enabled ? 'enabled' : 'disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle device motion for shake detection
|
* Handle device motion for shake detection
|
||||||
* @param {DeviceMotionEvent} event
|
* @param {DeviceMotionEvent} event
|
||||||
|
|
@ -509,7 +801,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 +823,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 +921,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) {
|
||||||
|
|
@ -611,11 +939,17 @@ export class Game {
|
||||||
const health = this.world.getComponent(this.playerEntity, Health);
|
const health = this.world.getComponent(this.playerEntity, Health);
|
||||||
if (health && health.currentHealth < health.maxHealth) {
|
if (health && health.currentHealth < health.maxHealth) {
|
||||||
health.heal(GameConfig.HEALTH_REGEN_AMOUNT);
|
health.heal(GameConfig.HEALTH_REGEN_AMOUNT);
|
||||||
|
// Emit health regen sound event
|
||||||
|
const healthRegenEntity = this.world.createEntity();
|
||||||
|
this.world.addComponent(healthRegenEntity, new SoundEvent('health'));
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
export const GameConfig = {
|
export const GameConfig = {
|
||||||
// Gameplay
|
// Gameplay
|
||||||
OBSTACLE_DAMAGE: 3, // Increased from 1 to balance invincibility frames and health regen
|
OBSTACLE_DAMAGE: 5, // Increased from 3 - obstacles should be more dangerous
|
||||||
COIN_SCORE: 10,
|
COIN_SCORE: 10,
|
||||||
HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds
|
HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds
|
||||||
HEALTH_REGEN_AMOUNT: 1,
|
HEALTH_REGEN_AMOUNT: 1,
|
||||||
|
|
@ -15,12 +15,34 @@ export const GameConfig = {
|
||||||
COMBO_BASE_SCORE: 10, // Base score per coin
|
COMBO_BASE_SCORE: 10, // Base score per coin
|
||||||
|
|
||||||
// Invincibility
|
// Invincibility
|
||||||
INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage
|
INVINCIBILITY_DURATION: 0.8, // Reduced from 1.5 to 0.8 seconds - shorter invincibility window
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
106
src/systems/ParticleSystem.js
Normal file
106
src/systems/ParticleSystem.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
131
src/systems/PowerUpSystem.js
Normal file
131
src/systems/PowerUpSystem.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { System } from '../ecs/System.js';
|
||||||
|
import { PowerUp } from '../components/PowerUp.js';
|
||||||
|
import { Transform } from '../components/Transform.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
584
src/systems/SoundSystem.js
Normal file
584
src/systems/SoundSystem.js
Normal file
|
|
@ -0,0 +1,584 @@
|
||||||
|
import { System } from '../ecs/System.js';
|
||||||
|
import { SoundEvent } from '../components/SoundEvent.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SoundSystem - Lightweight sound system using Web Audio API (no external dependencies)
|
||||||
|
* Generates simple procedural sound effects
|
||||||
|
* Follows ECS pattern: processes SoundEvent components from entities
|
||||||
|
*/
|
||||||
|
export class SoundSystem extends System {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
/** @type {AudioContext|null} Web Audio API context */
|
||||||
|
this.audioContext = null;
|
||||||
|
|
||||||
|
/** @type {boolean} Whether sound is enabled */
|
||||||
|
this.enabled = true;
|
||||||
|
|
||||||
|
/** @type {boolean} Whether audio context is initialized */
|
||||||
|
this.initialized = false;
|
||||||
|
|
||||||
|
/** @type {OscillatorNode[]} Active background music oscillators */
|
||||||
|
this.bgMusicOscillators = [];
|
||||||
|
|
||||||
|
/** @type {GainNode|null} Background music gain node */
|
||||||
|
this.bgMusicGain = null;
|
||||||
|
|
||||||
|
/** @type {boolean} Whether background music is playing */
|
||||||
|
this.bgMusicPlaying = false;
|
||||||
|
|
||||||
|
/** @type {number} Background music volume (0-1) */
|
||||||
|
this.bgMusicVolume = 0.25; // Increased from 0.15 for better audibility
|
||||||
|
|
||||||
|
// Initialize on first user interaction (browser requirement)
|
||||||
|
this.initOnInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when system is added to world
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
if (this.enabled && !this.bgMusicPlaying) {
|
||||||
|
this.startBackgroundMusic().catch(err => {
|
||||||
|
console.warn('Failed to auto-start background music:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update - processes SoundEvent components
|
||||||
|
* @param {number} _deltaTime - Time since last frame (not used for sound events)
|
||||||
|
*/
|
||||||
|
update(_deltaTime) {
|
||||||
|
// Process all SoundEvent components
|
||||||
|
const soundEvents = this.getEntities(SoundEvent);
|
||||||
|
|
||||||
|
for (const entityId of soundEvents) {
|
||||||
|
const soundEvent = this.getComponent(entityId, SoundEvent);
|
||||||
|
|
||||||
|
if (soundEvent && !soundEvent.processed) {
|
||||||
|
// Play the appropriate sound based on type
|
||||||
|
this.playSoundEvent(soundEvent);
|
||||||
|
|
||||||
|
// Mark as processed and remove component
|
||||||
|
soundEvent.processed = true;
|
||||||
|
this.world.removeComponent(entityId, SoundEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sound event
|
||||||
|
* @param {SoundEvent} soundEvent - The sound event to play
|
||||||
|
*/
|
||||||
|
playSoundEvent(soundEvent) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
// All sound methods are async, handle them properly
|
||||||
|
const playSound = async (soundMethod) => {
|
||||||
|
try {
|
||||||
|
await soundMethod();
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore sound errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (soundEvent.type) {
|
||||||
|
case 'coin':
|
||||||
|
playSound(() => this.playCoinCollect());
|
||||||
|
break;
|
||||||
|
case 'damage':
|
||||||
|
playSound(() => this.playDamage());
|
||||||
|
break;
|
||||||
|
case 'powerup':
|
||||||
|
playSound(() => this.playPowerUp());
|
||||||
|
break;
|
||||||
|
case 'combo':
|
||||||
|
playSound(() => this.playCombo(soundEvent.value || 1));
|
||||||
|
break;
|
||||||
|
case 'health':
|
||||||
|
playSound(() => this.playHealthRegen());
|
||||||
|
break;
|
||||||
|
case 'gameover':
|
||||||
|
playSound(() => this.playGameOver());
|
||||||
|
break;
|
||||||
|
case 'highscore':
|
||||||
|
playSound(() => this.playHighScore());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown sound event type:', soundEvent.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize audio context on first user interaction
|
||||||
|
*/
|
||||||
|
initOnInteraction() {
|
||||||
|
const initAudio = async () => {
|
||||||
|
if (!this.initialized) {
|
||||||
|
try {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
// Resume if suspended
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
await this.audioContext.resume();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Web Audio API not supported:', error);
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize on any user interaction
|
||||||
|
['click', 'touchstart', 'keydown', 'mousedown'].forEach(event => {
|
||||||
|
document.addEventListener(event, initAudio, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure audio context is initialized and resumed
|
||||||
|
* @returns {boolean} True if audio is ready (synchronous check)
|
||||||
|
*/
|
||||||
|
ensureInitialized() {
|
||||||
|
if (!this.enabled) return false;
|
||||||
|
|
||||||
|
// Create audio context if it doesn't exist
|
||||||
|
if (!this.audioContext) {
|
||||||
|
try {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.initialized = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to create audio context:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resume if suspended (non-blocking - will resume on next interaction if needed)
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume().then(() => {
|
||||||
|
console.log('Audio context resumed');
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('Failed to resume audio context:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.initialized && this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a tone
|
||||||
|
* @param {number} frequency - Frequency in Hz
|
||||||
|
* @param {number} duration - Duration in seconds
|
||||||
|
* @param {string} type - Waveform type ('sine', 'square', 'sawtooth', 'triangle')
|
||||||
|
* @param {number} [volume=0.3] - Volume (0-1)
|
||||||
|
*/
|
||||||
|
async playTone(frequency, duration, type = 'sine', volume = 0.3) {
|
||||||
|
const ready = this.ensureInitialized();
|
||||||
|
if (!ready || !this.audioContext) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oscillator = this.audioContext.createOscillator();
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.value = frequency;
|
||||||
|
oscillator.type = type;
|
||||||
|
|
||||||
|
// Envelope: quick attack, sustain, quick release
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
gainNode.gain.setValueAtTime(0, now);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(volume, now + 0.01);
|
||||||
|
gainNode.gain.setValueAtTime(volume, now + duration * 0.7);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||||||
|
|
||||||
|
oscillator.start(now);
|
||||||
|
oscillator.stop(now + duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to play tone:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a coin collection sound - metallic clinking like real coins
|
||||||
|
*/
|
||||||
|
async playCoinCollect() {
|
||||||
|
// Metallic clink using multiple harmonics via playTone
|
||||||
|
// Main coin "clink" - high frequency
|
||||||
|
await this.playTone(800, 0.15, 'sine', 0.3);
|
||||||
|
await this.playTone(1200, 0.15, 'sine', 0.2);
|
||||||
|
await this.playTone(1600, 0.15, 'sine', 0.1);
|
||||||
|
|
||||||
|
// Second coin clink slightly delayed for "clinking" effect
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(1000, 0.1, 'sine', 0.25).catch(() => { });
|
||||||
|
}
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a damage sound - "ouch" like human exclamation
|
||||||
|
*/
|
||||||
|
async playDamage() {
|
||||||
|
await this.playTone(300, 0.2, 'sine', 0.4);
|
||||||
|
await this.playTone(600, 0.2, 'sine', 0.3);
|
||||||
|
await this.playTone(800, 0.2, 'sawtooth', 0.2);
|
||||||
|
|
||||||
|
// Add a quick "uh" sound at the end
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(250, 0.1, 'sine', 0.2).catch(() => { });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a power-up sound
|
||||||
|
*/
|
||||||
|
playPowerUp() {
|
||||||
|
// Ascending arpeggio
|
||||||
|
const notes = [440, 554.37, 659.25, 783.99]; // A, C#, E, G
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(freq, 0.15, 'sine', 0.25).catch(() => { });
|
||||||
|
}
|
||||||
|
}, i * 80);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a combo sound (higher multiplier = more notes)
|
||||||
|
* @param {number} multiplier - Combo multiplier
|
||||||
|
*/
|
||||||
|
playCombo(multiplier) {
|
||||||
|
const baseFreq = 440;
|
||||||
|
const notes = Math.min(multiplier, 5);
|
||||||
|
|
||||||
|
for (let i = 0; i < notes; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
const freq = baseFreq * (1 + i * 0.2);
|
||||||
|
this.playTone(freq, 0.1, 'sine', 0.15).catch(() => { });
|
||||||
|
}
|
||||||
|
}, i * 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a health regeneration sound
|
||||||
|
*/
|
||||||
|
playHealthRegen() {
|
||||||
|
// Gentle ascending tone
|
||||||
|
this.playTone(330, 0.2, 'sine', 0.15).catch(() => { });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(392, 0.2, 'sine', 0.15).catch(() => { });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a game over sound
|
||||||
|
*/
|
||||||
|
playGameOver() {
|
||||||
|
// Descending sad tone
|
||||||
|
const notes = [440, 392, 349, 294]; // A, G, F, D
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(freq, 0.3, 'sine', 0.3).catch(() => { });
|
||||||
|
}
|
||||||
|
}, i * 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a new high score sound
|
||||||
|
*/
|
||||||
|
playHighScore() {
|
||||||
|
// Triumphant ascending scale
|
||||||
|
const notes = [523.25, 659.25, 783.99, 987.77]; // C, E, G, B
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.playTone(freq, 0.2, 'sine', 0.3).catch(() => { });
|
||||||
|
}
|
||||||
|
}, i * 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle sound on/off
|
||||||
|
* @returns {boolean} New enabled state
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
this.enabled = !this.enabled;
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sound enabled state
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if sound is enabled
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isEnabled() {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio context state for debugging
|
||||||
|
* @returns {string} Audio context state
|
||||||
|
*/
|
||||||
|
getState() {
|
||||||
|
if (!this.audioContext) return 'not initialized';
|
||||||
|
return this.audioContext.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test sound - plays a simple tone to verify audio works
|
||||||
|
*/
|
||||||
|
async testSound() {
|
||||||
|
console.log('Testing sound system...');
|
||||||
|
console.log('Audio context state:', this.getState());
|
||||||
|
await this.playTone(440, 0.2, 'sine', 0.5);
|
||||||
|
console.log('Test sound played');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start background music - animated looping OST
|
||||||
|
*/
|
||||||
|
async startBackgroundMusic() {
|
||||||
|
if (this.bgMusicPlaying) {
|
||||||
|
console.log('Background music already playing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = this.ensureInitialized();
|
||||||
|
if (!ready || !this.audioContext) {
|
||||||
|
console.warn('Cannot start background music: audio context not ready, state:', this.getState());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.bgMusicPlaying = true;
|
||||||
|
console.log('Starting background music, audio context state:', this.audioContext.state);
|
||||||
|
|
||||||
|
// Create gain node for background music volume control
|
||||||
|
this.bgMusicGain = this.audioContext.createGain();
|
||||||
|
this.bgMusicGain.gain.value = this.bgMusicVolume * 0.5;
|
||||||
|
this.bgMusicGain.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
// Electronic/EDM style music
|
||||||
|
// Simple chord progression: C - Am - F - G (I - vi - IV - V - classic electronic progression)
|
||||||
|
const chordProgression = [
|
||||||
|
[130.81, 164.81, 196], // C major (C, E, G)
|
||||||
|
[220, 261.63, 329.63], // Am (A, C, E)
|
||||||
|
[174.61, 220, 261.63], // F major (F, A, C)
|
||||||
|
[196, 246.94, 293.66] // G major (G, B, D)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Electronic melody notes (higher octave, pentatonic for catchy hook)
|
||||||
|
const melodyNotes = [523.25, 587.33, 659.25, 698.46, 783.99, 880, 987.77, 1046.5]; // C5 to C6 - bright electronic range
|
||||||
|
|
||||||
|
// Bass line for electronic feel (lower octave)
|
||||||
|
const bassNotes = [65.41, 73.42, 82.41, 87.31, 98, 110, 123.47]; // C2 to B2
|
||||||
|
|
||||||
|
const playChord = (chord, startTime, duration) => {
|
||||||
|
chord.forEach((freq, index) => {
|
||||||
|
const osc = this.audioContext.createOscillator();
|
||||||
|
const gain = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// Use sawtooth for electronic/analog synth feel
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.bgMusicGain);
|
||||||
|
|
||||||
|
// Very bright, energetic attack for happy feel
|
||||||
|
gain.gain.setValueAtTime(0, startTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.5, startTime + 0.1); // Very fast, bright attack
|
||||||
|
gain.gain.setValueAtTime(0.5, startTime + duration - 0.2);
|
||||||
|
gain.gain.linearRampToValueAtTime(0, startTime + duration);
|
||||||
|
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration);
|
||||||
|
|
||||||
|
this.bgMusicOscillators.push(osc);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const playMelody = (note, startTime, duration) => {
|
||||||
|
const osc = this.audioContext.createOscillator();
|
||||||
|
const gain = this.audioContext.createGain();
|
||||||
|
|
||||||
|
osc.type = 'square'; // Square wave for electronic lead
|
||||||
|
osc.frequency.value = note;
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.bgMusicGain);
|
||||||
|
|
||||||
|
// Very energetic, bouncy attack for happy melody
|
||||||
|
gain.gain.setValueAtTime(0, startTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Very quick, bright attack
|
||||||
|
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
|
||||||
|
gain.gain.linearRampToValueAtTime(0, startTime + duration);
|
||||||
|
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration);
|
||||||
|
|
||||||
|
this.bgMusicOscillators.push(osc);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Electronic bass line
|
||||||
|
const playBass = (note, startTime, duration) => {
|
||||||
|
const osc = this.audioContext.createOscillator();
|
||||||
|
const gain = this.audioContext.createGain();
|
||||||
|
|
||||||
|
osc.type = 'square'; // Square wave for electronic bass
|
||||||
|
osc.frequency.value = note;
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.bgMusicGain);
|
||||||
|
|
||||||
|
// Punchy bass envelope
|
||||||
|
gain.gain.setValueAtTime(0, startTime);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
|
||||||
|
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||||
|
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration);
|
||||||
|
|
||||||
|
this.bgMusicOscillators.push(osc);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loop function - creates 8 second loop (fast electronic tempo)
|
||||||
|
const loopDuration = 8; // seconds - fast electronic tempo
|
||||||
|
let nextLoopTime = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
const scheduleLoop = () => {
|
||||||
|
if (!this.bgMusicPlaying || !this.audioContext) return;
|
||||||
|
|
||||||
|
const startTime = nextLoopTime;
|
||||||
|
|
||||||
|
// Play chord progression (4 chords, 2 seconds each - fast electronic tempo)
|
||||||
|
chordProgression.forEach((chord, chordIndex) => {
|
||||||
|
const chordStart = startTime + (chordIndex * 2);
|
||||||
|
playChord(chord, chordStart, 1.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Electronic bass line (pulsing rhythm)
|
||||||
|
const bassPattern = [
|
||||||
|
{ note: bassNotes[0], time: 0, duration: 0.3 }, // C - kick
|
||||||
|
{ note: bassNotes[0], time: 0.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[2], time: 1.0, duration: 0.3 }, // E
|
||||||
|
{ note: bassNotes[0], time: 1.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[3], time: 2.0, duration: 0.3 }, // F
|
||||||
|
{ note: bassNotes[0], time: 2.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[4], time: 3.0, duration: 0.3 }, // G
|
||||||
|
{ note: bassNotes[0], time: 3.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[2], time: 4.0, duration: 0.3 }, // E
|
||||||
|
{ note: bassNotes[0], time: 4.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[3], time: 5.0, duration: 0.3 }, // F
|
||||||
|
{ note: bassNotes[0], time: 5.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[4], time: 6.0, duration: 0.3 }, // G
|
||||||
|
{ note: bassNotes[0], time: 6.5, duration: 0.2 }, // C
|
||||||
|
{ note: bassNotes[2], time: 7.0, duration: 0.3 } // E
|
||||||
|
];
|
||||||
|
|
||||||
|
bassPattern.forEach(({ note, time, duration }) => {
|
||||||
|
playBass(note, startTime + time, duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play melody over chords (simpler pattern)
|
||||||
|
const melodyPattern = [
|
||||||
|
{ note: melodyNotes[0], time: 0.5, duration: 1 },
|
||||||
|
{ note: melodyNotes[2], time: 2, duration: 1 },
|
||||||
|
{ note: melodyNotes[4], time: 4.5, duration: 1 },
|
||||||
|
{ note: melodyNotes[3], time: 6, duration: 1 },
|
||||||
|
{ note: melodyNotes[5], time: 8.5, duration: 1.5 },
|
||||||
|
{ note: melodyNotes[4], time: 10.5, duration: 1 },
|
||||||
|
{ note: melodyNotes[2], time: 12, duration: 1.5 },
|
||||||
|
{ note: melodyNotes[0], time: 14, duration: 1.5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
melodyPattern.forEach(({ note, time, duration }) => {
|
||||||
|
playMelody(note, startTime + time, duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule next loop
|
||||||
|
nextLoopTime += loopDuration;
|
||||||
|
|
||||||
|
// Schedule next loop before current one ends (schedule 2 seconds before end)
|
||||||
|
const timeUntilNext = (nextLoopTime - this.audioContext.currentTime) * 1000 - 2000;
|
||||||
|
|
||||||
|
if (timeUntilNext > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.bgMusicPlaying && this.audioContext) {
|
||||||
|
scheduleLoop();
|
||||||
|
}
|
||||||
|
}, timeUntilNext);
|
||||||
|
} else {
|
||||||
|
// If we're behind, schedule immediately
|
||||||
|
scheduleLoop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start first loop immediately
|
||||||
|
scheduleLoop();
|
||||||
|
|
||||||
|
console.log('Background music started, audio context state:', this.audioContext.state);
|
||||||
|
console.log('Background music volume:', this.bgMusicVolume);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to start background music:', error);
|
||||||
|
this.bgMusicPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop background music
|
||||||
|
*/
|
||||||
|
stopBackgroundMusic() {
|
||||||
|
this.bgMusicPlaying = false;
|
||||||
|
|
||||||
|
// Stop all oscillators
|
||||||
|
this.bgMusicOscillators.forEach(osc => {
|
||||||
|
try {
|
||||||
|
osc.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Oscillator might already be stopped
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.bgMusicOscillators = [];
|
||||||
|
|
||||||
|
if (this.bgMusicGain) {
|
||||||
|
this.bgMusicGain.disconnect();
|
||||||
|
this.bgMusicGain = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Background music stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set background music volume
|
||||||
|
* @param {number} volume - Volume (0-1)
|
||||||
|
*/
|
||||||
|
setBackgroundMusicVolume(volume) {
|
||||||
|
this.bgMusicVolume = Math.max(0, Math.min(1, volume));
|
||||||
|
if (this.bgMusicGain) {
|
||||||
|
this.bgMusicGain.gain.value = this.bgMusicVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue