diff --git a/index.html b/index.html index a285ca5..3117536 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,23 @@ text-shadow: 2px 2px 4px rgba(0,0,0,0.5); z-index: 100; } + #combo { + position: absolute; + top: 100px; + left: 50%; + transform: translateX(-50%); + color: #FFD700; + font-size: 32px; + font-weight: bold; + text-shadow: 2px 2px 4px rgba(0,0,0,0.8); + z-index: 100; + display: none; + animation: comboPulse 0.5s ease-in-out; + } + @keyframes comboPulse { + 0%, 100% { transform: translateX(-50%) scale(1); } + 50% { transform: translateX(-50%) scale(1.2); } + } #gameOver { position: absolute; top: 50%; @@ -119,9 +136,12 @@
Score: 0
+
High Score: 0
Health: 100
+
1x COMBO!
+
v-
@@ -132,6 +152,7 @@

Game Over!

+

Final Score: 0

diff --git a/src/components/Invincibility.js b/src/components/Invincibility.js new file mode 100644 index 0000000..ab61803 --- /dev/null +++ b/src/components/Invincibility.js @@ -0,0 +1,57 @@ +/** + * Invincibility component - tracks invincibility state and timer + */ +export class Invincibility { + /** + * @param {number} [duration=1.5] - Duration of invincibility in seconds + * @param {boolean} [startActive=false] - Whether to start invincible (default false) + */ + constructor(duration = 1.5, startActive = false) { + /** @type {number} Time remaining in seconds */ + this.timeRemaining = startActive ? duration : 0; + + /** @type {number} Total duration */ + this.duration = duration; + + /** @type {boolean} Whether entity is currently invincible */ + this.isInvincible = startActive; + } + + /** + * Update invincibility timer + * @param {number} deltaTime - Time since last frame in seconds + * @returns {boolean} True if still invincible + */ + update(deltaTime) { + if (!this.isInvincible) return false; + + this.timeRemaining -= deltaTime; + + if (this.timeRemaining <= 0) { + this.isInvincible = false; + this.timeRemaining = 0; + return false; + } + + return true; + } + + /** + * Activate invincibility + * @param {number} [duration] - Optional custom duration + */ + activate(duration = null) { + this.duration = duration || this.duration; + this.timeRemaining = this.duration; + this.isInvincible = true; + } + + /** + * Check if currently invincible + * @returns {boolean} + */ + getIsInvincible() { + return this.isInvincible && this.timeRemaining > 0; + } +} + diff --git a/src/game/EntityFactory.js b/src/game/EntityFactory.js index 31bbe19..f398994 100644 --- a/src/game/EntityFactory.js +++ b/src/game/EntityFactory.js @@ -3,7 +3,9 @@ import { Velocity } from '../components/Velocity.js'; import { MeshComponent } from '../components/MeshComponent.js'; import { Collidable } from '../components/Collidable.js'; import { Health } from '../components/Health.js'; +import { Invincibility } from '../components/Invincibility.js'; import { PlayerTag, CoinTag, ObstacleTag, BoundaryConstrained } from '../components/Tags.js'; +import { GameConfig } from './GameConfig.js'; /** * EntityFactory - creates pre-configured game entities with appropriate components. @@ -52,6 +54,8 @@ export class EntityFactory { this.world.addComponent(entity, new MeshComponent(mesh)); this.world.addComponent(entity, new Collidable(0, 'player')); // Player center point (original behavior) this.world.addComponent(entity, new Health(100)); + // Invincibility starts inactive until first damage + this.world.addComponent(entity, new Invincibility(GameConfig.INVINCIBILITY_DURATION, false)); this.world.addComponent(entity, new PlayerTag()); this.world.addComponent(entity, new BoundaryConstrained(this.groundSize)); diff --git a/src/game/Game.js b/src/game/Game.js index 3dd9dad..76aef4c 100644 --- a/src/game/Game.js +++ b/src/game/Game.js @@ -1,5 +1,6 @@ import { World } from '../ecs/World.js'; import { EntityFactory } from './EntityFactory.js'; +import { GameConfig } from './GameConfig.js'; // Systems import { InputSystem } from '../systems/InputSystem.js'; @@ -9,11 +10,13 @@ import { BoundarySystem } from '../systems/BoundarySystem.js'; import { CoinSystem } from '../systems/CoinSystem.js'; import { ObstacleSystem } from '../systems/ObstacleSystem.js'; import { CollisionSystem } from '../systems/CollisionSystem.js'; +import { InvincibilitySystem } from '../systems/InvincibilitySystem.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'; /** * Main Game class - manages the game loop and coordinates all systems. @@ -24,11 +27,14 @@ import { Health } from '../components/Health.js'; export class Game { constructor() { /** @type {number} Size of the game play area */ - this.groundSize = 30; + this.groundSize = GameConfig.GROUND_SIZE; /** @type {number} Current game score */ this.score = 0; + /** @type {number} High score (loaded from localStorage) */ + this.highScore = this.loadHighScore(); + /** @type {boolean} Whether the game is currently active */ this.gameActive = true; @@ -69,6 +75,20 @@ export class Game { lastShakeTime: 0 }; + // Combo system state + /** @type {number} Current combo multiplier (1x, 2x, 3x, etc.) */ + this.comboMultiplier = 1; + + /** @type {number} Time since last coin collection (for combo) */ + this.comboTimer = 0; + + /** @type {number} Last time a coin was collected */ + this.lastCoinTime = 0; + + // Health regeneration state + /** @type {number} Time since last health regeneration */ + this.healthRegenTimer = 0; + this.init(); this.setupEventListeners(); this.animate(); @@ -177,6 +197,9 @@ export class Game { // Game-specific behavior this.world.addSystem(new CoinSystem()); + // Invincibility system (before collision to update state) + this.world.addSystem(new InvincibilitySystem()); + // Collision detection this.collisionSystem = new CollisionSystem(); this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => { @@ -193,13 +216,13 @@ export class Game { this.playerEntity = this.entityFactory.createPlayer(); // Create coins - for (let i = 0; i < 10; i++) { + for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) { const coin = this.entityFactory.createCoin(this.coins.length); this.coins.push(coin); } // Create obstacles - for (let i = 0; i < 8; i++) { + for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) { const obstacle = this.entityFactory.createObstacle(); this.obstacles.push(obstacle); } @@ -230,8 +253,36 @@ export class Game { this.coins.splice(index, 1); } - // Update score - this.score += 10; + // Update combo system + const currentTime = performance.now() / 1000; // Convert to seconds + const timeSinceLastCoin = currentTime - this.lastCoinTime; + + if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) { + // Maintain combo + this.comboMultiplier = Math.min( + this.comboMultiplier + 1, + GameConfig.COMBO_MULTIPLIER_MAX + ); + this.comboTimer = GameConfig.COMBO_TIME_WINDOW; + } else { + // Reset combo + this.comboMultiplier = 1; + this.comboTimer = GameConfig.COMBO_TIME_WINDOW; + } + + this.lastCoinTime = currentTime; + + // Calculate score with combo multiplier + const baseScore = GameConfig.COMBO_BASE_SCORE; + const scoreGain = baseScore * this.comboMultiplier; + this.score += scoreGain; + + // Check for new high score + if (this.score > this.highScore) { + this.highScore = this.score; + this.saveHighScore(this.highScore); + } + this.updateUI(); // Spawn new coin @@ -240,12 +291,23 @@ export class Game { } handleObstacleCollision(playerEntity, obstacleEntity) { + // Check if player is invincible + const invincibility = this.world.getComponent(playerEntity, Invincibility); + if (invincibility && invincibility.getIsInvincible()) { + return; // No damage if invincible + } + const health = this.world.getComponent(playerEntity, Health); const playerTransform = this.world.getComponent(playerEntity, Transform); const obstacleTransform = this.world.getComponent(obstacleEntity, Transform); - // Damage player - const isDead = health.damage(1); + // Damage player (using config damage amount) + const isDead = health.damage(GameConfig.OBSTACLE_DAMAGE); + + // Activate invincibility frames + if (invincibility) { + invincibility.activate(GameConfig.INVINCIBILITY_DURATION); + } // Push player back const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position); @@ -254,6 +316,10 @@ export class Game { playerTransform.position.add(pushDirection.multiplyScalar(0.3)); playerTransform.position.y = 0.5; + // Reset combo on damage + this.comboMultiplier = 1; + this.comboTimer = 0; + this.updateUI(); if (isDead) { @@ -274,6 +340,23 @@ export class Game { updateUI() { document.getElementById('score').textContent = this.score; + + // Update high score + const highScoreEl = document.getElementById('highScore'); + if (highScoreEl) { + highScoreEl.textContent = this.highScore; + } + + // Update combo display + const comboEl = document.getElementById('combo'); + if (comboEl) { + if (this.comboMultiplier > 1 && this.comboTimer > 0) { + comboEl.textContent = `${this.comboMultiplier}x COMBO!`; + comboEl.style.display = 'block'; + } else { + comboEl.style.display = 'none'; + } + } const health = this.world.getComponent(this.playerEntity, Health); if (health) { @@ -284,6 +367,15 @@ export class Game { gameOver() { this.gameActive = false; document.getElementById('finalScore').textContent = this.score; + + // Show "New High Score!" if applicable + const newHighScoreEl = document.getElementById('newHighScore'); + if (newHighScoreEl && this.score === this.highScore && this.score > 0) { + newHighScoreEl.style.display = 'block'; + } else if (newHighScoreEl) { + newHighScoreEl.style.display = 'none'; + } + document.getElementById('gameOver').style.display = 'block'; } @@ -302,6 +394,14 @@ export class Game { this.score = 0; this.gameActive = true; this.lastTime = performance.now(); // Reset timer to prevent deltaTime spike + + // Reset combo system + this.comboMultiplier = 1; + this.comboTimer = 0; + this.lastCoinTime = 0; + + // Reset health regeneration timer + this.healthRegenTimer = 0; // Recreate entities this.createGameEntities(); @@ -427,6 +527,32 @@ export class Game { }); } + /** + * Load high score from localStorage + * @returns {number} High score value + */ + loadHighScore() { + try { + const saved = localStorage.getItem(GameConfig.STORAGE_HIGH_SCORE); + return saved ? parseInt(saved, 10) : 0; + } catch (error) { + console.debug('Failed to load high score:', error); + return 0; + } + } + + /** + * Save high score to localStorage + * @param {number} score - Score to save + */ + saveHighScore(score) { + try { + localStorage.setItem(GameConfig.STORAGE_HIGH_SCORE, score.toString()); + } catch (error) { + console.debug('Failed to save high score:', error); + } + } + onWindowResize() { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); @@ -472,6 +598,24 @@ export class Game { } if (this.gameActive) { + // Update combo timer + this.comboTimer = Math.max(0, this.comboTimer - deltaTime); + if (this.comboTimer <= 0 && this.comboMultiplier > 1) { + this.comboMultiplier = 1; + this.updateUI(); + } + + // Update health regeneration + this.healthRegenTimer += deltaTime; + if (this.healthRegenTimer >= GameConfig.HEALTH_REGEN_INTERVAL) { + const health = this.world.getComponent(this.playerEntity, Health); + if (health && health.currentHealth < health.maxHealth) { + health.heal(GameConfig.HEALTH_REGEN_AMOUNT); + this.updateUI(); + } + this.healthRegenTimer = 0; + } + // Update ECS world with actual deltaTime this.world.update(deltaTime); diff --git a/src/game/GameConfig.js b/src/game/GameConfig.js new file mode 100644 index 0000000..3fd6de1 --- /dev/null +++ b/src/game/GameConfig.js @@ -0,0 +1,31 @@ +/** + * GameConfig - Centralized game configuration constants + * Easy to tweak difficulty, balance, and gameplay parameters + */ +export const GameConfig = { + // Gameplay + OBSTACLE_DAMAGE: 3, // Increased from 1 to balance invincibility frames and health regen + COIN_SCORE: 10, + HEALTH_REGEN_INTERVAL: 10, // Heal 1 HP every 10 seconds + HEALTH_REGEN_AMOUNT: 1, + + // Combo System + COMBO_TIME_WINDOW: 3, // Seconds between coin collections to maintain combo + COMBO_MULTIPLIER_MAX: 5, // Maximum combo multiplier (5x) + COMBO_BASE_SCORE: 10, // Base score per coin + + // Invincibility + INVINCIBILITY_DURATION: 1.5, // Seconds of invincibility after damage + INVINCIBILITY_FLASH_RATE: 0.1, // Seconds between flash toggles + + // Difficulty + INITIAL_OBSTACLE_COUNT: 8, + INITIAL_COIN_COUNT: 10, + + // Arena + GROUND_SIZE: 30, + + // Storage Keys + STORAGE_HIGH_SCORE: 'threejs-game-highscore' +}; + diff --git a/src/systems/InvincibilitySystem.js b/src/systems/InvincibilitySystem.js new file mode 100644 index 0000000..4d64a4f --- /dev/null +++ b/src/systems/InvincibilitySystem.js @@ -0,0 +1,49 @@ +import { System } from '../ecs/System.js'; +import { Invincibility } from '../components/Invincibility.js'; +import { MeshComponent } from '../components/MeshComponent.js'; +import { GameConfig } from '../game/GameConfig.js'; + +/** + * InvincibilitySystem - handles invincibility flashing effect + */ +export class InvincibilitySystem extends System { + constructor() { + super(); + this.flashTimer = 0; + } + + /** + * Update invincibility timers and flash effect + * @param {number} deltaTime - Time since last frame in seconds + */ + update(deltaTime) { + const entities = this.getEntities(Invincibility, MeshComponent); + + this.flashTimer += deltaTime; + + for (const entityId of entities) { + const invincibility = this.getComponent(entityId, Invincibility); + const meshComp = this.getComponent(entityId, MeshComponent); + + if (!invincibility || !meshComp) continue; + + // Update invincibility timer + invincibility.update(deltaTime); + + // Flash effect - toggle visibility based on flash rate + if (invincibility.getIsInvincible()) { + const flashInterval = Math.floor(this.flashTimer / GameConfig.INVINCIBILITY_FLASH_RATE); + meshComp.mesh.visible = flashInterval % 2 === 0; + } else { + // Always visible when not invincible + meshComp.mesh.visible = true; + } + } + + // Reset flash timer periodically to prevent overflow + if (this.flashTimer > 10) { + this.flashTimer = 0; + } + } +} +