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 @@
@@ -132,6 +152,7 @@
Game Over!
+
🏆 New High Score! 🏆
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;
+ }
+ }
+}
+