diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1fe2704 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# 🧠 Architecture Technique : Space InZader + +Ce document détaille la structure interne du projet pour faciliter la compréhension et l'extension du code par de nouveaux développeurs. + +## 1. Philosophie de Design +Le projet repose sur une séparation stricte entre **la Logique (Engine)**, **le Rendu (Renderer)** et **l'Interface (React UI)**. + +- **Engine** : Calculs mathématiques purs, aucune notion de pixels ou de dessin. +- **Renderer** : Traduction de l'état du jeu en formes géométriques sur Canvas. +- **React UI** : Couche d'interface (HUD, Menus) qui "survole" le jeu sans interférer avec la boucle de calcul. + +--- + +## 2. Gestion de l'État (State Management) +Contrairement à une application React classique, le jeu n'utilise pas `useState` pour la simulation temps réel. + +- **`engineState` (Mutable Ref)** : L'intégralité du monde (joueur, ennemis, projectiles) est stockée dans un objet `useRef` dans `App.tsx`. Cela permet des mises à jour à 60 FPS sans déclencher de re-renders React qui ralentiraient le processeur. +- **`uiState` (React State)** : Une copie légère de l'état est synchronisée avec React uniquement pour mettre à jour le HUD et les menus. + +--- + +## 3. La Boucle de Jeu (Game Loop) +Située dans `App.tsx`, elle utilise `requestAnimationFrame`. À chaque frame : +1. Elle calcule le `deltaTime` (temps écoulé). +2. Elle appelle `updateGameState` (Engine). +3. Elle appelle `renderGame` (Renderer). +4. Elle met à jour le HUD via React. + +--- + +## 4. Organisation des Modules (src/) + +### 📂 `engine/` (Le Cerveau) +- **`CoreEngine.ts`** : Le chef d'orchestre. Il appelle tous les autres sous-systèmes dans le bon ordre. +- **`PhysicsEngine.ts`** : Gère les déplacements de base (vitesse, inertie) et les limites du monde. +- **`CollisionSystem.ts`** : Détecte les impacts. Il utilise un **QuadTree** pour ne tester que les entités proches les unes des autres (optimisation majeure). +- **`DamageEngine.ts`** : Le simulateur de combat. Il calcule la réduction de dégâts selon les couches (Bouclier > Armure > Coque) et les résistances élémentaires. +- **`StatsCalculator.ts`** : Gère les formules mathématiques complexes, notamment le **rendement dégressif** des bonus pour éviter que le joueur ne devienne "trop fort" trop vite. +- **`AbilitySystem.ts`** : Gère les cooldowns et l'exécution des compétences actives (Dash, Nova, etc.). +- **`InputManager.ts`** : Centralise les entrées clavier et souris. + +### 📂 `render/` (Les Yeux) +- **`CoreRenderer.ts`** : Définit l'ordre de dessin (le fond d'abord, puis les entités, puis les effets). +- **`ShipRenderer.ts`** : Dessine les vaisseaux à partir de primitives géométriques (pas d'images/sprites pour plus de flexibilité). Gère aussi le **Hit Flash** (clignotement blanc). +- **`EffectRenderer.ts`** : Gère tout ce qui est éphémère : particules, explosions et textes de dégâts flottants. +- **`WorldRenderer.ts`** : Dessine la grille hexagonale et les bordures du secteur. + +### 📂 `components/` (L'Interface) +- **`HUD.tsx`** : Interface de combat (barres de vie, radar, chaleur). +- **`UpgradeMenu.tsx`** : Le menu de montée de niveau. +- **`DevMenu.tsx`** : Un outil surpuissant pour tester le jeu (Labo d'ingénierie). + +--- + +## 5. Optimisations Clés + +### Le QuadTree (`engine/QuadTree.ts`) +Au lieu de comparer chaque projectile avec chaque ennemi (ce qui ferait des milliers de calculs par seconde), le monde est divisé en quadrants. On ne teste les collisions que dans les zones où des objets sont présents. + +### Rendement Dégressif (`engine/StatsCalculator.ts`) +Pour les passifs, nous utilisons la formule : `Bonus = Σ (0.8 ^ stacks)`. +Cela signifie que le 1er bonus est à 100% d'efficacité, le 2ème à 80%, le 3ème à 64%, etc. Cela permet de monter à l'infini sans jamais atteindre des valeurs qui cassent le jeu. + +### Audio Procédural (`engine/SoundEngine.ts`) +Le jeu n'utilise pas de fichiers MP3. Les sons sont générés mathématiquement par la carte son (Web Audio API) pour une latence zéro et un poids nul. diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..e35046c --- /dev/null +++ b/App.tsx @@ -0,0 +1,333 @@ + +import React, { useEffect, useRef, useState } from 'react'; +import { GameState, Entity, DamageType, Weapon, Stats, Keystone, Passive, EnvEventType } from './types'; +import { WORLD_WIDTH, WORLD_HEIGHT, VIEW_SCALE, INITIAL_STATS, WEAPON_POOL, CONTROLS } from './constants'; +import { HUD } from './components/HUD'; +import { UpgradeMenu } from './components/Menu/UpgradeMenu'; +import { DevMenu } from './components/Menu/DevMenu'; +import { DebugOverlay } from './components/DebugOverlay'; +import { updateGameState, spawnEnemy, createEffect } from './engine/CoreEngine'; +import { renderGame } from './render/CoreRenderer'; +import { startBGM, stopBGM } from './engine/SoundEngine'; +import { input } from './engine/InputManager'; +import { BLINK_DASH, TACTICAL_NOVA } from './engine/AbilitySystem'; +import { calculateRuntimeStats, syncDefenseState } from './engine/StatsCalculator'; + +const App: React.FC = () => { + const canvasRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight }); + const [isLabMenuOpen, setIsLabMenuOpen] = useState(true); + + const [fps, setFps] = useState(0); + const [frameTime, setFrameTime] = useState(0); + const frameTimes = useRef([]); + const lastFpsUpdate = useRef(0); + + const createInitialState = (): GameState => ({ + player: { + id: 'player', x: WORLD_WIDTH / 2, y: WORLD_HEIGHT / 2, rotation: -Math.PI / 2, vx: 0, vy: 0, radius: 40, type: 'player', + baseStats: { ...INITIAL_STATS }, runtimeStats: { ...INITIAL_STATS }, modifiers: [], statsDirty: true, + defense: { shield: INITIAL_STATS.maxShield, armor: INITIAL_STATS.maxArmor, hull: INITIAL_STATS.maxHull }, + isGodMode: false + }, + heat: 0, maxHeat: INITIAL_STATS.maxHeat, isOverheated: false, score: 0, level: 1, experience: 0, + expToNextLevel: 60, + wave: 1, waveTimer: 35, + waveKills: 0, + waveQuota: 15, + totalKills: 0, + startTime: Date.now(), + enemies: [], projectiles: [], xpDrops: [], effects: [], particles: [], activeWeapons: [{ ...WEAPON_POOL[0], level: 1 }], + activeAbilities: [ + { ...BLINK_DASH }, + { ...TACTICAL_NOVA } + ], + activeEvents: [], + keystones: [], activePassives: [], status: 'menu', comboCount: 0, comboTimer: 0, currentMisses: 0, bossSpawned: false, + isDebugMode: false, + }); + + const engineState = useRef(createInitialState()); + const [uiState, setUiState] = useState(engineState.current); + const lastTime = useRef(0); + const screenShake = useRef(0); + const camera = useRef({ x: WORLD_WIDTH / 2 - dimensions.width / (2 * VIEW_SCALE), y: WORLD_HEIGHT / 2 - dimensions.height / (2 * VIEW_SCALE) }); + + useEffect(() => { + const handleResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener('resize', handleResize); + if (canvasRef.current) input.setCanvas(canvasRef.current); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const resetGame = (newStatus: 'menu' | 'playing' | 'dev' | 'lab' = 'menu') => { + const freshState = createInitialState(); + freshState.status = newStatus; + freshState.startTime = Date.now(); + engineState.current = freshState; + camera.current = { x: freshState.player.x - dimensions.width / (2 * VIEW_SCALE), y: freshState.player.y - dimensions.height / (2 * VIEW_SCALE) }; + setUiState(freshState); + if (newStatus === 'playing') startBGM(); else stopBGM(); + }; + + useEffect(() => { + let animationFrameId: number; + const gameLoop = (time: number) => { + const dt = time - (lastTime.current || time - 16); + const deltaTime = lastTime.current === 0 ? 0.016 : Math.min(0.1, dt / 1000); + lastTime.current = time; + const s = engineState.current; + const ctx = canvasRef.current?.getContext('2d'); + + if (s.isDebugMode) { + frameTimes.current.push(dt); + if (frameTimes.current.length > 30) frameTimes.current.shift(); + if (time - lastFpsUpdate.current > 250) { + const avgDt = frameTimes.current.reduce((a, b) => a + b, 0) / frameTimes.current.length; + setFrameTime(avgDt); + setFps(Math.round(1000 / avgDt)); + lastFpsUpdate.current = time; + } + } + + if ((s.status === 'playing' || s.status === 'lab') && ctx) { + const mousePos = input.getMousePos(); + const mouseWorld = { + x: (mousePos.x / VIEW_SCALE) + camera.current.x, + y: (mousePos.y / VIEW_SCALE) + camera.current.y + }; + + const isInputFocused = document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement; + const keys = isInputFocused ? new Set() : input.getKeys(); + + updateGameState( + s, deltaTime, time, keys, mouseWorld, + () => { if(s.status !== 'lab') { s.status = 'leveling'; stopBGM(); } }, + () => { + if(s.status === 'lab') { + handleDevAction('heal_player'); + createEffect(s, s.player.x, s.player.y, "RESPAWN_SIMULÉ", "#ffffff"); + } else { + s.status = 'gameover'; + stopBGM(); + } + }, + (amount) => { screenShake.current = amount; } + ); + + const sidebarWidth = 450; + const offset = (s.status === 'lab' && isLabMenuOpen) ? sidebarWidth : 0; + const visibleWidth = dimensions.width - offset; + + const targetX = s.status === 'lab' + ? s.player.x - (offset + visibleWidth / 2) / VIEW_SCALE + : s.player.x - (dimensions.width / 2) / VIEW_SCALE; + + camera.current.x += (targetX - camera.current.x) * 0.1; + camera.current.y += (s.player.y - dimensions.height / (2 * VIEW_SCALE) - camera.current.y) * 0.1; + + if (screenShake.current > 0) screenShake.current -= deltaTime * 40; + setUiState({ ...s }); + } + + if (ctx) { + renderGame(ctx, s, dimensions, camera.current, screenShake.current, time); + } + animationFrameId = requestAnimationFrame(gameLoop); + }; + animationFrameId = requestAnimationFrame(gameLoop); + return () => cancelAnimationFrame(animationFrameId); + }, [dimensions, isLabMenuOpen]); + + useEffect(() => { + const handleGlobalKeys = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === CONTROLS.PAUSE) { + const s = engineState.current; + if (s.status === 'playing') { s.status = 'paused'; stopBGM(); } + else if (s.status === 'paused') { s.status = 'playing'; startBGM(); } + setUiState({...s}); + } + if (key === CONTROLS.DEBUG) { + e.preventDefault(); + const s = engineState.current; + s.isDebugMode = !s.isDebugMode; + setUiState({...s}); + } + }; + window.addEventListener('keydown', handleGlobalKeys); + return () => { window.removeEventListener('keydown', handleGlobalKeys); input.dispose(); }; + }, []); + + const handleDevAction = (action: string, data?: any) => { + const s = engineState.current; + const isLab = s.status === 'lab'; + const spawnDist = isLab ? 300 : 1000; + + switch(action) { + case 'toggle_lab_menu': + setIsLabMenuOpen(!isLabMenuOpen); + break; + case 'reset_simulation': + s.enemies = []; s.projectiles = []; s.xpDrops = []; s.particles = []; s.effects = []; + s.player.x = WORLD_WIDTH/2; s.player.y = WORLD_HEIGHT/2; s.heat = 0; s.isOverheated = false; + createEffect(s, s.player.x, s.player.y, "REBOOT_TOTAL", "#ffffff"); + break; + case 'reset_physics': + // Reset profond : réalignement des PV max et désactivation God Mode + s.player.baseStats = { ...INITIAL_STATS }; + s.player.isGodMode = false; + s.player.statsDirty = true; + // On force le recalcul immédiat pour éviter les overflows de PV + s.player.runtimeStats = calculateRuntimeStats(s.player, s); + syncDefenseState(s.player); + createEffect(s, s.player.x, s.player.y, "PHYSICS_NORMALIZED", "#fbbf24"); + break; + case 'spawn_basic': case 'spawn_swarmer': case 'spawn_sniper': case 'spawn_kamikaze': case 'spawn_boss': + const type = action.replace('spawn_', ''); + const newEnemy = spawnEnemy(s.wave, s.player, type as any, spawnDist); + s.enemies.push(newEnemy); + createEffect(s, newEnemy.x, newEnemy.y, `INJECT_${type.toUpperCase()}`, "#ef4444"); + break; + case 'clear_enemies': + s.enemies = []; + createEffect(s, s.player.x, s.player.y, "ZONE_CLEARED", "#22d3ee"); + break; + case 'tune_stat': + s.player.baseStats = { ...s.player.baseStats, [data.prop]: data.val }; + s.player.statsDirty = true; + break; + case 'install_weapon': + const existingW = s.activeWeapons.find(w => w.id === data.id); + if (existingW) existingW.level = Math.min(3, existingW.level + 1); + else s.activeWeapons.push({ ...data, level: 1 }); + break; + case 'install_passive': + const existingP = s.activePassives.find(ap => ap.passive.id === data.id); + if (existingP) existingP.stacks++; + else s.activePassives.push({ passive: data, stacks: 1 }); + s.player.statsDirty = true; + break; + case 'install_keystone': + if (!s.keystones.find(k => k.id === data.id)) { + s.keystones.push(data); + s.player.statsDirty = true; + } + break; + case 'clear_loadout': + s.activeWeapons = [{ ...WEAPON_POOL[0], level: 1 }]; + s.activePassives = []; + s.keystones = []; + s.player.statsDirty = true; + break; + case 'change_status': + s.status = data; + if (data === 'playing') startBGM(); else stopBGM(); + break; + case 'god_mode': + s.player.isGodMode = !s.player.isGodMode; + if (s.player.isGodMode) { + handleDevAction('heal_player'); + createEffect(s, s.player.x, s.player.y, "GOD_MODE: ON", "#facc15"); + } else { + // Sécurité : on remet les PV dans les limites normales au cas où + s.player.statsDirty = true; + createEffect(s, s.player.x, s.player.y, "GOD_MODE: OFF", "#94a3b8"); + } + break; + case 'heal_player': + s.player.defense.shield = s.player.runtimeStats.maxShield; + s.player.defense.armor = s.player.runtimeStats.maxArmor; + s.player.defense.hull = s.player.runtimeStats.maxHull; + s.heat = 0; + s.isOverheated = false; + createEffect(s, s.player.x, s.player.y, "SYSTEM_REPAIRED", "#4ade80"); + break; + } + + if (s.player.statsDirty) { + s.player.runtimeStats = calculateRuntimeStats(s.player, s); + syncDefenseState(s.player); + s.player.statsDirty = false; + } + setUiState({...s}); + }; + + return ( +
+ + + {uiState.isDebugMode && } + {uiState.status !== 'menu' && uiState.status !== 'dev' && uiState.status !== 'lab' && } + + {uiState.status === 'menu' && ( +
+

Space InZader

+
+ +
+ + +
+
+
+ )} + + {(uiState.status === 'dev' || uiState.status === 'lab') && ( + resetGame('menu')} + onLaunchSandbox={() => handleDevAction('change_status', 'playing')} + onTriggerAction={handleDevAction} + /> + )} + + {uiState.status === 'leveling' && ( + { + const s = engineState.current; + if (u.type === 'weapon') { + const ex = s.activeWeapons.find(w => w.id === u.item.id); + if (ex) ex.level = Math.min(3, ex.level + 1); + else s.activeWeapons.push({ ...u.item, level: 1 }); + } else if (u.type === 'passive') { + const ex = s.activePassives.find(p => p.passive.id === u.item.id); + if (ex) ex.stacks = Math.min(u.item.maxStacks, ex.stacks + 1); + else s.activePassives.push({ passive: u.item, stacks: 1 }); + s.player.statsDirty = true; + } + if (s.player.statsDirty) { + s.player.runtimeStats = calculateRuntimeStats(s.player, s); + syncDefenseState(s.player); + s.player.statsDirty = false; + } + s.experience -= s.expToNextLevel; + s.expToNextLevel = Math.floor(s.expToNextLevel * 1.3); + s.level++; + s.status = 'playing'; + startBGM(); + setUiState({ ...s }); + }} + currentWeapons={uiState.activeWeapons} + currentKeystones={uiState.keystones} + /> + )} + + {uiState.status === 'paused' && ( +
+

SYSTEM_PAUSE

+
+ )} + + {uiState.status === 'gameover' && ( +
+

CRITICAL_FAILURE

+ +
+ )} +
+ ); +}; + +export default App; diff --git a/BLACK_HOLE_IMPLEMENTATION_SUMMARY.md b/BLACK_HOLE_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index ca7c059..0000000 --- a/BLACK_HOLE_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,229 +0,0 @@ -# Implementation Summary: Black Hole Instant Kill & DevTools Scrolling - -## Overview -This session implemented two critical improvements to the Space InZader game: -1. Black hole center instant kill mechanic -2. DevTools scrolling fix - -## Changes Summary - -### Files Modified -1. **js/systems/CollisionSystem.js** (+340 lines, -10 lines) - - Added class constants for black hole instant kill parameters - - Implemented instant kill logic for player - - Implemented instant kill logic for NPCs/enemies - - Refactored to use named constants instead of magic numbers - -2. **index.html** (+1 line) - - Fixed DevTools scrolling by adding `min-height: 0` to CSS - -### Documentation Created -1. **BLACK_HOLE_INSTANT_KILL.md** - Comprehensive feature documentation -2. **DEVTOOLS_SCROLLING_FIX.md** - Technical explanation of CSS fix - -## Feature Details - -### Black Hole Instant Kill - -#### Kill Zones -- **Center Kill Zone**: 0-30 pixels → Instant death ☠️ -- **Damage Zone**: 30-80 pixels → Gradual damage (existing behavior) -- **Safe Zone**: >80 pixels → No damage - -#### Player Behavior -- Instant death when entering center (distance < 30px) -- Respects god mode for testing -- Intense visual feedback: - - Screen shake: 15 intensity, 0.5 seconds - - Purple flash: #9400D3, 0.5 intensity, 0.5 seconds -- Death sound plays immediately -- Triggers game over screen -- Console log: `[Black Hole] Player sucked into center - INSTANT DEATH!` - -#### NPC/Enemy Behavior -- All enemies instantly killed in center zone -- Works on all enemy types (Drone, Chasseur, Tank, Tireur, Elite, Boss) -- Standard death behavior (XP drops, kill count) -- Console log: `[Black Hole] Enemy sucked into center - INSTANT DEATH!` - -#### Constants Defined -```javascript -BLACK_HOLE_CENTER_KILL_RADIUS = 30 // pixels -BLACK_HOLE_DEATH_SHAKE_INTENSITY = 15 -BLACK_HOLE_DEATH_SHAKE_DURATION = 0.5 // seconds -BLACK_HOLE_DEATH_FLASH_COLOR = '#9400D3' // Purple -BLACK_HOLE_DEATH_FLASH_INTENSITY = 0.5 -BLACK_HOLE_DEATH_FLASH_DURATION = 0.5 // seconds -``` - -### DevTools Scrolling Fix - -#### Problem -Content below the visible area was not accessible in DevTools overlay. - -#### Root Cause -Flex item (`.devtools-content`) couldn't shrink below content size without `min-height: 0`. - -#### Solution -Added `min-height: 0` to `.devtools-content` CSS class. - -#### Result -- All DevTools tabs now fully scrollable -- Scrollbar appears when content exceeds visible height -- All sections accessible (including new Wave Control) - -## Quality Assurance - -### Code Review -✅ **PASSED** - All feedback addressed -- Extracted magic numbers to class constants -- Improved code maintainability -- Named constants for visual feedback parameters - -### Security Scan -✅ **PASSED** - CodeQL: 0 vulnerabilities -- No security issues detected - -### Testing Checklist -- [x] Player enters black hole center → instant death -- [x] NPCs enter black hole center → instant death -- [x] God mode protects player from instant death -- [x] Existing damage zone still works (30-80px) -- [x] Visual feedback displays correctly -- [x] Death sound plays -- [x] Game over triggers properly -- [x] DevTools scrolling works on all tabs - -## Technical Implementation - -### Code Structure -```javascript -class CollisionSystem { - constructor() { - // Black hole instant kill constants - this.BLACK_HOLE_CENTER_KILL_RADIUS = 30; - this.BLACK_HOLE_DEATH_SHAKE_INTENSITY = 15; - // ... other constants - } - - checkWeatherHazardCollisions() { - // For each black hole: - // For player: - // if distance < CENTER_KILL_RADIUS: - // instant kill + visual feedback - // else if distance < damageRadius: - // gradual damage (existing) - // - // For each enemy: - // if distance < CENTER_KILL_RADIUS: - // instant kill - // else if distance < damageRadius: - // gradual damage (existing) - } -} -``` - -### CSS Fix -```css -.devtools-content { - flex: 1; - overflow-y: auto; - padding: 20px; - min-height: 0; /* NEW: Enables scrolling */ -} -``` - -## User Experience Impact - -### Gameplay -- **More Dangerous**: Black hole is now truly deadly at its center -- **Strategic**: Players must avoid center while managing pull effect -- **Fair**: 30px radius is small enough to avoid with skill -- **Exciting**: Instant death creates tension and risk/reward - -### DevTools -- **Improved Usability**: All content now accessible -- **Better Testing**: Can see all controls without issues -- **Consistent UX**: Matches expected scrolling behavior - -## Performance Considerations - -### Black Hole Instant Kill -- **Minimal Impact**: Simple distance check (already being performed) -- **No Overhead**: Instant kill is simpler than gradual damage -- **Efficient**: Uses existing collision detection system - -### DevTools Scrolling -- **Zero Impact**: CSS-only change -- **No Runtime Cost**: Browser handles scrolling natively - -## Backward Compatibility - -### Saved Games -✅ **Compatible** - No changes to save format - -### Existing Features -✅ **Compatible** - All existing features work as before -- Black hole pull mechanics unchanged -- Damage zone behavior unchanged (outside center) -- God mode compatibility maintained -- All enemy types supported - -## Future Enhancements - -### Potential Improvements -1. **Visual Indicator**: Red circle showing instant kill zone (30px radius) -2. **Death Animation**: Unique vortex effect for center deaths -3. **Audio Cue**: Warning sound when entering kill zone -4. **Statistics**: Track black hole deaths separately -5. **Achievement**: "Avoided the Singularity" - survive black hole event - -### Balance Adjustments -Current values can be easily modified via constants: -- `BLACK_HOLE_CENTER_KILL_RADIUS`: Increase/decrease instant kill zone -- Visual feedback intensity: Adjust shake/flash parameters -- Could add difficulty scaling (harder difficulties = larger kill zone) - -## Commits - -1. **c336763**: Initial implementation - - Instant kill logic for player and NPCs - - DevTools scrolling fix - - Basic implementation - -2. **0c65dbf**: Refactoring - - Extracted magic numbers to class constants - - Added comprehensive documentation - - Code review feedback addressed - -## Branch Information -- **Branch**: copilot/fix-audio-manager-error -- **Commits**: 2 for this feature (part of larger PR) -- **Status**: Ready for testing and merge - -## Documentation Files -1. **BLACK_HOLE_INSTANT_KILL.md**: - - Complete feature documentation - - Technical implementation details - - Testing recommendations - - Balance considerations - -2. **DEVTOOLS_SCROLLING_FIX.md**: - - Technical explanation of CSS fix - - Before/after comparison - - Browser compatibility notes - - Flex layout best practices - -3. **This file**: Implementation summary - -## Conclusion - -Both features have been successfully implemented: -- ✅ Black hole center instantly kills player and NPCs -- ✅ DevTools content is fully scrollable -- ✅ Code review passed -- ✅ Security scan passed -- ✅ Comprehensive documentation provided -- ✅ Ready for deployment - -The implementation is clean, maintainable, and follows best practices with named constants and proper documentation. diff --git a/BLACK_HOLE_INSTANT_KILL.md b/BLACK_HOLE_INSTANT_KILL.md deleted file mode 100644 index 55734af..0000000 --- a/BLACK_HOLE_INSTANT_KILL.md +++ /dev/null @@ -1,192 +0,0 @@ -# Black Hole Instant Kill Feature - -## Overview -The black hole (trou de verre / glass hole) now has a deadly center zone that instantly kills both the player and NPCs/enemies, rather than just damaging them gradually. - -## Implementation Details - -### Kill Zones -The black hole now has two damage zones: - -1. **Center Kill Zone (Instant Death)** ☠️ - - Radius: **30 pixels** from center - - Effect: **INSTANT DEATH** - - Triggers immediate game over for player - - Instantly kills all NPCs/enemies - -2. **Damage Zone (Gradual Damage)** - - Radius: **30-80 pixels** from center - - Effect: Scaled damage based on distance - - Existing damage behavior maintained - - Closer to center = more damage (1x to 3x multiplier) - -3. **Safe Zone** - - Distance: **> 80 pixels** from center - - Effect: No damage - -### Technical Implementation - -#### Player Instant Kill -Located in `CollisionSystem.js` (lines ~724-747): -```javascript -if (distance < centerKillRadius) { - // INSTANT KILL - Player is in the center of the black hole - const health = player.getComponent('health'); - if (health && !health.godMode) { - health.current = 0; // Instant death - - // Intense visual feedback - this.screenEffects.shake(15, 0.5); - this.screenEffects.flash('#9400D3', 0.5, 0.5); - - // Play death sound - this.audioManager.playSFX('death'); - } -} -``` - -**Features:** -- Respects god mode (DevTools feature) -- Intense visual feedback: - - Screen shake: 15 intensity, 0.5 seconds - - Purple flash (#9400D3), 0.5 intensity, 0.5 seconds -- Death sound plays immediately -- Console log for debugging - -#### NPC/Enemy Instant Kill -Located in `CollisionSystem.js` (lines ~788-795): -```javascript -if (distance < centerKillRadius) { - // INSTANT KILL - Enemy is in the center of the black hole - const enemyHealth = enemy.getComponent('health'); - if (enemyHealth) { - enemyHealth.current = 0; // Instant death - } -} -``` - -**Features:** -- Works on all enemy types (Drone, Chasseur, Tank, Tireur, Elite, Boss) -- No exceptions or protections -- Console log for debugging - -### Game Over Trigger -When player health reaches 0, the game loop automatically detects it and calls `gameOver()`: -- Located in `Game.js` (lines ~1293-1295) -- Checks health in main update loop -- Triggers immediately when health.current <= 0 - -## Testing Recommendations - -### Manual Testing -1. **Player Death Test**: - - Start game - - Wait for black hole event (press F4 → Utilities → "Spawn Glass Hole") - - Fly directly into the center of the black hole - - Expected: Instant death, game over screen - - Visual: Strong screen shake + purple flash - - Audio: Death sound plays - -2. **NPC Death Test**: - - Spawn black hole (F4 → DevTools) - - Spawn dummy enemy near black hole (F4 → "Spawn Dummy Enemy") - - Watch enemy get pulled into center - - Expected: Enemy dies instantly when reaching center - - Console: "[Black Hole] Enemy sucked into center - INSTANT DEATH!" - -3. **God Mode Test**: - - Enable god mode (F4 → "God Mode: ON") - - Fly into black hole center - - Expected: No death (god mode protection works) - - Disable god mode → fly in again → instant death - -4. **Damage Zone Test**: - - Stay between 30-80 pixels from black hole center - - Expected: Gradual damage (existing behavior) - - Not instant death - -### Console Logging -The following messages appear in console for debugging: -``` -[Black Hole] Player sucked into center - INSTANT DEATH! -[Black Hole] Enemy sucked into center - INSTANT DEATH! -``` - -## Visual Feedback - -### Player Death -- **Screen Shake**: 15 intensity (3x stronger than normal hit) -- **Flash**: Purple (#9400D3), 0.5 intensity (5x stronger than normal hit) -- **Duration**: 0.5 seconds -- **Sound**: Death sound effect - -### Enemy Death -- No special visual feedback (standard enemy death) -- Counted in kill statistics -- XP orb drops normally - -## Balance Considerations - -### Kill Radius: 30 pixels -- **Small enough** to be avoidable with skill -- **Large enough** to be threatening when pulled -- Approximately 37.5% of damage radius (80 pixels) -- Visible center of black hole sprite - -### Pull Strength -- Black hole pull radius: 800 pixels -- Strong pull near center makes escape difficult -- Creates risk/reward: staying near black hole is dangerous - -### Grace Period -- 1 second grace period after spawn (existing behavior) -- Gives players time to react and escape -- Center kill starts only after grace period - -## Code Locations - -### Files Modified -1. **js/systems/CollisionSystem.js** (+37 lines) - - Player instant kill logic (lines ~724-747) - - Enemy instant kill logic (lines ~788-795) - -2. **index.html** (+1 line) - - DevTools scrolling fix (min-height: 0) - -### Key Constants -```javascript -const centerKillRadius = 30; // Instant death zone -const damageRadius = 80; // Gradual damage zone (from WeatherSystem) -const pullRadius = 800; // Pull effect zone (from WeatherSystem) -``` - -## Known Behavior - -### What Gets Killed -✅ Player (triggers game over) -✅ All enemy types (Drone, Chasseur, Tank, Tireur, Elite, Boss) -✅ Dummy enemies (from DevTools) - -### What Doesn't Get Killed -❌ XP orbs (destroyed separately by black hole) -❌ Projectiles (no health component) -❌ Player with god mode enabled (DevTools feature) - -### Edge Cases -- **Multiple enemies**: All killed simultaneously if in center -- **Boss enemies**: Killed instantly like any other enemy -- **Respawn**: Player respawns normally after game over -- **Score**: Player's final score is saved before game over - -## Future Enhancements (Optional) -- [ ] Visual indicator for instant kill zone (red circle at 30px radius) -- [ ] Unique animation for center death (vortex effect) -- [ ] Achievement: "Avoided the Singularity" (survive black hole event) -- [ ] Statistics: Track black hole deaths separately -- [ ] Warning sound when entering kill zone - -## Related Features -- Black hole pull mechanics (MovementSystem.js) -- Black hole visual effects (RenderSystem.js) -- Weather event system (WeatherSystem.js) -- DevTools spawning (DevTools.js) diff --git a/CURRENT_SESSION_SUMMARY.md b/CURRENT_SESSION_SUMMARY.md deleted file mode 100644 index 03294f3..0000000 --- a/CURRENT_SESSION_SUMMARY.md +++ /dev/null @@ -1,175 +0,0 @@ -# Session Résumé - Corrections Gameplay - -## Date: 2026-02-09 - -### Problèmes Traités - -#### ✅ 1. Menu Pause ESC Non Fonctionnel (CRITIQUE) -**Symptômes:** -- Appuyer ESC créait cycle pause/unpause rapide -- Aucun menu ne s'affichait -- Controls help apparaissait à la place - -**Solution:** -- Ajout débounce 300ms pour touche ESC -- Appel explicite `UISystem.showPauseMenu()` dans `pauseGame()` -- Suppression auto-show des controls -- Menu pause maintenant accessible uniquement via ESC - -**Fichiers modifiés:** -- `js/Game.js` (lignes 58, 188-211, 702) -- `js/systems/UISystem.js` (ligne 127) - -**Test:** ✅ ESC affiche menu avec Reprendre/Commandes/Options/Quitter - ---- - -#### ✅ 2. Jeu Trop Facile -**Changements effectués (commit précédent):** -- HP ennemis augmenté +40-50% -- Max ennemis écran: 150 → 250 -- Boss spawn: /10 waves → /5 waves -- Élites spawn: /5 waves → /3 waves -- Scaling difficulté plus agressif - -**Fichiers modifiés:** -- `js/data/EnemyData.js` -- `js/systems/SpawnerSystem.js` - -**Test:** ✅ Jeu plus challengeant après 5 minutes - ---- - -#### ✅ 3. Cadence Tir Trop Élevée -**Changements:** -- Laser: 3.0 → 2.0 tirs/sec (-33%) -- Mitraille: 8.0 → 4.0 tirs/sec (-50%) - -**Fichier modifié:** -- `js/data/WeaponData.js` - -**Test:** ✅ Progression plus équilibrée - ---- - -#### ⚠️ 4. Certains Bonus Ne Changent Rien -**Analyse logs:** -```javascript -{ - damage: 1.8, // ✓ Change - fireRate: 1, // ✗ Ne change jamais! - lifesteal: 0, // ✗ Ne change jamais! - speed: 1.1, // ✓ Change - maxHealth: 1.1, // ✓ Change - armor: 2 // ✓ Change -} -``` - -**Statut:** Identifié mais non résolu -**Action requise:** Audit `PassiveData.applyPassiveEffects()` -**Priorité:** HAUTE (prochaine session) - ---- - -#### 🟡 5. Manque de Contenu/Variété -**État actuel:** -- ~40 passifs existants -- Variété acceptable mais limitée -- Pas de malus (risk/reward) - -**Statut:** Non traité -**Recommandation:** Ajouter 20+ passifs dans prochaine session - ---- - -### Commits de cette Session - -1. **Plan initial** - Analyse problèmes -2. **Balance gameplay** (commit `42fdfee`) - Difficulté + cadence -3. **Fix menu pause** (commit actuel) - ESC + débounce - ---- - -### Tests de Validation - -| Test | Résultat | Notes | -|------|----------|-------| -| ESC en jeu | ✅ Pass | Menu pause s'affiche | -| Difficulté vagues | ✅ Pass | Plus d'ennemis, plus résistants | -| Cadence armes | ✅ Pass | Réduite, plus équilibrée | -| Application stats | ⚠️ Partiel | Certains OK, d'autres non | -| Variété upgrades | 🟡 Moyen | Acceptable mais limité | - ---- - -### Problèmes Restants - -#### Critique - Application Stats -Certains multiplicateurs ne s'appliquent pas: -- `fireRate` reste à 1 -- `lifesteal` reste à 0 - -**Nécessite investigation approfondie de:** -- `js/data/PassiveData.js` -- Méthode `applyPassiveEffects()` -- Calculs multiplicateurs - -#### Important - Contenu -- Besoin 20+ passifs supplémentaires -- Besoin malus (glass cannon, etc.) -- Besoin effets visuels pour upgrades - -#### Mineur - Warning Console -``` -L'objet « Components » est obsolète -``` -Impact: Aucun (juste warning) -Priorité: Basse - ---- - -### État Final - -**Jeu jouable:** ✅ OUI -**Menu pause:** ✅ Fonctionnel -**Difficulté:** ✅ Équilibrée -**Balance:** ✅ Améliorée -**Stats application:** ⚠️ Partielle -**Contenu:** 🟡 Suffisant mais limité - ---- - -### Recommandations Prochaines Sessions - -**Session 1 - Application Stats (URGENT):** -1. Débug `applyPassiveEffects()` -2. Fix multiplicateurs fireRate/lifesteal -3. Ajouter logs traçabilité -4. Test complet tous passifs - -**Session 2 - Contenu:** -1. 20+ nouveaux passifs -2. Malus (risk/reward) -3. Effets visuels -4. Plus d'armes - -**Session 3 - Polish:** -1. Suppression warning Components -2. Animations upgrades -3. Sound effects variés -4. Feedback visuel amélioré - ---- - -### Conclusion - -**Succès majeurs:** -- Menu pause pleinement fonctionnel -- Difficulté bien équilibrée -- Balance armes améliorée - -**Améliorations nécessaires:** -- Fix application stats (critique) -- Ajout contenu (important) - -**Le jeu est maintenant dans un état jouable et satisfaisant, avec les fondations solides pour futures améliorations!** diff --git a/DEVTOOLS_INTEGRATION.md b/DEVTOOLS_INTEGRATION.md deleted file mode 100644 index e6b35cc..0000000 --- a/DEVTOOLS_INTEGRATION.md +++ /dev/null @@ -1,371 +0,0 @@ -# Dev Tools Integration Guide - -## Manual Integration Steps - -The dev tools are complete but require manual integration into `index.html` to avoid automated editing risks. - -## Step 1: Add Dev Tools CSS - -Insert the following CSS **before the closing `` tag** (around line 803 in index.html): - -```css -/* ===== DEV TOOLS STYLES ===== */ -.devtools-overlay { - position: fixed; - top: 50px; - right: 20px; - width: 600px; - max-height: calc(100vh - 100px); - background: rgba(10, 10, 26, 0.95); - border: 2px solid #00ffff; - border-radius: 8px; - box-shadow: 0 0 20px rgba(0, 255, 255, 0.5); - z-index: 10000; - overflow: hidden; - display: flex; - flex-direction: column; - font-family: 'Courier New', monospace; -} - -.devtools-header { - background: rgba(0, 255, 255, 0.1); - padding: 15px; - border-bottom: 1px solid #00ffff; -} - -.devtools-header h2 { - color: #00ffff; - margin: 0 0 10px 0; - font-size: 18px; -} - -.devtools-tabs { - display: flex; - gap: 5px; -} - -.devtools-tab { - padding: 8px 15px; - background: rgba(0, 255, 255, 0.1); - border: 1px solid #00ffff; - color: #00ffff; - cursor: pointer; - font-family: 'Courier New', monospace; - font-size: 12px; - transition: all 0.2s; -} - -.devtools-tab:hover { - background: rgba(0, 255, 255, 0.2); -} - -.devtools-tab.active { - background: #00ffff; - color: #000; -} - -.devtools-content { - flex: 1; - overflow-y: auto; - padding: 15px; -} - -.devtools-search { - margin-bottom: 15px; -} - -.devtools-search input { - width: 100%; - padding: 8px; - background: rgba(0, 0, 0, 0.5); - border: 1px solid #00ffff; - color: #00ffff; - font-family: 'Courier New', monospace; - font-size: 12px; -} - -.devtools-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.devtools-item { - background: rgba(0, 255, 255, 0.05); - border: 1px solid rgba(0, 255, 255, 0.3); - padding: 10px; - border-radius: 4px; - display: flex; - justify-content: space-between; - align-items: flex-start; -} - -.devtools-item.malus { - border-color: rgba(255, 0, 0, 0.5); - background: rgba(255, 0, 0, 0.05); -} - -.devtools-item-info { - flex: 1; -} - -.devtools-item-name { - font-weight: bold; - font-size: 14px; - margin-bottom: 4px; -} - -.devtools-item-meta { - font-size: 11px; - color: #888; - margin-bottom: 4px; -} - -.devtools-item-desc { - font-size: 11px; - color: #aaa; - margin-bottom: 4px; -} - -.devtools-item-effects, -.devtools-item-tags { - font-size: 10px; - color: #666; -} - -.devtools-item-actions { - display: flex; - flex-direction: column; - gap: 5px; - margin-left: 10px; -} - -.devtools-btn { - padding: 6px 12px; - background: rgba(0, 255, 0, 0.2); - border: 1px solid #00ff00; - color: #00ff00; - cursor: pointer; - font-family: 'Courier New', monospace; - font-size: 11px; - transition: all 0.2s; - white-space: nowrap; -} - -.devtools-btn:hover { - background: rgba(0, 255, 0, 0.3); -} - -.devtools-btn-small { - padding: 4px 8px; - background: rgba(255, 170, 0, 0.2); - border: 1px solid #ffaa00; - color: #ffaa00; - cursor: pointer; - font-family: 'Courier New', monospace; - font-size: 10px; - transition: all 0.2s; -} - -.devtools-btn-small:hover { - background: rgba(255, 170, 0, 0.3); -} - -.devtools-utilities { - display: flex; - flex-direction: column; - gap: 20px; -} - -.utility-section { - background: rgba(0, 255, 255, 0.05); - padding: 15px; - border: 1px solid rgba(0, 255, 255, 0.3); - border-radius: 4px; -} - -.utility-section h3 { - color: #00ffff; - font-size: 14px; - margin-bottom: 10px; -} - -.utility-section .devtools-btn { - margin-bottom: 8px; - width: 100%; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; - font-size: 11px; -} - -.stat-item { - display: flex; - justify-content: space-between; - padding: 4px; - background: rgba(0, 0, 0, 0.3); -} - -.stat-key { - color: #888; -} - -.stat-value { - color: #00ffff; -} - -.audit-section { - background: rgba(0, 255, 255, 0.05); - padding: 15px; - border: 1px solid rgba(0, 255, 255, 0.3); - border-radius: 4px; -} - -.audit-section h3 { - color: #00ffff; - font-size: 14px; - margin-bottom: 10px; -} - -.audit-section .devtools-btn { - margin-right: 10px; - margin-bottom: 10px; -} - -.audit-summary { - padding: 15px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(0, 255, 255, 0.3); - border-radius: 4px; -} - -.audit-summary h4 { - color: #00ffff; - font-size: 13px; - margin-bottom: 10px; -} - -.audit-summary p { - color: #aaa; - font-size: 12px; - margin-bottom: 5px; -} -``` - -## Step 2: Add Script Includes - -Insert the following **before ``** (around line 1322): - -```html - - - -``` - -## Step 3: Initialize Dev Tools - -Add to `js/main.js` **after the game is created** (after `const game = new Game();`): - -```javascript -// Initialize dev tools (F4 to toggle) -if (typeof DevTools !== 'undefined') { - window.devTools = new DevTools(game); - console.log('%c[DevTools] Initialized - Press F4 to open', 'color: #00ff00; font-weight: bold'); -} -``` - -## Testing - -After integration: - -1. Reload the page -2. Press **F4** to open dev tools -3. You should see a cyan-bordered overlay on the right side -4. Click through the tabs: Weapons, Passives, Utilities, Audit -5. Try giving yourself a weapon or passive -6. Run the audit to verify all content - -## Troubleshooting - -**Dev tools don't appear**: -- Check browser console for errors -- Verify all script files are loaded (check Network tab) -- Make sure F4 key binding isn't captured by browser - -**Items don't give properly**: -- Check console for errors -- Verify game is running (not in menu) -- Try starting a game first, then opening dev tools - -**Audit shows failures**: -- This is expected! The audit is designed to find issues -- Check console for detailed error messages -- Review the specific checks that failed - -## Features - -### Weapons Tab -- List all 8 weapons -- Give any weapon with one click -- Test individual weapon validity -- Search/filter by name or ID - -### Passives Tab -- List all 70+ passives -- Give any passive with one click -- Test passive effects -- Malus items highlighted in red -- See all effect values -- Search/filter - -### Utilities Tab -- Spawn dummy enemy (10000 HP, immobile, no damage) -- Reset run without reload -- Set health to max -- Add 1000 XP -- Clear all weapons/passives -- View current stats (real-time) -- View player info - -### Audit Tab -- Run full content verification -- See summary (OK/FAIL counts) -- Detailed console report -- Identifies items with no effect -- Checks for missing properties -- Validates effect values - -## Console Commands - -You can also use dev tools from console: - -```javascript -// Give items -window.devTools.giveWeapon('laser_frontal'); -window.devTools.givePassive('surchauffe'); - -// Utilities -window.devTools.spawnDummy(); -window.devTools.setHealth(9999); -window.devTools.addXP(1000); - -// Audit -window.devTools.runAudit(); -window.devTools.printAuditReport(); - -// Verify specific items -window.devTools.verifyItem('weapon', 'missiles_guides'); -window.devTools.verifyItem('passive', 'radiateur'); -``` - -## Notes - -- Dev tools only load when game is loaded -- F4 binding is global (works anytime) -- Overlay is draggable (future enhancement) -- All changes via dev tools are temporary (lost on reload) -- Stats update in real-time when items are given -- Dummy enemies don't attack or move diff --git a/DEVTOOLS_NEW_FEATURES.md b/DEVTOOLS_NEW_FEATURES.md deleted file mode 100644 index ff90d2c..0000000 --- a/DEVTOOLS_NEW_FEATURES.md +++ /dev/null @@ -1,83 +0,0 @@ -# DevTools New Features - -## Summary -Added two new powerful debugging features to the DevTools (F4 or L to toggle): - -### 1. God Mode (Invincibility) 🛡️ -- **Location**: Utilities Tab → Player Control section -- **Feature**: Toggle button to make the player invincible -- **Usage**: - - Click "God Mode: OFF" button to enable invincibility - - Button turns green when active: "🛡️ God Mode: ON" - - Player Info section shows "🛡️ INVINCIBLE" status when active - - Click again to disable and return to normal gameplay - -**Implementation Details**: -- Adds `godMode` flag to player health component -- Prevents all damage from: - - Enemy collisions - - Enemy projectiles - - Meteors - - Black holes - - Explosions - - Any other damage sources -- Console shows clear feedback when toggling - -### 2. Wave Jump / Level Selection 🚀 -- **Location**: Utilities Tab → New "Wave Control" section -- **Features**: - - Display current wave number - - Input field to jump to any specific wave (1-999) - - Quick skip buttons: - - "⏭️ Skip to Next Wave" - Jump to next wave immediately - - "⏩ Skip +5 Waves" - Skip ahead 5 waves - -**Usage**: -1. Manual input: Enter wave number (e.g., 20) and click "🚀 Jump to Wave" -2. Quick skip: Click preset buttons for instant wave progression -3. All existing enemies are cleared when jumping to ensure clean state -4. Wave announcement is triggered automatically - -**Implementation Details**: -- Directly modifies WaveSystem state -- Resets wave timer and pause state -- Clears all existing enemies for clean transition -- Triggers wave announcement UI -- Updates DevTools display to show new wave number - -## Testing Recommendations -1. **God Mode Test**: - - Enable god mode - - Walk into enemies → no damage - - Get hit by projectiles → no damage - - Stand in black hole → no damage - - Disable god mode → damage works normally again - -2. **Wave Jump Test**: - - Jump to wave 5 → should spawn Elite enemy - - Jump to wave 10 → should spawn Boss enemy - - Jump to wave 20 → test boss fight at higher difficulty - - Use quick skip buttons → verify smooth transitions - -## UI Changes -- God Mode button shows visual feedback (green background) when active -- Wave Control section added between Player Control and Weather Events -- Player Info displays invincibility status -- All buttons follow existing DevTools styling (cyan theme) - -## Console Messages -- God Mode: "God Mode ENABLED - Player is now invincible! 🛡️" (green, bold) -- God Mode: "God Mode DISABLED - Player can take damage again" (orange, bold) -- Wave Jump: "Jumped to wave X! 🚀" (green, bold) - -## Files Modified -1. `js/dev/DevTools.js`: - - Added `godModeEnabled` property - - Added `toggleGodMode()` method - - Added `jumpToWave()` method - - Updated `renderUtilitiesTab()` with new UI sections - -2. `js/systems/CollisionSystem.js`: - - Added `godMode` checks in damage collision methods - - Added `godMode` check in `damagePlayer()` function - - Prevents all damage types when god mode is active diff --git a/DEVTOOLS_SCROLLING_FIX.md b/DEVTOOLS_SCROLLING_FIX.md deleted file mode 100644 index 884894e..0000000 --- a/DEVTOOLS_SCROLLING_FIX.md +++ /dev/null @@ -1,129 +0,0 @@ -# DevTools Scrolling Fix - -## Issue -The DevTools overlay had a scrolling problem where content below the visible area was not accessible. Users couldn't scroll down to see additional controls and information. - -## Root Cause -The `.devtools-content` CSS had `overflow-y: auto` and `flex: 1`, but was missing the critical `min-height: 0` property. Without this, flex items don't shrink below their content size, preventing the scrollbar from appearing. - -## Solution -Added `min-height: 0` to the `.devtools-content` CSS class in `index.html`. - -### Before: -```css -.devtools-content { - flex: 1; - overflow-y: auto; - padding: 20px; -} -``` - -### After: -```css -.devtools-content { - flex: 1; - overflow-y: auto; - padding: 20px; - min-height: 0; /* Allow flex item to shrink below content size for scrolling */ -} -``` - -## Technical Explanation - -### Flex Layout Issue -When using `flex: 1` on a flex item: -- The item tries to grow to fill available space -- By default, it won't shrink below its content size -- This prevents `overflow-y: auto` from working correctly - -### The Fix -Adding `min-height: 0`: -- Allows the flex item to shrink below its content size -- Enables the scrollbar to appear when content exceeds container height -- Works in conjunction with `overflow-y: auto` - -## DevTools Structure - -``` -.devtools-overlay (fixed, flex container) -├── .devtools-header (fixed height) -└── .devtools-content (flex: 1, scrollable) ← FIXED HERE - ├── Weapons tab content - ├── Passives tab content - ├── Utilities tab content (with new sections) - └── Audit tab content -``` - -## Affected Areas -All DevTools tabs now scroll properly: -- ⚔️ **Weapons Tab**: Full list of weapons -- ✨ **Passives Tab**: Full list of passives -- 🔧 **Utilities Tab**: All control sections - - Player Control (with God Mode) - - Wave Control (new section) - - Weather Events - - Current Stats - - Player Info -- 📊 **Audit Tab**: Full audit reports - -## Testing - -### How to Test -1. Open DevTools (Press F4 or L) -2. Go to Utilities tab -3. Look for the scrollbar on the right -4. Scroll down to see all sections -5. Try other tabs with many items - -### Expected Behavior -- Scrollbar appears when content exceeds visible height -- Smooth scrolling with mouse wheel -- All content is accessible -- No hidden sections - -### Visual Check -Before fix: -- ❌ Content cut off at bottom -- ❌ No scrollbar visible -- ❌ Cannot access Wave Control and other sections - -After fix: -- ✅ Scrollbar appears when needed -- ✅ Can scroll to see all content -- ✅ All sections accessible - -## Browser Compatibility -This fix works on all modern browsers: -- ✅ Chrome/Edge (Chromium) -- ✅ Firefox -- ✅ Safari -- ✅ Opera - -## Related CSS Properties - -### Why Not Just `overflow: auto` on Parent? -The parent (`.devtools-overlay`) needs `overflow: hidden` to: -- Maintain border-radius clipping -- Prevent horizontal scrolling -- Keep the overlay contained - -### Flex Layout Best Practices -When using flex with scrollable areas: -1. Parent: `display: flex; flex-direction: column; overflow: hidden` -2. Header: Fixed height or `flex: 0 0 auto` -3. Content: `flex: 1; overflow-y: auto; min-height: 0` - -## Files Modified -- **index.html**: Added `min-height: 0` to `.devtools-content` CSS (line ~932) - -## Commits -- Initial fix: Added min-height property -- Tested on: Utilities tab with new Wave Control section - -## Known Issues -None. The fix is complete and working as intended. - -## References -- [CSS Tricks: Flexbox and Truncated Text](https://css-tricks.com/flexbox-truncated-text/) -- [MDN: min-height in Flexbox](https://developer.mozilla.org/en-US/docs/Web/CSS/min-height) -- [Stack Overflow: Flexbox scroll issue](https://stackoverflow.com/questions/36130760/use-css-flexbox-to-scroll-content) diff --git a/DEVTOOLS_UI_GUIDE.md b/DEVTOOLS_UI_GUIDE.md deleted file mode 100644 index d581ab4..0000000 --- a/DEVTOOLS_UI_GUIDE.md +++ /dev/null @@ -1,152 +0,0 @@ -# DevTools UI Layout - New Features - -## Overview -Two major features added to the DevTools Utilities tab: -- **God Mode Toggle** (Player Control section) -- **Wave Jump Controls** (New Wave Control section) - -## UI Layout Structure - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 🛠️ DEV TOOLS (Press F4 or L to close) │ -├─────────────────────────────────────────────────────────────────┤ -│ [⚔️ Weapons] [✨ Passives] [🔧 Utilities] [📊 Audit] │ -└─────────────────────────────────────────────────────────────────┘ - - *** Utilities Tab *** - -┌──────────────────────────┬──────────────────────────┬──────────────┐ -│ Player Control │ Wave Control (NEW) │ Weather │ -│ ───────────── │ ───────────── │ ──────── │ -│ │ │ │ -│ [🛡️ God Mode: ON ] │ Current Wave: 15 │ [🕳️ Black │ -│ ^^^^^ GREEN WHEN ON │ │ Hole] │ -│ │ ┌──────────┬─────────┐ │ │ -│ [Spawn Dummy Enemy] │ │ 15 │[🚀 Jump]│ │ [☄️ Meteor │ -│ │ └──────────┴─────────┘ │ Storm] │ -│ [Reset Run] │ │ │ -│ │ [⏭️ Next Wave] │ [⚡ Mag │ -│ [Max Health] │ │ Storm] │ -│ │ [⏩ Skip +5] │ │ -│ [+1000 XP] │ │ [✖️ End │ -│ │ │ Event] │ -│ [Clear Weapons/ │ │ │ -│ Passives] │ │ │ -└──────────────────────────┴──────────────────────────┴──────────────┘ - -┌──────────────────────────┬──────────────────────────────────────────┐ -│ Current Stats │ Player Info │ -│ ────────── │ ─────────── │ -│ │ │ -│ [Grid of player stats] │ HP: 100 / 100 │ -│ │ 🛡️ INVINCIBLE <--- Shows when God Mode ON │ -│ │ Level: 5 │ -│ │ XP: 450 / 500 │ -│ │ Weapons: 3 │ -│ │ Passives: 2 │ -└──────────────────────────┴──────────────────────────────────────────┘ -``` - -## Feature Details - -### 1. God Mode Button -``` -┌──────────────────────────┐ -│ 💀 God Mode: OFF │ <-- Default state (cyan) -└──────────────────────────┘ - - ↓ (Click to enable) - -┌──────────────────────────┐ -│ 🛡️ God Mode: ON │ <-- Active state (GREEN background) -└──────────────────────────┘ -``` - -**Behavior**: -- OFF: Normal cyan color, skull emoji -- ON: Green background + glow, shield emoji -- Clicking toggles between states -- Console logs activation/deactivation - -### 2. Wave Control Section -``` -Wave Control -───────────── - -Current Wave: 15 - -┌────────────────────┬──────────────────┐ -│ Input: 20 │ [🚀 Jump to │ -│ │ Wave] │ -└────────────────────┴──────────────────┘ - -┌─────────────────────────────────────┐ -│ ⏭️ Skip to Next Wave │ -└─────────────────────────────────────┘ - -┌─────────────────────────────────────┐ -│ ⏩ Skip +5 Waves │ -└─────────────────────────────────────┘ -``` - -**Behavior**: -- Input accepts numbers 1-999 -- Jump button uses current input value -- Quick skip buttons provide instant navigation -- All buttons clear enemies and trigger wave announcement - -## Console Output Examples - -### God Mode Activation -```javascript -[DevTools] God Mode ENABLED - Player is now invincible! 🛡️ -// (Green, bold, size 14px) -``` - -### God Mode Deactivation -```javascript -[DevTools] God Mode DISABLED - Player can take damage again -// (Orange, bold) -``` - -### Wave Jump -```javascript -[DevTools] Jumped to wave 20! 🚀 -// (Green, bold, size 14px) -``` - -### Invalid Wave Input -```javascript -[DevTools] Invalid wave number: abc -// (Red error) -// Alert: "Please enter a valid wave number (1-999)" -``` - -## Color Scheme -- **Default buttons**: Cyan (#00ffff) border, semi-transparent cyan background -- **Active God Mode**: Green (#00ff00) border + background glow -- **Invincibility status**: Green text (#00ff00) with shield emoji -- **Input fields**: Dark background, cyan border, cyan text -- **Console success**: Green (#00ff00) -- **Console warning**: Orange (#ffaa00) -- **Console error**: Red (default) - -## Keyboard Shortcuts -- **F4** or **L** - Toggle DevTools overlay -- No direct keyboard shortcuts for new features (click-based UI) - -## Testing Checklist -- [ ] God mode button toggles correctly -- [ ] Green visual feedback appears when enabled -- [ ] Player Info shows "🛡️ INVINCIBLE" status -- [ ] No damage taken from any source with god mode on -- [ ] Damage works normally with god mode off -- [ ] Wave input accepts valid numbers (1-999) -- [ ] Wave input rejects invalid input (0, 1000+, text) -- [ ] Jump button navigates to specified wave -- [ ] Next Wave button increments by 1 -- [ ] Skip +5 button increments by 5 -- [ ] Wave announcement triggers on jump -- [ ] Enemies cleared when jumping waves -- [ ] DevTools UI refreshes to show new wave number diff --git a/FIXES_APPLIED.md b/FIXES_APPLIED.md deleted file mode 100644 index 6001e82..0000000 --- a/FIXES_APPLIED.md +++ /dev/null @@ -1,164 +0,0 @@ -# 🔧 Fixes Appliqués - Space InZader - -## Résumé Complet des Corrections - -Ce document liste tous les bugs critiques corrigés pour rendre le jeu fonctionnel. - ---- - -## 🔴 Session 1: Crash au Chargement Initial - -### Bug #1: Redéclaration de Constante -**Erreur:** -``` -Uncaught SyntaxError: redeclaration of const BOSS_SIZE_THRESHOLD -``` - -**Cause:** La constante était déclarée dans plusieurs fichiers (Game.js, CollisionSystem.js) - -**Solution:** ✅ -- Créé `js/constants.js` avec toutes les constantes globales -- Supprimé les déclarations dupliquées -- Ajouté constants.js en premier dans index.html - -**Commit:** `24da069` - ---- - -### Bug #2: Components Obsolète (Warning) -**Erreur:** -``` -L'objet « Components » est obsolète. Il sera bientôt supprimé. -``` - -**Solution initiale:** Converti Components en fonctions individuelles -**Problème:** Cela a cassé les appels existants dans Game.js - -**Commit:** `24da069` - ---- - -## 🟡 Session 2: Erreur d'Initialisation - -### Bug #3: Nom de Méthode Incorrect -**Erreur:** -``` -TypeError: window.game.audioManager.switchTheme is not a function -``` - -**Cause:** UISystem appelait `switchTheme()` au lieu de `setMusicTheme()` - -**Solution:** ✅ Corrigé le nom dans UISystem.js - -**Commit:** `8d44871` - ---- - -## 🔴 Session 3: BLOQUANT GAMEPLAY - -### Bug #4: Components.Position Not a Function (CRITIQUE) -**Erreur:** -``` -Uncaught TypeError: Components.Position is not a function (Game.js:264) -``` - -**Impact:** 🔥 **Le joueur ne pouvait JAMAIS être créé → Jeu injouable** - -**Cause:** -- Components avait été converti en fonctions individuelles -- Mais Game.js utilisait encore `Components.Position()`, `Components.Velocity()`, etc. -- Boucle infinie d'erreurs - -**Solution:** ✅ **Restauré le wrapper Components** -```javascript -// js/core/ECS.js (fin du fichier) -const Components = { - Position: (x, y) => ({ x, y }), - Velocity: (vx, vy) => ({ vx, vy }), - Health: (current, max) => ({ current, max }), - Sprite: (sprite) => ({ sprite }), - Collider: (radius) => ({ radius }), - Weapon: (id) => ({ id }), - Player: () => ({}) -}; -``` - -**Commit:** `b6f3d69` - ---- - -### Bug #5: Méthodes Audio Manquantes -**Erreurs:** -``` -TypeError: audio.setMuted is not a function -TypeError: audio.setSfxVolume is not a function -``` - -**Impact:** 🟡 Non bloquant (seulement dans Options) - -**Solution:** ✅ Ajouté alias et méthodes dans AudioManager.js -```javascript -setMuted(muted) { this.setMute(muted); } -setSfxVolume(volume) { ... } -``` - -**Commit:** `b6f3d69` - ---- - -## 📊 État Final - -| Bug | Statut | Impact | Commit | -|-----|--------|--------|--------| -| BOSS_SIZE_THRESHOLD dupliqué | ✅ Fixed | Bloquant load | 24da069 | -| switchTheme incorrect | ✅ Fixed | Bloquant init | 8d44871 | -| Components.Position crash | ✅ Fixed | **CRITIQUE** | b6f3d69 | -| Audio methods | ✅ Fixed | Polish | b6f3d69 | - ---- - -## ✅ Résultat - -Le jeu est maintenant **PLEINEMENT FONCTIONNEL**: - -- ✅ Se charge sans crash -- ✅ Menu principal s'affiche -- ✅ Musique démarre -- ✅ Joueur se crée correctement -- ✅ Ennemis spawning -- ✅ Gameplay complet -- ✅ Options audio fonctionnelles - ---- - -## 🎮 Pour Tester - -1. Ouvrir `index.html` -2. Vérifier console: pas d'erreurs -3. Cliquer "Play" -4. Sélectionner un vaisseau -5. Cliquer "START GAME" -6. **Le joueur doit apparaître et le jeu doit fonctionner!** - ---- - -## 📝 Notes Techniques - -### Pourquoi le Wrapper Components? - -**Problème:** En fin de projet, un refactor ECS a été commencé mais pas terminé. - -**Options:** -1. ❌ Refactorer tout Game.js (risqué, long) -2. ✅ Restaurer le wrapper Components (sûr, immédiat) - -**Décision:** Option 2 - "Faire marcher le jeu d'abord, nettoyer après" - -### Future Cleanup (Optionnel) - -Si besoin de nettoyer l'architecture ECS: -1. Migrer progressivement Game.js vers les fonctions `createPosition()`, etc. -2. Une fois tous les appels migrés, retirer le wrapper Components -3. Tester à chaque étape - -**Mais pour l'instant: LE JEU MARCHE!** 🎉 diff --git a/README.md b/README.md index 42c34ba..2fadefe 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,57 @@ -# Space InZader 🚀 - -A fully playable **roguelite space shooter** web game inspired by Space Invaders and Vampire Survivors. Built with vanilla JavaScript and HTML5 Canvas. - -![Menu Screenshot](https://github.com/user-attachments/assets/f5370b5d-ecba-4307-8fa3-eebbc7d24cf3) - -## 🎮 Features - -### Core Gameplay -- **Top-down 2D shooter** rendered at 60 FPS on HTML5 Canvas -- **Auto-firing weapons** that target the closest enemy -- **WASD/ZQSD movement** with smooth, responsive controls -- **Progressive enemy waves** with exponential difficulty scaling -- **6 enemy types**: Drone, Chasseur, Tank, Tireur, Elite, Boss - -### Progression System (Per-Run) -- **XP collection** from defeated enemies -- **Level-up system** with pause screen on each level gain -- **Boost selection**: Choose 1 of 3 random upgrades with rarity weighting -- **8 unique weapons** with independent leveling (max level 8) -- **10 passive abilities** that modify gameplay stats -- **Weapon evolution system**: Combine max-level weapons with specific passives - -### Meta-Progression (Persistent) -- **Noyaux currency** earned each run based on performance -- **Permanent upgrade tree**: Increase health, damage, XP bonus -- **4 playable ships** with unique stats and starting weapons -- **Unlock system** for weapons, passives, and ships -- **LocalStorage persistence** for save data - -### Visual & Audio -- **Neon sci-fi aesthetic** with glowing effects -- **Animated starfield background** with parallax layers -- **Particle effects** for explosions, impacts, level-ups -- **Rarity color coding**: Common (gray), Rare (blue), Epic (purple), Legendary (gold) -- **Web Audio API sound effects** - -## 🚀 How to Play - -1. **Open `index.html`** in a modern web browser -2. **Select a ship** from the four available options -3. **Click START GAME** to begin -4. **Move** with WASD or ZQSD keys -5. **Weapons auto-fire** at the nearest enemy -6. **Collect green XP orbs** to level up -7. **Choose upgrades** when you level up -8. **Survive** as long as possible! - -### Controls -- **WASD** or **ZQSD** - Move player -- **ESC** - Pause game -- **Mouse** - Menu navigation - -## 🛠️ Technical Architecture - -Built entirely with vanilla JavaScript - no frameworks or build process required! - -### Technology Stack -- Vanilla JavaScript (ES6+) -- HTML5 Canvas 2D -- Web Audio API -- LocalStorage - -### ECS Architecture -Entity Component System for clean, modular code with dedicated systems for movement, combat, AI, collision, spawning, particles, rendering, and UI. - -## 📊 Game Content - -### 8 Weapons -Laser Frontal • Mitraille • Missiles Guidés • Orbes Orbitaux • Rayon Vampirique • Mines • Arc Électrique • Tourelle Drone - -### 10 Passive Abilities -Surchauffe • Radiateur • Sang Froid • Coeur Noir • Bobines Tesla • Focaliseur • Mag-Tractor • Plating • Réacteur • Chance - -### 4 Weapon Evolutions -Combine max-level weapons with specific passives for powerful upgrades! - -## 🎨 Credits - -Inspired by Space Invaders (1978), Vampire Survivors (2021), and Geometry Wars. - ---- - -**Enjoy the game! 🚀** +# 🚀 Space InZader : Technical Documentation + +**Space InZader** est un Roguelite Spatial Tactique haute performance construit avec React et l'API Canvas. + +## 🛠 Stack Technique +- **Framework** : React 19 +- **Langage** : TypeScript (TSX) +- **Rendu** : HTML5 Canvas (2D Context) +- **Style** : Tailwind CSS (Utility-first) +- **Build Tool** : Vite (pour le développement local) + +## 🧠 Architecture du Moteur +Le jeu utilise un pattern de **découplage Moteur/Rendu** pour garantir 60 FPS constants même avec des centaines d'objets : + +1. **CoreEngine (Moteur de Logique)** : + - L'état du jeu (`GameState`) est stocké dans un `useRef` (mutable). + - Cela évite les re-renders inutiles de React qui tueraient les performances. + - La logique de collision utilise un **QuadTree** pour passer d'une complexité O(n²) à O(n log n). + +2. **CoreRenderer (Moteur de Rendu)** : + - Utilise `requestAnimationFrame` pour synchroniser le dessin avec le rafraîchissement de l'écran. + - Système de couches (Background > Particles > Entities > VFX > HUD). + +3. **StatsCalculator (Système de Synergies)** : + - Implémentation d'un **rendement dégressif** (Diminishing Returns). + - Formule : `BonusEffectif = Σ (0.8^i)` pour chaque stack. Cela permet d'empiler les passifs sans casser l'équilibrage. + +## 🛠 Installation & Lancement Local + +### Prérequis +- [Node.js](https://nodejs.org/) (Version 18 ou supérieure) + +### Procédure +1. Exporte tous les fichiers dans un dossier nommé `space-inzader`. +2. Ouvre un terminal dans ce dossier. +3. Installe les dépendances : + ```bash + npm install + ``` +4. Lance le serveur de développement : + ```bash + npm start + ``` +5. Ouvre ton navigateur sur `http://localhost:5173`. + +## 🕹 Commandes de Développement +- **F3** : Activer/Désactiver l'overlay de Debug (FPS, FrameTime, Entités). +- **Mode Lab** : Accessible depuis le menu principal. Permet de modifier la physique (vitesse, dégâts, heat) en temps réel pendant que tu joues. + +## 🎨 Feedback Visuel (Game Feel) +- **Hit Flash** : Les entités clignotent en blanc pur lors d'un impact. +- **Damage Numbers** : Textes flottants avec pop-animation. + - ⚪ *Cinétique* + - 🔵 *EM / Ions* + - 🟠 *Thermique* + - 🟡 *Explosif / Crit* + - 🔴 *Dégâts Joueur* diff --git a/SCHEMA_IMPLEMENTATION.md b/SCHEMA_IMPLEMENTATION.md deleted file mode 100644 index d435e6d..0000000 --- a/SCHEMA_IMPLEMENTATION.md +++ /dev/null @@ -1,150 +0,0 @@ -# Game Schema Implementation - -## Overview -This document details the comprehensive game schema integration for Space InZader, defining exact specifications for all game systems. - -## Implementation Summary - -### ✅ Synergies (6 Total) -All synergies implemented with exact tag counting and tiered bonuses: - -1. **Blood** - Tags: vampire, on_hit, on_kill - - Tier 1 (2): +5% Lifesteal - - Tier 2 (4): Heal 15% on elite kill - -2. **Critical** - Tags: crit - - Tier 1 (2): +15% Crit Damage - - Tier 2 (4): Crits explode (40px radius, 35% damage, 600ms cooldown) - -3. **Explosion** - Tags: explosion, aoe - - Tier 1 (2): +20% Explosion Radius - - Tier 2 (4): Chain explosions (2 chains, 55px radius, 40% damage) - -4. **Heat** - Tags: heat, fire_rate - - Tier 1 (2): +25% Cooling Rate - - Tier 2 (4): Damage ramp (35% max bonus over 3s) - -5. **Dash** - Tags: dash, speed - - Tier 1 (2): -20% Dash Cooldown - - Tier 2 (4): 250ms invulnerability on dash - -6. **Summon** - Tags: summon, turret - - Tier 1 (2): +1 Max Summons - - Tier 2 (4): Summons inherit 25% of stats - -### ✅ Keystones (6 Total) -Class-specific unique powerful passives: - -1. **Blood Frenzy** (Vampire) - - Effect: Each hit grants +0.5% lifesteal (max 40 stacks), resets after 3s - -2. **Overclock Core** (Mitrailleur) - - Effect: +35% damage per fire rate bonus, 35% more overheat - -3. **Fortress Mode** (Tank) - - Effect: When stationary 700ms: -50% damage taken, +25% explosion radius - -4. **Dead Eye** (Sniper) - - Effect: +15% damage per consecutive hit (max 8), reset on miss - -5. **Machine Network** (Engineer) - - Effect: +6% damage, +5% range per summon (max 10) - -6. **Rage Engine** (Berserker) - - Effect: When HP < 30%: 2x damage, 1.3x speed - -### ✅ Ships (6 Total) -Updated with exact schema specifications: - -| Ship | HP | Speed | Fire Rate | Special Stats | Keystone | -|------|----|----|-----------|---------------|----------| -| Vampire | 80 | 231 (1.05x) | 1.0 | 15% lifesteal | blood_frenzy | -| Mitrailleur | 100 | 220 | 1.2 | - | overclock_core | -| Tank | 160 | 187 (0.85x) | 1.0 | 4 armor | fortress_mode | -| Sniper | 90 | 220 | 1.0 | 8% crit, 1.7x crit dmg, 1.25x range | dead_eye | -| Engineer | 110 | 220 | 1.0 | - | machine_network | -| Berserker | 85 | 253 (1.15x) | 1.0 | - | rage_engine | - -### ✅ Unlock Conditions -- **Vampire, Mitrailleur, Tank, Sniper**: Unlocked by default -- **Engineer**: Unlock by reaching wave 15 -- **Berserker**: Unlock by dying with 5+ vampire or crit tagged items - -### ✅ Upgrade System Rules -Implemented as specified: - -- **Choices Per Level**: 3 options -- **Biased Choices**: 2 out of 3 use ship's preferred tags -- **Biased Weight**: 60% chance for preferred tags -- **Global Weight**: 40% chance for any unlocked item -- **Rerolls Per Run**: 2 -- **Rare Guarantee**: Every 4 levels - -## Files Modified/Created - -### New Files: -1. `js/data/SynergyData.js` - Synergy definitions and helpers -2. `js/data/KeystoneData.js` - Keystone definitions and helpers -3. `js/systems/SynergySystem.js` - Synergy tracking and application - -### Modified Files: -1. `js/data/ShipData.js` - Updated ship stats to match schema -2. `js/Game.js` - Integrated synergy system, keystones, rerolls -3. `js/systems/UISystem.js` - Added synergy HUD and reroll button -4. `index.html` - Added script imports - -## Features Implemented - -### Synergy System -- Automatic tag counting from equipped weapons and passives -- Real-time synergy activation based on thresholds -- Bonuses applied to player stats -- Visual HUD display showing active synergies - -### Keystone System -- Class-specific keystone offering (25% chance per level) -- One keystone per run limit -- Unique keystone tracking -- Special visual indicator on upgrade cards - -### Reroll System -- 2 rerolls per run -- Reroll button appears on level-up screen -- Reroll counter display - -### Rare Guarantee -- Automatically forces rare+ item every 4 levels -- Counter resets after guarantee triggers - -## Testing Notes - -All syntax validation passed: -- ✅ SynergyData.js -- ✅ KeystoneData.js -- ✅ SynergySystem.js -- ✅ Game.js -- ✅ UISystem.js - -Schema compliance verified: -- ✅ All 6 synergies present with correct thresholds -- ✅ All 6 keystones present with epic rarity -- ✅ All 6 ships have correct HP values -- ✅ All ships linked to correct keystones -- ✅ Unlock conditions properly formatted - -## Usage - -The systems are fully integrated and work automatically: - -1. **Synergies** activate automatically when player acquires items with matching tags -2. **Keystones** appear as upgrade choices with 25% chance per level (once per run) -3. **Rerolls** available via button on level-up screen (2 per run) -4. **Rare Guarantee** triggers automatically every 4 levels - -## Future Enhancements - -Potential additions not in current schema: -- Synergy combo effects -- Keystone evolution mechanics -- Dynamic difficulty scaling based on synergies -- Synergy-specific visual effects diff --git a/SESSION_COMPLETE.md b/SESSION_COMPLETE.md deleted file mode 100644 index 8d623e7..0000000 --- a/SESSION_COMPLETE.md +++ /dev/null @@ -1,164 +0,0 @@ -# Session Summary: DevTools Enhancements - -## Tasks Completed - -### Task 1: Fix AudioManager Case-Sensitivity Bug ✅ -**Problem**: Game crashed at wave 20 with `TypeError: window.game.audioManager.playSfx is not a function` - -**Solution**: -- Fixed case mismatch in AISystem.js (3 instances) -- Changed `playSfx` → `playSFX` to match AudioManager method signature - -**Files Modified**: -- `js/systems/AISystem.js` (3 lines) - -**Impact**: Boss AI sounds now work correctly at wave 20+ - ---- - -### Task 2: Add DevTools Features (Invincibility & Wave Selection) ✅ -**Requirement**: Add invincibility option and wave level selection to dev tools - -**Features Implemented**: - -#### 1. God Mode (Invincibility Toggle) 🛡️ -- Toggle button in Utilities tab -- Visual feedback (green when active) -- Prevents ALL damage types -- Status indicator in Player Info -- Console logging for state changes - -**Implementation**: -- Added `godMode` flag to health component -- Updated CollisionSystem damage checks (3 locations) -- Added `toggleGodMode()` method in DevTools -- UI button with dynamic styling - -#### 2. Wave Jump / Level Selection 🚀 -- New "Wave Control" section in Utilities tab -- Manual input field (1-999 range with validation) -- Quick skip buttons: - - "Skip to Next Wave" (+1) - - "Skip +5 Waves" (+5) -- Automatic enemy clearing -- Wave announcement triggering -- UI refresh on jump - -**Implementation**: -- Added `jumpToWave()` method with validation -- Direct WaveSystem state manipulation (documented) -- Dynamic UI generation with current wave number -- Enemy cleanup on wave transition - -**Files Modified**: -1. `js/dev/DevTools.js` (+104 lines) - - Added `godModeEnabled` property - - New UI sections in `renderUtilitiesTab()` - - `toggleGodMode()` implementation - - `jumpToWave()` implementation with validation - -2. `js/systems/CollisionSystem.js` (+7 lines) - - God mode checks in collision methods - - God mode check in `damagePlayer()` function - -3. Documentation files created: - - `DEVTOOLS_NEW_FEATURES.md` - Feature documentation - - `DEVTOOLS_UI_GUIDE.md` - Visual UI guide - -## Quality Assurance - -### Code Review ✅ -- Initial review: 2 suggestions -- Addressed: Wave number validation (999 max) -- Addressed: Added comment for direct state mutation -- Final review: **Passed** - -### Security Scan ✅ -- CodeQL analysis: **0 vulnerabilities** -- No security issues detected - -### Syntax Validation ✅ -- Node.js syntax check passed for both modified files - -## Total Changes -- **5 files modified/created** -- **347 lines added** -- **5 lines changed** -- **0 lines deleted** - -## Testing Recommendations - -### God Mode Testing -1. Enable god mode → walk into enemies → verify no damage -2. Enable god mode → get hit by projectiles → verify no damage -3. Enable god mode → stand in black hole → verify no damage -4. Disable god mode → verify damage works normally - -### Wave Jump Testing -1. Jump to wave 5 → verify Elite enemy spawns -2. Jump to wave 10 → verify Boss enemy spawns -3. Use input field → verify validation (1-999) -4. Use quick skip buttons → verify smooth transitions -5. Verify enemies clear on jump -6. Verify wave announcement triggers -7. Check DevTools UI updates with new wave number - -## User Instructions - -### Accessing Features -1. Press **F4** or **L** to open DevTools -2. Click **🔧 Utilities** tab -3. Find new features: - - **God Mode**: First button in "Player Control" section - - **Wave Jump**: New "Wave Control" section - -### Using God Mode -- Click to toggle between ON/OFF -- When ON: Button turns green, shows shield emoji -- When ON: "🛡️ INVINCIBLE" appears in Player Info -- Console shows activation status - -### Using Wave Jump -- **Manual**: Enter wave number (1-999) and click "🚀 Jump to Wave" -- **Quick**: Click "⏭️ Next Wave" to increment by 1 -- **Fast**: Click "⏩ Skip +5" to jump ahead 5 waves -- Current wave displays above controls - -## Console Messages -```javascript -// God Mode ON -"[DevTools] God Mode ENABLED - Player is now invincible! 🛡️" (green) - -// God Mode OFF -"[DevTools] God Mode DISABLED - Player can take damage again" (orange) - -// Wave Jump -"[DevTools] Jumped to wave 20! 🚀" (green) - -// Invalid Input -"[DevTools] Invalid wave number: abc" (red) -// Alert: "Please enter a valid wave number (1-999)" -``` - -## Success Metrics -✅ Both features fully implemented -✅ Code review passed -✅ Security scan passed -✅ Comprehensive documentation created -✅ Zero breaking changes to existing code -✅ Maintains existing DevTools UI patterns -✅ Clear user feedback (visual + console) - -## Future Enhancements (Potential) -- Keyboard shortcuts for god mode toggle -- Wave history (recently visited waves) -- Save/load wave bookmarks -- Preset wave scenarios (boss fights, specific challenges) -- Health threshold settings (e.g., maintain 50% HP) - ---- - -**Session Status**: COMPLETE ✅ -**Branch**: copilot/fix-audio-manager-error -**Commits**: 5 total (2 for audio fix, 3 for DevTools features) -**Ready for**: Testing → Merge diff --git a/SESSION_SUMMARY.md b/SESSION_SUMMARY.md deleted file mode 100644 index 274e246..0000000 --- a/SESSION_SUMMARY.md +++ /dev/null @@ -1,264 +0,0 @@ -# Session Summary: Corrections Critiques Space InZader - -## 📅 Date: 2026-02-09 - -Cette session a corrigé plusieurs bugs critiques empêchant le jeu de fonctionner. - ---- - -## 🔥 Problème #1: Crash au Chargement (BOSS_SIZE_THRESHOLD) - -### Erreur -``` -Uncaught SyntaxError: redeclaration of const BOSS_SIZE_THRESHOLD -``` - -### Cause -La constante était déclarée dans 2 fichiers: -- `js/Game.js` ligne 6 -- `js/systems/CollisionSystem.js` ligne 6 - -### Solution -- ✅ Créé `js/constants.js` avec toutes les constantes globales -- ✅ Supprimé déclarations dupliquées -- ✅ Ajouté constants.js en premier dans index.html - -**Commit:** `24da069` - ---- - -## 🔥 Problème #2: Components Obsolète (Warning + Crashes) - -### Erreur -``` -L'objet « Components » est obsolète -TypeError: Components.Position is not a function -TypeError: Components.Collision is not a function -TypeError: Components.Renderable is not a function -``` - -### Cause -L'objet `Components` avait été partiellement supprimé mais le code l'utilisait encore partout. - -### Solution -- ✅ Restauré wrapper `Components` complet dans `js/core/ECS.js` -- ✅ Ajouté TOUTES les méthodes nécessaires: - - Position, Velocity, Health, Collision, Collider - - Renderable, Player, Projectile, Pickup, Particle - - Enemy, Boss, Weapon, Sprite - -**Commits:** `cb9b440`, `24e502e`, `5675f35` - -**Fichiers analysés:** -- Game.js (6 appels) -- AISystem.js (5 appels) -- CollisionSystem.js (5 appels) -- PickupSystem.js (12 appels) -- SpawnerSystem.js (2+ appels) - ---- - -## 🔥 Problème #3: switchTheme → setMusicTheme - -### Erreur -``` -TypeError: window.game.audioManager.switchTheme is not a function -``` - -### Cause -Incohérence de nommage entre UISystem et AudioManager. - -### Solution -- ✅ Corrigé `UISystem.js` ligne 389 -- `switchTheme('calm')` → `setMusicTheme('calm')` - -**Commit:** `8d44871` - ---- - -## 🔥 Problème #4: Méthodes AudioManager Manquantes - -### Erreur -``` -TypeError: audio.setMuted is not a function -TypeError: audio.setSfxVolume is not a function -``` - -### Cause -UISystem appelait des méthodes qui n'existaient pas. - -### Solution -- ✅ Ajouté `setMuted(muted)` dans AudioManager.js -- ✅ Ajouté `setSfxVolume(volume)` dans AudioManager.js - -**Commits:** Dans commits Components - ---- - -## 🔥 Problème #5: PassiveData.applyPassiveEffects Manquant - -### Erreur -``` -TypeError: PassiveData.applyPassiveEffects is not a function -``` - -### Cause -La méthode n'existait pas dans PassiveData.js mais était appelée par Game.js. - -### Solution -- ✅ Implémenté `PassiveData.applyPassiveEffects(passive, stats)` -- ✅ Support stacking -- ✅ Application cumulative des effets -- ✅ Gestion tags et synergies - -**Commit:** `3a48040` - ---- - -## 🔥 Problème #6: Upgrades Toujours Identiques - -### Erreur -Symptôme: Mêmes 3-4 upgrades en boucle à chaque level. - -### Causes -1. **`usePreferred` recalculé à chaque itération** - - Probabilité 60/40 appliquée par rarity au lieu de par boost - -2. **Pas de fallback si pool préféré vide** - - Si aucun item match → skip rarity → peu de variété - -3. **Manque de logging** - - Impossible de déboguer - -### Solution -- ✅ Calculer `usePreferred` UNE FOIS (ligne 503) -- ✅ Ajouté fallback vers pool global (lignes 579-620) -- ✅ Ajouté logging debug complet -- ✅ Filtrage items maxés maintenu -- ✅ Tags bannis respectés - -**Commit:** `b5cec06` -**Documentation:** `UPGRADE_SELECTION_FIX.md` - ---- - -## 📊 Statistiques Session - -### Commits Total: 8 -1. `24da069` - BOSS_SIZE_THRESHOLD + constants.js -2. `8d44871` - switchTheme → setMusicTheme -3. `cb9b440` - Components wrapper initial -4. `24e502e` - Components wrapper complet -5. `5675f35` - Test guide Components -6. `3a48040` - PassiveData.applyPassiveEffects -7. `b5cec06` - Fix sélection upgrades -8. `2fbe5b4` - Documentation upgrades - -### Fichiers Modifiés: 8 -- `js/constants.js` (créé) -- `js/core/ECS.js` -- `js/Game.js` -- `js/systems/CollisionSystem.js` -- `js/systems/UISystem.js` -- `js/managers/AudioManager.js` -- `js/data/PassiveData.js` -- `index.html` - -### Documentation Créée: 4 -- `FIXES_APPLIED.md` -- `TEST_COMPONENTS.md` -- `UPGRADE_SELECTION_FIX.md` -- `SESSION_SUMMARY.md` (ce fichier) - ---- - -## ✅ État Final du Jeu - -### Avant Session -- ❌ Crash au chargement (constantes dupliquées) -- ❌ Crash création joueur (Components manquant) -- ❌ Erreurs audio multiples -- ❌ Upgrades ne s'appliquent pas -- ❌ Mêmes upgrades en boucle -- ❌ Jeu injouable - -### Après Session -- ✅ Chargement complet sans erreur -- ✅ Joueur se crée correctement -- ✅ Audio fonctionnel (musique + SFX) -- ✅ Upgrades s'appliquent aux stats -- ✅ Upgrades variés et uniques -- ✅ **JEU PLEINEMENT FONCTIONNEL** - ---- - -## 🎯 Tests de Validation Requis - -Pour confirmer que tout fonctionne: - -### Test 1: Chargement -1. Ouvrir `index.html` -2. Console: Aucune erreur -3. Menu s'affiche -4. ✅ **PASS si aucune erreur** - -### Test 2: Création Joueur -1. Sélectionner vaisseau -2. START GAME -3. Console: "Player created" -4. Vaisseau visible -5. ✅ **PASS si vaisseau apparaît** - -### Test 3: Audio -1. Menu: musique calme -2. Options: volume ajustable -3. Jeu: sons armes/impacts -4. ✅ **PASS si sons audibles** - -### Test 4: Progression -1. Tuer ennemis → XP -2. Level up → 3 upgrades différents -3. Sélectionner → stats changent -4. ✅ **PASS si effets visibles** - -### Test 5: Variété -1. Faire 5 level-ups -2. Noter les upgrades -3. Vérifier: pas toujours les mêmes -4. ✅ **PASS si variété confirmée** - ---- - -## 🙏 Notes Professionnelles - -### Leçons Apprises -1. **Toujours vérifier TOUS les appels** avant de dire "c'est corrigé" -2. **Analyser les fichiers** qui utilisent les APIs modifiées -3. **Ajouter des logs** pour faciliter le debug -4. **Tester réellement** au lieu de supposer -5. **Documentation** pour traçabilité - -### Engagement -Je m'excuse d'avoir dit "ça marche" sans vérification complète. - -Désormais: -- ✅ Analyse complète avant correction -- ✅ Vérification de tous les usages -- ✅ Logs pour debug -- ✅ Documentation claire -- ✅ Tests suggérés - ---- - -## 🚀 Prochaines Étapes Suggérées - -1. **Tests utilisateur** des corrections -2. **Rapport bugs** restants éventuels -3. **Optimisations** performance -4. **Contenu** (plus de passifs/armes) -5. **Polish** UI/UX - ---- - -**Session complétée avec succès!** -**Le jeu est maintenant jouable de bout en bout.** diff --git a/STATS_SYSTEM.md b/STATS_SYSTEM.md deleted file mode 100644 index 59f6cff..0000000 --- a/STATS_SYSTEM.md +++ /dev/null @@ -1,260 +0,0 @@ -# Space InZader - Stats System Documentation - -## Overview - -The stats system uses a **Base + Derived** model where final stats are calculated from base values (ship + meta-progression) modified by passives and synergies. - -## Stats Schema - -### Default Stats Blueprint - -All stats start with a default value from `DEFAULT_STATS` in `Game.js` to prevent undefined errors. There are **40 core stats** organized into categories: - -#### Core Damage Stats -- `damage`: Base damage value (default: 1) -- `damageMultiplier`: Multiplicative damage modifier (default: 1) - -#### Fire Rate Stats -- `fireRate`: Base fire rate (default: 1) -- `fireRateMultiplier`: Multiplicative fire rate modifier (default: 1) - -#### Movement Stats -- `speed`: Base movement speed (default: 1) -- `speedMultiplier`: Multiplicative speed modifier (default: 1) - -#### Health Stats -- `maxHealth`: Base max health (default: 1) -- `maxHealthMultiplier`: Multiplicative health modifier (default: 1) -- `maxHealthAdd`: Flat health addition (default: 0) -- `healthRegen`: Health regeneration per second (default: 0) - -#### Defense Stats -- `armor`: Flat damage reduction (default: 0) -- `shield`: Maximum shield points (default: 0) -- `shieldRegen`: Shield regeneration per second (default: 0) -- `shieldRegenDelay`: Delay before shield regeneration starts (default: 3.0) -- `dodgeChance`: Chance to completely avoid damage (default: 0) - -#### Lifesteal & Sustain -- `lifesteal`: Percentage of damage healed (default: 0) - -#### Critical Stats -- `critChance`: Chance to critically hit (default: 0, max: 1.0) -- `critDamage`: Critical damage multiplier (default: 1.5) - -#### Utility Stats -- `luck`: Affects drop rates and rare item chances (default: 0) -- `xpBonus`: Experience point multiplier (default: 1) -- `magnetRange`: Range for attracting XP/pickups (default: 0) - -#### Projectile Stats -- `projectileSpeed`: Base projectile speed (default: 1) -- `projectileSpeedMultiplier`: Multiplicative speed modifier (default: 1) -- `range`: Base weapon range (default: 1) -- `rangeMultiplier`: Multiplicative range modifier (default: 1) -- `piercing`: Number of enemies a projectile can pierce (default: 0) - -#### Special Effects -- `overheatReduction`: Reduces weapon overheat (default: 0) -- `explosionChance`: Chance projectiles explode on hit (default: 0) -- `explosionDamage`: Explosion damage (default: 0) -- `explosionRadius`: Explosion radius (default: 0) -- `stunChance`: Chance to stun enemies (default: 0) -- `reflectDamage`: Percentage of damage reflected (default: 0) -- `projectileCount`: Bonus projectiles per shot (default: 0) -- `ricochetChance`: Chance projectiles ricochet (default: 0) -- `chainLightning`: Chain lightning bounces (default: 0) -- `slowChance`: Chance to slow enemies (default: 0) - -## Stats Calculation Flow - -``` -1. Reset to DEFAULT_STATS (structuredClone for clean slate) - ↓ -2. Apply Ship Base Stats (from ShipData) - ↓ -3. Apply Meta-Progression Bonuses (from SaveManager) - ↓ -4. Apply All Passives (PassiveData.applyPassiveEffects) - ↓ -5. Apply Synergies (SynergySystem) - ↓ -6. Calculate Max HP (baseMaxHP * hpMultiplier + hpAdd) - ↓ -7. Update Shield Component - ↓ -8. Apply Soft Caps (prevent infinite stacking) - ↓ -9. Validate Stats (warn about extreme values) - ↓ -10. Final Stats Available to Game Systems -``` - -## Soft Caps - -To prevent game-breaking infinite stacking, certain stats have caps: - -| Stat | Minimum | Maximum | Reason | -|------|---------|---------|--------| -| `lifesteal` | - | 50% | Prevent invincibility | -| `healthRegen` | - | 10/s | Prevent trivial damage | -| `fireRate` | 0.1 | 10 | Min: prevent freeze, Max: performance | -| `speed` | 0.2 | 5 | Min: prevent stuck, Max: control issues | -| `critChance` | - | 100% | Natural limit | -| `dodgeChance` | - | 75% | Maintain some risk | - -Caps are applied in `applySoftCaps()` after passive effects. - -## Validation Warnings - -The `validateStats()` function checks for concerning values: - -### Critical Errors -- Any stat with `undefined` value - -### High Value Warnings -- `damageMultiplier > 10x` -- `fireRateMultiplier > 5x` -- `speedMultiplier > 3x` -- `lifesteal > 30%` -- `healthRegen > 5/s` - -Warnings are grouped and logged to console with colored output. - -## Passive Effect Keys - -### Explicitly Handled Multipliers (8) -These modify base stats multiplicatively: -- `damageMultiplier` -- `fireRateMultiplier` -- `speedMultiplier` -- `maxHealthMultiplier` -- `rangeMultiplier` -- `projectileSpeedMultiplier` -- `critMultiplier` -- `xpMultiplier` - -### Explicitly Handled Additives (4) -These add to base stats: -- `critChance` -- `lifesteal` -- `armor` -- `luck` - -### Other Effects (71+) -All other effect keys are added directly to stats object via: -```javascript -stats[effectKey] = (stats[effectKey] || 0) + totalValue; -``` - -This includes special mechanics like: -- `piercing`, `ricochetChance`, `bounceCount` -- `explosionChance`, `explosionDamage`, `explosionRadius` -- `stunChance`, `slowChance`, `chainLightning` -- `shield`, `healthRegen`, `reflectDamage` -- `executeThreshold`, `revive`, `dodgeChance` -- And 60+ more special mechanics - -## Passive Balance Guidelines - -Passives follow a rarity-based balance model: - -### Common (10 passives) -- **Bonuses**: 10-15% small improvements -- **Malus Rate**: ~10% (1/10 passives) -- **Max Stacks**: 5-8 -- **Examples**: +12% damage, +10% fire rate, +10% health - -### Uncommon (21 passives) -- **Bonuses**: 15-25% moderate improvements -- **Malus Rate**: ~19% (4/21 passives) -- **Max Stacks**: 3-5 -- **Examples**: +20% range, +15% ricochet, +20 shield - -### Rare (27 passives) -- **Bonuses**: 25-50% significant improvements -- **Malus Rate**: ~41% (11/27 passives) -- **Max Stacks**: 2-4 -- **Examples**: +50% fury, +80% crit multi, +30% chain lightning - -### Epic (18 passives) -- **Bonuses**: 50-100%+ major improvements -- **Malus Rate**: ~72% (13/18 passives) -- **Max Stacks**: 1-2 -- **Examples**: +60% damage/-30% HP, +100% burst damage, revive mechanic - -Higher rarities have more powerful effects but come with significant trade-offs. - -## Health Calculation - -Max health uses a special **base * multiplier + add** formula: - -```javascript -const baseMaxHP = shipData.baseStats.maxHealth + (metaUpgrades * 10); -const hpMultiplier = stats.maxHealthMultiplier || 1; -const hpAdd = stats.maxHealthAdd || 0; -const newMax = Math.max(1, Math.floor(baseMaxHP * hpMultiplier + hpAdd)); -``` - -Current HP is adjusted to maintain the health ratio: -```javascript -const ratio = oldCurrent / oldMax; -health.current = Math.max(1, Math.min(Math.ceil(newMax * ratio), newMax)); -``` - -This ensures: -- Health changes preserve percentage (e.g., 50% stays 50%) -- Uses ceiling to avoid killing player via rounding -- Clamps to valid range [1, newMax] - -## Best Practices - -### When Adding New Stats -1. Add default value to `DEFAULT_STATS` -2. Document the stat in this file -3. Consider if it needs a soft cap -4. Add validation warning if needed -5. Test with extreme values - -### When Creating New Passives -1. Use existing effect keys when possible -2. Follow rarity-based balance guidelines -3. Include malus for powerful effects (especially rare/epic) -4. Limit max stacks appropriately -5. Test stacking behavior - -### When Debugging Stats -1. Check console for validation warnings -2. Verify DEFAULT_STATS has the stat -3. Check if soft cap is being applied -4. Trace through recalculatePlayerStats flow -5. Use browser devtools to inspect stats object - -## Common Issues - -### "Cannot read toFixed of undefined" -**Cause**: Stat not in DEFAULT_STATS -**Fix**: Add the stat to DEFAULT_STATS with appropriate default value - -### "Stats seem too high after 20 waves" -**Cause**: No soft cap or cap too high -**Fix**: Add soft cap in applySoftCaps() or adjust existing cap - -### "Passive has no effect" -**Cause**: Effect key typo or not recognized -**Fix**: Check effect key matches applyPassiveEffects logic - -### "Health keeps changing unexpectedly" -**Cause**: Ratio preservation or multiple recalculations -**Fix**: Check that maxHealthMultiplier is being used correctly - -## Summary - -The stats system is designed to: -- ✅ Prevent undefined errors (all stats have defaults) -- ✅ Support flexible passive effects (83 unique effect keys) -- ✅ Maintain game balance (soft caps prevent infinite scaling) -- ✅ Provide clear feedback (validation warnings) -- ✅ Be extensible (easy to add new stats/effects) - -**Total Stats**: 40 core stats + 83 effect keys = 123 possible stat modifications diff --git a/TEST_COMPONENTS.md b/TEST_COMPONENTS.md deleted file mode 100644 index 931219f..0000000 --- a/TEST_COMPONENTS.md +++ /dev/null @@ -1,86 +0,0 @@ -# Test de Validation Components - Space InZader - -## Objectif -Vérifier que TOUS les appels `Components.X()` fonctionnent sans erreur. - -## Procédure de Test - -### 1. Ouvrir le Jeu -``` -Ouvrir index.html dans le navigateur -``` - -### 2. Ouvrir la Console (F12) -Vérifier qu'il n'y a AUCUNE de ces erreurs: -- ❌ `Components.Position is not a function` -- ❌ `Components.Velocity is not a function` -- ❌ `Components.Collision is not a function` -- ❌ `Components.Renderable is not a function` -- ❌ `Components.Player is not a function` -- ❌ `Components.Health is not a function` -- ❌ `Components.Projectile is not a function` -- ❌ `Components.Pickup is not a function` -- ❌ `Components.Particle is not a function` -- ❌ `Components.Enemy is not a function` - -### 3. Logs Attendus (SUCCÈS) -``` -Space InZader - Scripts loaded -Space InZader - Initializing... -State changed: BOOT -> MENU -Space InZader - Ready! -``` - -### 4. Cliquer "START GAME" -``` -State changed: MENU -> RUNNING -Player created <-- DOIT APPARAÎTRE! -``` - -### 5. Vérifications Gameplay -- ✅ Le vaisseau du joueur est visible au centre -- ✅ Les ennemis commencent à apparaître -- ✅ Le timer commence à compter -- ✅ Les contrôles WASD/ZQSD fonctionnent -- ✅ Le joueur peut tirer (auto-fire) - -## Components Ajoutés dans ECS.js - -| Méthode | Utilisé Dans | Ligne(s) | -|---------|--------------|----------| -| Position | Game.js, AISystem, CollisionSystem, PickupSystem, SpawnerSystem | Multiple | -| Velocity | Game.js, AISystem, CollisionSystem, PickupSystem, SpawnerSystem | Multiple | -| Collision | Game.js, AISystem, CollisionSystem, PickupSystem | 270, etc. | -| Renderable | Game.js, AISystem, CollisionSystem, PickupSystem | 287, etc. | -| Health | Game.js | 272 | -| Player | Game.js | 274 | -| Projectile | AISystem | Multiple | -| Pickup | CollisionSystem, PickupSystem | Multiple | -| Particle | PickupSystem | Multiple | -| Enemy | SpawnerSystem | Multiple | -| Boss | SpawnerSystem | Boss spawns | - -## Résultat Attendu - -### ✅ SUCCÈS si: -- Aucune erreur "is not a function" dans la console -- Le joueur se crée et apparaît -- Le gameplay fonctionne normalement - -### ❌ ÉCHEC si: -- Erreur "Components.X is not a function" -- Le joueur ne se crée pas -- Crash au démarrage du jeu - -## Note Importante - -⚠️ L'avertissement suivant est NORMAL et n'est PAS une erreur: -``` -L'objet « Components » est obsolète. Il sera bientôt supprimé. -``` -C'est juste un warning du navigateur, pas un crash. Le jeu doit fonctionner malgré ce message. - ---- - -**Date du Fix:** 2026-02-09 -**Commit:** 24e502e - Fix COMPLET: Ajouter TOUTES les méthodes Components manquantes diff --git a/TEST_ESC_KEY.md b/TEST_ESC_KEY.md deleted file mode 100644 index a3b55c3..0000000 --- a/TEST_ESC_KEY.md +++ /dev/null @@ -1,226 +0,0 @@ -# 🧪 Test ESC Key - Guide de Vérification - -## Ce qui a été corrigé - -### Problème Original -- ESC ne fonctionnait pas -- Menu pause ne s'affichait pas -- Toggles pause/resume rapides - -### Corrections Appliquées -1. ✅ Ajout débounce 300ms réel (était absent malgré commit précédent) -2. ✅ hidePauseMenu() appelle maintenant resumeGame() correctement -3. ✅ Transitions propres entre états - ---- - -## Tests à Effectuer - -### Test 1: Ouvrir Menu Pause -**Étapes:** -1. Ouvrir `index.html` -2. Cliquer "PLAY" -3. Sélectionner un vaisseau -4. Cliquer "START GAME" -5. **Appuyer ESC** - -**Résultat attendu:** -- ✅ Menu pause s'affiche avec fond semi-transparent -- ✅ Boutons visibles: - - REPRENDRE - - COMMANDES - - OPTIONS - - QUITTER -- ✅ Jeu arrêté (timer ne bouge pas, ennemis figés) -- ✅ Musique continue - ---- - -### Test 2: Reprendre avec ESC -**Étapes:** -1. Menu pause ouvert (Test 1) -2. **Appuyer ESC à nouveau** - -**Résultat attendu:** -- ✅ Menu pause disparaît -- ✅ Jeu reprend immédiatement -- ✅ Timer continue -- ✅ Ennemis bougent -- ✅ Vaisseau contrôlable - ---- - -### Test 3: Reprendre avec Bouton -**Étapes:** -1. Menu pause ouvert (Test 1) -2. **Cliquer "REPRENDRE"** - -**Résultat attendu:** -- ✅ Menu pause disparaît -- ✅ Jeu reprend immédiatement -- ✅ Timer continue -- ✅ Ennemis bougent - ---- - -### Test 4: Spam ESC (Test Débounce) -**Étapes:** -1. En jeu -2. **Appuyer ESC rapidement 5-10 fois** - -**Résultat attendu:** -- ✅ Menu pause s'ouvre -- ✅ Pas de toggle rapide pause/resume -- ✅ Menu reste stable -- ✅ Pas de glitch visuel - ---- - -### Test 5: Navigation Menu Pause -**Étapes:** -1. Menu pause ouvert -2. **Cliquer "COMMANDES"** -3. Regarder les contrôles -4. **Cliquer "RETOUR"** - -**Résultat attendu:** -- ✅ Écran commandes s'affiche -- ✅ Bouton retour visible -- ✅ Retour au menu pause -- ✅ Peut reprendre le jeu - ---- - -### Test 6: Options depuis Pause -**Étapes:** -1. Menu pause ouvert -2. **Cliquer "OPTIONS"** -3. Ajuster volume musique/SFX -4. **Cliquer "RETOUR"** -5. **Cliquer "REPRENDRE"** - -**Résultat attendu:** -- ✅ Options s'affichent -- ✅ Sliders fonctionnent -- ✅ Volume change en temps réel -- ✅ Retour au menu pause OK -- ✅ Reprendre fonctionne - ---- - -### Test 7: Quitter vers Menu -**Étapes:** -1. Menu pause ouvert -2. **Cliquer "QUITTER"** - -**Résultat attendu:** -- ✅ Retour au menu principal -- ✅ Musique menu démarre -- ✅ Peut relancer une partie - ---- - -## Console Debug - -### Logs Attendus (F12 Console) - -**Lors de ESC (pause):** -``` -State changed: RUNNING -> PAUSED -Game paused - menu opened -``` - -**Lors de ESC (resume):** -``` -State changed: PAUSED -> RUNNING -``` - -**Lors du bouton Reprendre:** -``` -State changed: PAUSED -> RUNNING -``` - -### Logs à NE PAS voir - -❌ **Toggles rapides:** -``` -State changed: RUNNING -> PAUSED -State changed: PAUSED -> RUNNING ← Immédiat (BAD!) -State changed: RUNNING -> PAUSED -State changed: PAUSED -> RUNNING -``` - ---- - -## Debugging - -### Si ESC ne fonctionne toujours pas: - -1. **Vérifier la console (F12)** - - Erreurs JavaScript? - - Logs de changement d'état? - -2. **Vérifier index.html** - - `
` existe? - - Boutons ont les bons IDs? - -3. **Vérifier GameStates** - - État actuel dans console: `window.game.gameState.currentState` - - Devrait être "RUNNING" en jeu - -4. **Forcer refresh** - - Ctrl+F5 (hard reload) - - Vider cache navigateur - ---- - -## Code Modifié - -### Game.js -```javascript -// Propriété debounce -this.escapePressed = false; - -// Event listener -window.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && !this.escapePressed) { - this.escapePressed = true; - setTimeout(() => { - this.escapePressed = false; - }, 300); - - if (this.gameState.isState(GameStates.RUNNING)) { - this.pauseGame(); - } else if (this.gameState.isState(GameStates.PAUSED)) { - this.resumeGame(); - } - } -}); -``` - -### UISystem.js -```javascript -hidePauseMenu() { - if (this.pauseMenu) { - this.pauseMenu.classList.remove('active'); - } - // Resume properly! - if (window.game && window.game.gameState.isState(GameStates.PAUSED)) { - window.game.resumeGame(); - } -} -``` - ---- - -## Résultat Attendu - -✅ **ESC ouvre le menu pause** -✅ **ESC ferme le menu pause** -✅ **Bouton Reprendre fonctionne** -✅ **Pas de toggles rapides** -✅ **Navigation menu fluide** -✅ **Options accessibles** -✅ **Quitter fonctionne** - -**Si tous les tests passent, ESC fonctionne correctement!** 🎮 diff --git a/UPGRADE_SELECTION_FIX.md b/UPGRADE_SELECTION_FIX.md deleted file mode 100644 index f803f2f..0000000 --- a/UPGRADE_SELECTION_FIX.md +++ /dev/null @@ -1,152 +0,0 @@ -# Fix: Sélection d'Upgrades Répétitifs - -## 🐛 Problème -Les mêmes upgrades apparaissaient à chaque montée de niveau, rendant le jeu répétitif et cassant le système de builds par classe. - -## 🔍 Cause Racine Identifiée - -### Bug #1: `usePreferred` Recalculé à Chaque Itération -**Ligne 522 (AVANT):** -```javascript -for (let i = startIndex; i < rarities.length; i++) { - const rarity = rarities[i]; - const usePreferred = Math.random() < 0.6; // ❌ MAUVAIS: recalculé à chaque boucle! -} -``` - -**Problème:** La probabilité 60/40 était appliquée par rarity, pas par boost. Si on itérait sur 4 rarities, on avait 4 chances de changer de stratégie. - -**Conséquence:** Distribution incorrecte entre items préférés et globaux. - -### Bug #2: Pas de Fallback si Pool Préféré Vide -**Ligne 540-545 (AVANT):** -```javascript -if (usePreferred) { - return weapon.tags?.some(t => preferredTags.includes(t)); -} -return true; -``` - -**Problème:** Si `usePreferred=true` mais qu'aucun item ne match les tags préférés pour cette rarity, la fonction retournait un array vide → next rarity → possiblement toujours vide. - -**Conséquence:** Peu de variété car certaines rarities étaient skippées systématiquement. - -### Bug #3: Manque de Logging -Impossible de débogger pourquoi les mêmes items revenaient sans logs. - -## ✅ Solution Appliquée - -### Fix #1: Calculer `usePreferred` UNE FOIS -**js/Game.js ligne 503:** -```javascript -// 60% chance to use preferred tags, 40% for global pool -// FIX: Calculate ONCE per boost, not per rarity iteration -const usePreferred = Math.random() < 0.6 && preferredTags.length > 0; - -logger.debug('Game', `Selecting boost: usePreferred=${usePreferred}, preferredTags=${preferredTags.join(',')}`); -``` - -**Résultat:** La stratégie (préféré vs global) est déterminée une seule fois par boost, pas par rarity. - -### Fix #2: Fallback vers Pool Global -**js/Game.js lignes 579-620:** -```javascript -// FIX: If preferred pool is empty, fallback to global pool for this rarity -if (all.length === 0 && usePreferred) { - logger.debug('Game', `No preferred options at ${rarity}, trying global pool`); - - // Retry without preferred filter - const globalWeapons = Object.keys(WeaponData.WEAPONS).filter(key => { - // ... filtrage sans tags préférés ... - }); - - const globalPassives = Object.keys(PassiveData.PASSIVES).filter(key => { - // ... filtrage sans tags préférés ... - }); - - all = [...globalWeapons, ...globalPassives]; -} -``` - -**Résultat:** Si le pool préféré est vide, on essaie le pool global avant de passer à la rarity suivante. - -### Fix #3: Logging Debug -**Ajouté à plusieurs endroits:** -```javascript -logger.debug('Game', `Selecting boost: usePreferred=${usePreferred}`); -logger.debug('Game', `Rarity ${rarity}: found ${filtered.length} options`); -logger.info('Game', `Selected ${selected.type}: ${selected.key} (${rarity})`); -logger.warn('Game', 'No boost options available at any rarity level'); -``` - -**Résultat:** On peut maintenant voir exactement ce qui se passe dans la sélection. - -## 🧪 Tests de Validation - -### Test 1: Variété des Upgrades -``` -Level 1: [sang_froid (rare), crit_plus (common), piercing (uncommon)] -Level 2: [ricochet (rare), explosion_on_kill (rare), regen_hp (uncommon)] -Level 3: [crit_damage (common), dash_cooldown (uncommon), magnet (common)] -``` -✅ **Résultat attendu:** Upgrades différents à chaque niveau - -### Test 2: Tags Préférés Respectés -**Vampire** (preferredTags: vampire, on_hit, on_kill, crit, regen): -``` -Devrait voir plus souvent: sang_froid, coeur_noir, vampirisme, crit_plus -Devrait voir rarement: bouclier, summon items -``` -✅ **Résultat attendu:** ~60% des items ont des tags préférés - -### Test 3: Passifs Maxés Exclus -``` -1. Prendre crit_plus (stacks: 1/8) -2. Level up → crit_plus apparaît → take it (stacks: 2/8) -3. Repeat until stacks: 8/8 -4. Level up → crit_plus NE DOIT PAS apparaître -``` -✅ **Résultat attendu:** Item maxé n'apparaît plus - -### Test 4: Tags Bannis Exclus -**Tank** (bannedTags: dash, glass_cannon): -``` -Ne devrait JAMAIS voir: dash_cooldown, glass_cannon keystones -``` -✅ **Résultat attendu:** Items bannis jamais proposés - -## 📊 Changements Techniques - -**Fichier modifié:** `js/Game.js` -**Méthode modifiée:** `selectRandomBoost(luck, existing, forceRare)` -**Lignes modifiées:** 493-640 - -**Avant:** 150 lignes -**Après:** 199 lignes (+49 lignes) -- +8 lignes de logs -- +41 lignes de fallback global - -## 🎯 Résultat Final - -### Avant le Fix -- ❌ Mêmes 3-4 upgrades en boucle -- ❌ Tags préférés/bannis ignorés -- ❌ Items maxés réapparaissent -- ❌ Pas de variété de builds - -### Après le Fix -- ✅ Upgrades variés à chaque level -- ✅ Tags préférés respectés (60%) -- ✅ Items maxés exclus -- ✅ Builds par classe différenciés -- ✅ Logging pour debug - -## 🚀 Impact Gameplay - -Le système de progression est maintenant fonctionnel: -- Chaque classe a son identité -- Les builds se construisent progressivement -- Aucun upgrade gaspillé sur items maxés -- Le jeu est rejouable avec variété - -**Status:** ✅ CORRIGÉ ET TESTÉ diff --git a/ai/EnemyAI.ts b/ai/EnemyAI.ts new file mode 100644 index 0000000..72efe57 --- /dev/null +++ b/ai/EnemyAI.ts @@ -0,0 +1,52 @@ + +import { Entity, GameState } from '../types'; + +export const updateEnemyAI = (e: Entity, player: Entity, state: GameState, deltaTime: number, time: number) => { + const dist = Math.sqrt((player.x - e.x) ** 2 + (player.y - e.y) ** 2); + const angleToPlayer = Math.atan2(player.y - e.y, player.x - e.x); + + // Vitesse de rotation (Lissage de la rotation) + e.rotation = angleToPlayer; + + switch (e.subtype) { + case 'boss': + // Approche lente et constante + e.vx = Math.cos(angleToPlayer) * e.baseStats.speed; + e.vy = Math.sin(angleToPlayer) * e.baseStats.speed; + break; + + case 'sniper': + // Comportement de maintien de distance (Kiting) + if (dist > 600) { + e.vx = Math.cos(angleToPlayer) * e.baseStats.speed; + e.vy = Math.sin(angleToPlayer) * e.baseStats.speed; + } else if (dist < 450) { + e.vx = -Math.cos(angleToPlayer) * e.baseStats.speed; + e.vy = -Math.sin(angleToPlayer) * e.baseStats.speed; + } else { + // Orbitation latérale + e.vx = Math.cos(angleToPlayer + Math.PI/2) * e.baseStats.speed; + e.vy = Math.sin(angleToPlayer + Math.PI/2) * e.baseStats.speed; + } + break; + + case 'kamikaze': + // Charge pure (Beeline) avec accélération + const boost = dist < 300 ? 1.5 : 1.0; + e.vx = Math.cos(angleToPlayer) * e.baseStats.speed * boost; + e.vy = Math.sin(angleToPlayer) * e.baseStats.speed * boost; + break; + + case 'swarmer': + case 'basic': + default: + // Poursuite simple + e.vx = Math.cos(angleToPlayer) * e.baseStats.speed; + e.vy = Math.sin(angleToPlayer) * e.baseStats.speed; + break; + } + + // Application du mouvement + e.x += e.vx; + e.y += e.vy; +}; diff --git a/components/DebugOverlay.tsx b/components/DebugOverlay.tsx new file mode 100644 index 0000000..4e38c1e --- /dev/null +++ b/components/DebugOverlay.tsx @@ -0,0 +1,76 @@ + +import React from 'react'; +import { GameState } from '../types'; + +interface DebugOverlayProps { + state: GameState; + fps: number; + frameTime: number; +} + +export const DebugOverlay: React.FC = ({ state, fps, frameTime }) => { + const { player, enemies, projectiles, particles, xpDrops, effects, activeEvents, wave, heat } = state; + + const Row = ({ label, value, color = "text-green-400" }: { label: string, value: any, color?: string }) => ( +
+ {label}: + {value} +
+ ); + + // Déterminer la couleur de santé du FPS + const getFpsColor = (f: number) => { + if (f >= 55) return "text-green-400"; + if (f >= 30) return "text-yellow-400"; + return "text-red-500"; + }; + + return ( +
+
+
+
ENGINE_STATUS
+
+ {fps}FPS +
+
+
+
Frame Delay
+
17 ? 'text-orange-400' : 'text-slate-300'}`}> + {frameTime.toFixed(2)}ms +
+
+
+ +
+
+
// OBJECT_LOAD
+ + + + + +
+ +
+
// NAVIGATION
+ + + + +
+ +
+
// SYSTEM
+ 0 ? activeEvents[0].type : "STABLE"} color={activeEvents.length > 0 ? "text-amber-400" : "text-green-800"} /> + +
+
+ +
+ Debug: F3 + Space InZader v1.0.4 +
+
+ ); +}; diff --git a/components/HUD.tsx b/components/HUD.tsx new file mode 100644 index 0000000..aa4c960 --- /dev/null +++ b/components/HUD.tsx @@ -0,0 +1,218 @@ + +import React, { useMemo, useState, useEffect } from 'react'; +import { GameState, Tag, Weapon, DamageType, ActiveAbility } from '../types'; +import { DAMAGE_COLORS } from '../constants'; + +interface HUDProps { + state: GameState; +} + +const SegmentedBar: React.FC<{ + value: number; + max: number; + segments: number; + color: string; + vertical?: boolean; +}> = ({ value, max, segments, color, vertical }) => { + const activeSegments = Math.round((Math.max(0, Math.min(max, value)) / (max || 1)) * segments); + return ( +
+ {Array.from({ length: segments }).map((_, i) => ( +
+ ))} +
+ ); +}; + +const ShipBlueprint: React.FC<{ shield: number, maxShield: number, isGodMode?: boolean }> = ({ shield, maxShield, isGodMode }) => { + const shieldPerc = shield / (maxShield || 1); + return ( +
+ + + + + + + + + +
+ {isGodMode ? 'GOD_MODE' : 'BOUCLIER'} +
+
+ ); +}; + +const AbilitySlot: React.FC<{ ability: ActiveAbility }> = ({ ability }) => { + const progress = ability.currentCooldown / ability.cooldown; + const isReady = ability.currentCooldown <= 0; + + return ( +
+ {ability.icon} + {!isReady && ( +
+ {Math.ceil(ability.currentCooldown)}s +
+ )} +
+ {ability.key} +
+ {!isReady && ( + + + + )} +
+ ); +}; + +const TECH_LABELS: Record = { 1: 'I', 2: 'II', 3: 'III' }; + +export const HUD: React.FC = ({ state }) => { + const { player, heat, maxHeat, isOverheated, score, level, experience, expToNextLevel, activeWeapons, activeAbilities, wave, waveKills, waveQuota, totalKills, startTime, comboCount, comboTimer, currentMisses } = state; + const { runtimeStats, defense, isGodMode } = player; + + const [now, setNow] = useState(Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const missionTime = useMemo(() => { + const elapsed = (now - startTime) / 1000; + const mins = Math.floor(elapsed / 60); + const secs = Math.floor(elapsed % 60); + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }, [startTime, now]); + + return ( +
+ +
+
+
+ Chrono + {missionTime} +
+
+
+ Secteur + {wave} +
+
+
+ Éliminations + {totalKills} +
+
+ +
+
+
+ + VAGUE {wave} : {waveKills} / {waveQuota} SUPPRESSIONS + +
+
+ +
+ Niveau {level} + Score: {score.toLocaleString()} +
+
+
+
+
+ +
+ +
+
+ {activeAbilities.map(a => )} +
+
+
+ Armure + {Math.round(defense.armor)} +
+ +
+
+
+ Coque + {Math.round(defense.hull)} +
+ +
+
+
+ +
+ {activeWeapons.map((w, i) => { + const cooldown = 1000 / (w.fireRate * runtimeStats.fireRate * (w.level > 1 ? (w.level === 2 ? 1.5 : 1.9) : 1.0)); + const elapsed = performance.now() - w.lastFired; + const progress = Math.min(1, elapsed / cooldown); + return ( +
+
+ TECH {TECH_LABELS[w.level]} +
+
+ {w.name} +
+
+
+
= 1 ? DAMAGE_COLORS[w.type] : '#1e293b' }} /> +
+
+ ); + })} +
+ +
+ {comboCount > 0 && ( +
+
+ SÉRIE + x{comboCount} +
+
+ )} + +
+ Thermique + +
+ +
+ 0 ? 'text-cyan-400' : 'text-slate-500'}`}> + {comboCount > 0 ? 'Série' : 'Neutre'} + + 0 ? comboTimer : 0} + max={runtimeStats.comboWindow} + segments={10} + color="#22d3ee" + vertical + /> +
+
+ +
+ ); +}; diff --git a/components/Menu/DevMenu.tsx b/components/Menu/DevMenu.tsx new file mode 100644 index 0000000..b56361b --- /dev/null +++ b/components/Menu/DevMenu.tsx @@ -0,0 +1,346 @@ + +import React, { useState, useEffect } from 'react'; +import { GameState } from '../../types'; +import { INITIAL_STATS, WEAPON_POOL, PASSIVES, KEYSTONES } from '../../constants'; + +interface DevMenuProps { + state: GameState; + isLabMenuOpen: boolean; + onClose: () => void; + onLaunchSandbox: () => void; + onTriggerAction: (action: string, data?: any) => void; +} + +export const DevMenu: React.FC = ({ state, isLabMenuOpen, onClose, onLaunchSandbox, onTriggerAction }) => { + const isLabMode = state.status === 'lab'; + const [activeTab, setActiveTab] = useState<'tuning' | 'sim' | 'index' | 'stats'>(isLabMode ? 'tuning' : 'index'); + + useEffect(() => { + if (isLabMode) setActiveTab('tuning'); + else setActiveTab('index'); + }, [isLabMode]); + + const [selectedAsset, setSelectedAsset] = useState<{type: 'Armes' | 'Keystones' | 'Modules', item: any}>({ + type: 'Armes', + item: WEAPON_POOL[0] + }); + + const TabButton = ({ id, label, icon }: { id: any, label: string, icon?: string }) => ( + + ); + + const TuningSlider = ({ label, prop, min, max, step }: { label: string, prop: string, min: number, max: number, step: number }) => { + const val = (state.player.baseStats as any)[prop] ?? (INITIAL_STATS as any)[prop] ?? 1; + return ( +
e.stopPropagation()} + > +
+ {label} + {val.toFixed(2)} +
+ { + e.stopPropagation(); + onTriggerAction('tune_stat', { prop, val: parseFloat(e.target.value) }); + }} + className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-cyan-400" + /> +
+ ); + }; + + if (!isLabMode) { + return ( +
e.stopPropagation()} + > +
+
+
+

Database Dev

+
// Central Intelligence v2.6
+
+
+ + +
+
+
+ + +
+
+ +
+ {activeTab === 'index' && ( +
+
+
+

Armement

+ {WEAPON_POOL.map(w => ( + + ))} +
+
+

Modules

+ {PASSIVES.map(p => ( + + ))} +
+
+
+
+

{selectedAsset.item.name}

+

"{selectedAsset.item.description}"

+ +
+
+
+ )} + {activeTab === 'stats' && ( +
+ {(Object.entries(state.player.runtimeStats) as [string, any][]).map(([k, v]) => ( +
+
{k}
+
{typeof v === 'number' ? v.toFixed(2) : v}
+
+ ))} +
+ )} +
+
+ ); + } + + return ( +
+ + + +
e.stopPropagation()} + > +
+
+
+

Engineering Lab

+
+

// SIMULATION_ACTIVE

+ +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + {activeTab === 'tuning' && ( +
+
+

// System Physics

+
+ + +
+
+ + + + + + + +
+ + +
+
+ )} + + {activeTab === 'sim' && ( +
+

// Entity Injection

+ +
+ + + + + + +
+ +
+ + +
+
+ )} + +
+ +
+ SECURE_CONNECTION // 0x42AF_ACCESS +
+
+ +
+
+
+
+
Telemetry_Realtime
+
+ +
+ Entities: {state.enemies.length} + Projectiles: {state.projectiles.length} + God_Mode: {state.player.isGodMode ? 'ACTIVE' : 'OFF'} +
+ + {/* Defense Stats Section */} +
+
Hull_integrity_Matrix
+
+
+
+ BOUCLIER + {state.player.defense.shield.toFixed(0)} / {state.player.runtimeStats.maxShield.toFixed(0)} +
+
+
+ +
+
+ ARMURE + {state.player.defense.armor.toFixed(0)} / {state.player.runtimeStats.maxArmor.toFixed(0)} +
+
+
+ +
+
+ STRUCTURE + {state.player.defense.hull.toFixed(0)} / {state.player.runtimeStats.maxHull.toFixed(0)} +
+
+
+
+
+ + {/* Additional Secondary Stats */} +
+ Shield_Regen: {state.player.runtimeStats.shieldRegen.toFixed(1)}/s + Thermal_Load: {state.heat.toFixed(0)} units +
+ +
+
+
Equipped_Weapons
+ {state.activeWeapons.length > 0 ? ( + state.activeWeapons.map(w => ( +
+ {w.name} + TECH_{w.level} +
+ )) + ) : No weapons equipped} +
+ +
+
Active_Passives
+ {state.activePassives.length > 0 ? ( + state.activePassives.map(({passive, stacks}) => ( +
+ {passive.name} + x{stacks} +
+ )) + ) : No passive modules} +
+ + {state.keystones.length > 0 && ( +
+
Keystones
+ {state.keystones.map(k => ( +
+ {k.name} +
+ ))} +
+ )} +
+
+
+ +
+ ); +}; diff --git a/components/Menu/UpgradeMenu.tsx b/components/Menu/UpgradeMenu.tsx new file mode 100644 index 0000000..89bc3c3 --- /dev/null +++ b/components/Menu/UpgradeMenu.tsx @@ -0,0 +1,94 @@ + +import React from 'react'; +import { Weapon, Keystone, Tag, Passive } from '../../types'; +import { WEAPON_POOL, KEYSTONES, PASSIVES } from '../../constants'; + +interface UpgradeMenuProps { + onSelect: (upgrade: { type: 'weapon' | 'keystone' | 'stat' | 'passive'; item: any }) => void; + currentWeapons: Weapon[]; + currentKeystones: Keystone[]; +} + +const RARITY_COLORS = { + common: 'text-slate-400 border-slate-700/50', + rare: 'text-cyan-400 border-cyan-700/50', + epic: 'text-purple-400 border-purple-700/50', + legendary: 'text-amber-400 border-amber-700/50 shadow-[0_0_20px_rgba(251,191,36,0.1)]', +}; + +const RARITY_BGS = { + common: 'bg-slate-900/60', + rare: 'bg-cyan-950/20', + epic: 'bg-purple-950/20', + legendary: 'bg-amber-950/20', +}; + +export const UpgradeMenu: React.FC = ({ onSelect, currentWeapons, currentKeystones }) => { + const options = React.useMemo(() => { + // Permettre de choisir une arme si elle n'est pas déjà Tech III (level < 3) + const availableWeapons = WEAPON_POOL.filter(w => { + const existing = currentWeapons.find(cw => cw.id === w.id); + return !existing || existing.level < 3; + }); + + const allPool = [ + ...availableWeapons.map(w => ({ type: 'weapon' as const, item: w })), + ...PASSIVES.map(p => ({ type: 'passive' as const, item: p })) + ]; + + const shuffled = allPool.sort(() => Math.random() - 0.5); + return shuffled.slice(0, 3); + }, [currentWeapons]); + + const getTypeName = (type: string, item: any) => { + if (type === 'weapon') { + const existing = currentWeapons.find(cw => cw.id === item.id); + return existing ? `UPGRADE TECH ${existing.level + 1}` : 'NOUVEL ARMEMEMENT'; + } + if (type === 'passive') return `${item.rarity.toUpperCase()} MODULE`; + return 'SYSTÈME'; + }; + + return ( +
+
+
+

Modification Système Détectée

+ Auth : Terminal de Commandement +
+ +
+ {options.map((opt, i) => { + const rarity = (opt.item as Passive).rarity || 'common'; + const existingWeapon = opt.type === 'weapon' ? currentWeapons.find(cw => cw.id === opt.item.id) : null; + + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/constants.ts b/constants.ts new file mode 100644 index 0000000..9b3709f --- /dev/null +++ b/constants.ts @@ -0,0 +1,164 @@ + +import { DamageType, Tag, Weapon, Stats, Keystone, Passive } from './types'; + +export const WORLD_WIDTH = 4000; +export const WORLD_HEIGHT = 4000; +export const VIEW_SCALE = 1.0; + +export const CONTROLS = { + MOVE_UP: ['z', 'w', 'arrowup'], + MOVE_DOWN: ['s', 'arrowdown'], + MOVE_LEFT: ['q', 'a', 'arrowleft'], + MOVE_RIGHT: ['d', 'arrowright'], + FIRE: [' ', 'mousedown'], + ABILITY_1: 'shift', + ABILITY_2: 'e', + PAUSE: 'p', + DEBUG: 'f3' +}; + +export const TECH_MULTIPLIERS: Record = { + 1: 1.0, + 2: 1.5, + 3: 1.9, +}; + +export const INITIAL_STATS: Stats = { + maxShield: 50, + maxArmor: 100, + maxHull: 100, + shieldRegen: 2, + armorHardness: 0.1, + speed: 6, + rotationSpeed: 0.1, + damageMult: 1, + fireRate: 1, + critChance: 0.05, + critMult: 2.0, + cooling: 15, + maxHeat: 250, + magnetRange: 120, + xpMult: 1, + res_EM: 0, + res_Kinetic: 0, + res_Explosive: 0, + res_Thermal: 0, + res_Hull: 0, + dmgTakenMult: 1.0, + auraSlowAmount: 0, + auraSlowRange: 0, + overheatHullDmg: 0, + missTolerance: 0, + comboWindow: 1.0, + rangeMult: 1.0, + projectileSpeedMult: 1.0, + dodgeChance: 0, + luck: 0, +}; + +export const DAMAGE_COLORS: Record = { + [DamageType.EM]: '#22d3ee', + [DamageType.THERMAL]: '#fb923c', + [DamageType.KINETIC]: '#f8fafc', + [DamageType.EXPLOSIVE]: '#facc15', +}; + +export const WEAPON_POOL: Weapon[] = [ + // --- ÉLECTROMAGNÉTIQUE (EM) --- + { id: 'ion_blaster', name: 'Blaster à Ions', type: DamageType.EM, tags: [Tag.ENERGY, Tag.BALLISTIC], damage: 22, fireRate: 3.5, heatPerShot: 6, bulletSpeed: 18, bulletColor: '#22d3ee', range: 800, lastFired: 0, description: 'Tirs ioniques rapides. Déchire les boucliers.', level: 1 }, + { id: 'emp_pulse', name: 'Pulsar IEM', type: DamageType.EM, tags: [Tag.ENERGY, Tag.AREA], damage: 60, fireRate: 0.8, heatPerShot: 25, bulletSpeed: 10, bulletColor: '#0ea5e9', range: 500, lastFired: 0, description: 'Onde de choc IEM neutralisante.', level: 1 }, + { id: 'arc_disruptor', name: 'Disrupteur d\'Arc', type: DamageType.EM, tags: [Tag.ENERGY, Tag.CHAIN], damage: 18, fireRate: 2.0, heatPerShot: 12, bulletSpeed: 25, bulletColor: '#7dd3fc', range: 600, lastFired: 0, description: 'Éclairs rebondissants entre les cibles.', level: 1 }, + { id: 'disruptor_beam', name: 'Rayon Disrupteur', type: DamageType.EM, tags: [Tag.ENERGY, Tag.BEAM], damage: 12, fireRate: 10.0, heatPerShot: 2, bulletSpeed: 30, bulletColor: '#22d3ee', range: 700, lastFired: 0, description: 'Laser continu affaiblissant.', level: 1 }, + { id: 'em_drone_wing', name: 'Escadrille EM', type: DamageType.EM, tags: [Tag.DRONE, Tag.ENERGY], damage: 30, fireRate: 1.5, heatPerShot: 15, bulletSpeed: 12, bulletColor: '#22d3ee', range: 900, lastFired: 0, description: 'Déploie des drones de combat ioniques.', level: 1 }, + { id: 'overload_missile', name: 'Missile Overload', type: DamageType.EM, tags: [Tag.ENERGY, Tag.HOMING], damage: 85, fireRate: 0.5, heatPerShot: 30, bulletSpeed: 10, bulletColor: '#06b6d4', range: 1200, lastFired: 0, description: 'Missile lourd à tête chercheuse EM.', level: 1 }, + + // --- THERMIQUE (THERMAL) --- + { id: 'solar_flare', name: 'Éclat Solaire', type: DamageType.THERMAL, tags: [Tag.ENERGY, Tag.DOT], damage: 14, fireRate: 4.0, heatPerShot: 5, bulletSpeed: 16, bulletColor: '#fb923c', range: 750, lastFired: 0, description: 'Tirs incendiaires provoquant des brûlures.', level: 1 }, + { id: 'plasma_stream', name: 'Flux de Plasma', type: DamageType.THERMAL, tags: [Tag.ENERGY, Tag.BEAM], damage: 8, fireRate: 12.0, heatPerShot: 3, bulletSpeed: 20, bulletColor: '#f97316', range: 450, lastFired: 0, description: 'Lance-flammes à plasma haute température.', level: 1 }, + { id: 'thermal_lance', name: 'Lance Thermique', type: DamageType.THERMAL, tags: [Tag.ENERGY, Tag.BALLISTIC], damage: 140, fireRate: 0.4, heatPerShot: 50, bulletSpeed: 40, bulletColor: '#ea580c', range: 1300, lastFired: 0, description: 'Rayon de précision perforant l\'armure.', level: 1 }, + { id: 'incinerator_mine', name: 'Mine Incendiaire', type: DamageType.THERMAL, tags: [Tag.AREA, Tag.DOT], damage: 75, fireRate: 0.3, heatPerShot: 20, bulletSpeed: 0, bulletColor: '#fb923c', range: 300, lastFired: 0, description: 'Piège thermique créant une zone de feu.', level: 1 }, + { id: 'fusion_rocket', name: 'Roquette Fusion', type: DamageType.THERMAL, tags: [Tag.EXPLOSIVE, Tag.ENERGY], damage: 95, fireRate: 0.7, heatPerShot: 22, bulletSpeed: 15, bulletColor: '#f59e0b', range: 1000, lastFired: 0, description: 'Projectile explosif à fusion thermique.', level: 1 }, + { id: 'starfire_array', name: 'Matrice Starfire', type: DamageType.THERMAL, tags: [Tag.AREA, Tag.ORBITAL], damage: 25, fireRate: 2.5, heatPerShot: 18, bulletSpeed: 0, bulletColor: '#fbbf24', range: 600, lastFired: 0, description: 'Bombardement de zone aléatoire.', level: 1 }, + + // --- CINÉTIQUE (KINETIC) --- + { id: 'railgun_mk2', name: 'Railgun Mk2', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 150, fireRate: 0.3, heatPerShot: 45, bulletSpeed: 50, bulletColor: '#ffffff', range: 1500, lastFired: 0, description: 'Sniper lourd perforant.', level: 1 }, + { id: 'auto_cannon', name: 'Auto-Canon', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 16, fireRate: 8.0, heatPerShot: 3, bulletSpeed: 22, bulletColor: '#cbd5e1', range: 900, lastFired: 0, description: 'Mitrailleuse rotative à haute cadence.', level: 1 }, + { id: 'gauss_repeater', name: 'Répéteur Gauss', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 48, fireRate: 2.8, heatPerShot: 10, bulletSpeed: 28, bulletColor: '#94a3b8', range: 1100, lastFired: 0, description: 'Fusil d\'assaut électromagnétique équilibré.', level: 1 }, + { id: 'mass_driver', name: 'Mass Driver', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 95, fireRate: 1.1, heatPerShot: 18, bulletSpeed: 32, bulletColor: '#f1f5f9', range: 1200, lastFired: 0, description: 'Canon à impact lourd.', level: 1 }, + { id: 'shrapnel_burst', name: 'Shrapnel Burst', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.AREA], damage: 12, fireRate: 1.2, heatPerShot: 25, bulletSpeed: 18, bulletColor: '#94a3b8', range: 600, lastFired: 0, description: 'Shotgun spatial à dispersion.', level: 1 }, + { id: 'siege_slug', name: 'Obusier de Siège', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 220, fireRate: 0.2, heatPerShot: 60, bulletSpeed: 24, bulletColor: '#ffffff', range: 1800, lastFired: 0, description: 'Artillerie lourde à ultra-longue portée.', level: 1 }, + + // --- EXPLOSIF (EXPLOSIVE) --- + { id: 'cluster_missile', name: 'Missile Grappe', type: DamageType.EXPLOSIVE, tags: [Tag.EXPLOSIVE, Tag.HOMING, Tag.AREA], damage: 55, fireRate: 1.0, heatPerShot: 20, bulletSpeed: 14, bulletColor: '#facc15', range: 900, lastFired: 0, description: 'Missile se divisant en sous-munitions.', level: 1 }, + { id: 'gravity_bomb', name: 'Bombe Gravité', type: DamageType.EXPLOSIVE, tags: [Tag.EXPLOSIVE, Tag.AREA], damage: 90, fireRate: 0.4, heatPerShot: 40, bulletSpeed: 8, bulletColor: '#a855f7', range: 700, lastFired: 0, description: 'Crée un micro trou noir à l\'impact.', level: 1 }, + { id: 'drone_swarm', name: 'Essaim de Drones', type: DamageType.EXPLOSIVE, tags: [Tag.DRONE, Tag.SWARM], damage: 35, fireRate: 0.5, heatPerShot: 35, bulletSpeed: 10, bulletColor: '#fbbf24', range: 800, lastFired: 0, description: 'Lance des drones kamikazes en masse.', level: 1 }, + { id: 'orbital_strike', name: 'Frappe Orbitale', type: DamageType.EXPLOSIVE, tags: [Tag.AREA, Tag.ORBITAL], damage: 120, fireRate: 0.3, heatPerShot: 50, bulletSpeed: 30, bulletColor: '#facc15', range: 2000, lastFired: 0, description: 'Frappe chirurgicale à haute puissance.', level: 1 }, + { id: 'shockwave_emitter', name: 'Onde de Choc', type: DamageType.EXPLOSIVE, tags: [Tag.AREA, Tag.DEFENSIVE], damage: 45, fireRate: 0.8, heatPerShot: 15, bulletSpeed: 5, bulletColor: '#facc15', range: 400, lastFired: 0, description: 'Émet une onde repoussant les ennemis.', level: 1 }, + { id: 'minefield_layer', name: 'Poseur de Mines', type: DamageType.EXPLOSIVE, tags: [Tag.AREA, Tag.BALLISTIC], damage: 65, fireRate: 0.5, heatPerShot: 25, bulletSpeed: 4, bulletColor: '#fde047', range: 500, lastFired: 0, description: 'Déploie un champ de mines tactique.', level: 1 }, +]; + +export const KEYSTONES: Keystone[] = [ + { id: 'blood_frenzy', name: 'Frénésie de Combat', description: 'Vampirisme structurel : +20% Dégâts, mais réduit l\'efficacité de l\'armure de 30%.', modifiers: [ + { id: 'bf-1', property: 'damageMult', value: 1.2, type: 'multiplicative' }, + { id: 'bf-2', property: 'armorHardness', value: 0.7, type: 'multiplicative' } + ]}, + { id: 'overheat_protocol', name: 'Protocole Surchauffe', description: 'Augmente les dégâts de 50% quand vous êtes au-dessus de 80% de Heat.', modifiers: [ + { id: 'oh-1', property: 'damageMult', value: 1.5, type: 'multiplicative' } + ]}, +]; + +export const PASSIVES: Passive[] = [ + // --- UTILITAIRES --- + { id: 'salvager_1', name: 'Salvager I', description: 'Récupérateur d\'Épaves. +30 Portée de ramassage XP.', rarity: 'common', maxStacks: 10, tags: [Tag.MINING], modifiers: [ + { id: 's1-1', property: 'magnetRange', value: 30, type: 'additive' } + ] }, + { id: 'learning_algorithm', name: 'Algorithme d\'Apprentissage', description: 'Optimise l\'acquisition de données. +15% Gain XP.', rarity: 'rare', maxStacks: 5, tags: [Tag.ENERGY], modifiers: [ + { id: 'la-1', property: 'xpMult', value: 1.15, type: 'multiplicative' } + ] }, + { id: 'luck_enhancer', name: 'Injecteur de Probabilités', description: 'Manipule les flux quantiques. +10 Chance.', rarity: 'rare', maxStacks: 5, tags: [Tag.ENERGY], modifiers: [ + { id: 'le-1', property: 'luck', value: 10, type: 'additive' } + ] }, + + // --- DÉFENSE --- + { id: 'cap_battery', name: 'Capacitor Battery', description: 'Batterie de Condensateur. +10% Refroidissement.', rarity: 'common', maxStacks: 10, tags: [Tag.ENERGY], modifiers: [ + { id: 'cb-1', property: 'cooling', value: 1.10, type: 'multiplicative' } + ] }, + { id: 'pds_module', name: 'Power Diagnostic System', description: 'Couteau suisse : +5 Bouclier Max, +0.5 Régén.', rarity: 'common', maxStacks: 10, tags: [Tag.DEFENSIVE], modifiers: [ + { id: 'pd-1', property: 'maxShield', value: 5, type: 'additive' }, + { id: 'pd-2', property: 'shieldRegen', value: 0.5, type: 'additive' } + ] }, + { id: 'hardened_hull', name: 'Châssis Renforcé', description: 'Nanocomposites haute densité. +20 Coque Max.', rarity: 'common', maxStacks: 10, tags: [Tag.DEFENSIVE], modifiers: [ + { id: 'hh-1', property: 'maxHull', value: 20, type: 'additive' } + ] }, + { id: 'reactive_plating', name: 'Placage Réactif', description: 'Dissipe l\'énergie d\'impact. +5% Dureté Armure.', rarity: 'rare', maxStacks: 5, tags: [Tag.DEFENSIVE], modifiers: [ + { id: 'rp-1', property: 'armorHardness', value: 0.05, type: 'additive' } + ] }, + + // --- ATTAQUE --- + { id: 'co_processor', name: 'Co-Processeur I', description: 'Optimisation CPU : +3% Cadence, +5% Vitesse Projectile.', rarity: 'common', maxStacks: 10, tags: [Tag.ENERGY], modifiers: [ + { id: 'cp-1', property: 'fireRate', value: 1.03, type: 'multiplicative' }, + { id: 'cp-2', property: 'projectileSpeedMult', value: 1.05, type: 'multiplicative' } + ] }, + { id: 'targeting_computer', name: 'Calculateur de Tir', description: 'Analyse balistique avancée. +5% Chance de Critique.', rarity: 'rare', maxStacks: 10, tags: [Tag.KINETIC], modifiers: [ + { id: 'tc-1', property: 'critChance', value: 0.05, type: 'additive' } + ] }, + { id: 'warhead_optimizer', name: 'Optimiseur d\'Ogives', description: 'Augmente la puissance explosive. +10% Dégâts Globaux.', rarity: 'rare', maxStacks: 10, tags: [Tag.EXPLOSIVE], modifiers: [ + { id: 'wo-1', property: 'damageMult', value: 1.10, type: 'multiplicative' } + ] }, + + // --- SPÉCIALISATION --- + { id: 'thermal_sink', name: 'Dissipateur Thermique', description: 'Gestion de la chaleur extrême. +40 Capacité Heat Max.', rarity: 'common', maxStacks: 5, tags: [Tag.ENERGY, Tag.DOT], modifiers: [ + { id: 'ts-1', property: 'maxHeat', value: 40, type: 'additive' } + ] }, + { id: 'thruster_overclock', name: 'Overclock Propulseurs', description: 'Surcharge les moteurs. +10% Vitesse et Rotation.', rarity: 'rare', maxStacks: 5, tags: [Tag.ENERGY], modifiers: [ + { id: 'to-1', property: 'speed', value: 1.10, type: 'multiplicative' }, + { id: 'to-2', property: 'rotationSpeed', value: 1.10, type: 'multiplicative' } + ] }, + { id: 'quantum_buffer', name: 'Buffer Quantique', description: 'Stabilisation EM. +15% Résistance EM.', rarity: 'rare', maxStacks: 5, tags: [Tag.ENERGY, Tag.DEFENSIVE], modifiers: [ + { id: 'qb-1', property: 'res_EM', value: 0.15, type: 'additive' } + ] }, + { id: 'heavy_caliber', name: 'Calibre Lourd', description: 'Munitions massives. +15% Dégâts mais -5% Cadence.', rarity: 'epic', maxStacks: 5, tags: [Tag.KINETIC], modifiers: [ + { id: 'hc-1', property: 'damageMult', value: 1.15, type: 'multiplicative' }, + { id: 'hc-2', property: 'fireRate', value: 0.95, type: 'multiplicative' } + ] }, +]; diff --git a/debug.html b/debug.html deleted file mode 100644 index 32819b9..0000000 --- a/debug.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - JS Test - - -

Loading Scripts...

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/engine/AbilitySystem.ts b/engine/AbilitySystem.ts new file mode 100644 index 0000000..40e3b56 --- /dev/null +++ b/engine/AbilitySystem.ts @@ -0,0 +1,84 @@ + +import { GameState, ActiveAbility, DamageType } from '../types'; +import { emitParticles } from '../render/ParticleSystem'; +import { applyDamage } from './DamageEngine'; +import { CONTROLS } from '../constants'; + +export const updateAbilities = (state: GameState, deltaTime: number, keys: Set) => { + state.activeAbilities.forEach(ability => { + // Réduction du cooldown + if (ability.currentCooldown > 0) { + ability.currentCooldown = Math.max(0, ability.currentCooldown - deltaTime); + } + + // Déclenchement + if (ability.currentCooldown <= 0 && keys.has(ability.key)) { + ability.execute(state); + ability.currentCooldown = ability.cooldown; + } + }); +}; + +// --- Catalogue des Compétences --- + +export const BLINK_DASH: ActiveAbility = { + id: 'blink_dash', + name: 'Blink Dash', + description: 'Téléportation courte distance vers le curseur.', + cooldown: 4.0, + currentCooldown: 0, + icon: '⚡', + key: CONTROLS.ABILITY_1, + execute: (state) => { + const { player } = state; + const dashDist = 300; + const targetX = player.x + Math.cos(player.rotation) * dashDist; + const targetY = player.y + Math.sin(player.rotation) * dashDist; + + emitParticles(state, player.x, player.y, '#22d3ee', 20, 15); + player.x = targetX; + player.y = targetY; + emitParticles(state, player.x, player.y, '#f8fafc', 20, 10); + } +}; + +export const TACTICAL_NOVA: ActiveAbility = { + id: 'tactical_nova', + name: 'Nova Tactique', + description: 'Onde de choc EM déchargeant les boucliers ennemis.', + cooldown: 12.0, + currentCooldown: 0, + icon: '🌀', + key: CONTROLS.ABILITY_2, + execute: (state) => { + const { player, enemies } = state; + const novaRange = 400; + + emitParticles(state, player.x, player.y, '#22d3ee', 50, 20); + + enemies.forEach(e => { + const dx = e.x - player.x; + const dy = e.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < novaRange) { + applyDamage(e, { + amount: 150, + type: DamageType.EM, + penetration: 0.5, + isCrit: false + }); + + const force = (1 - dist / novaRange) * 15; + e.vx += (dx / dist) * force; + e.vy += (dy / dist) * force; + } + }); + } +}; + +// Pool automatique pour le DevMode +export const ALL_ABILITIES: ActiveAbility[] = [ + BLINK_DASH, + TACTICAL_NOVA +]; diff --git a/engine/CollisionSystem.ts b/engine/CollisionSystem.ts new file mode 100644 index 0000000..164ba93 --- /dev/null +++ b/engine/CollisionSystem.ts @@ -0,0 +1,183 @@ + +import { GameState, DamageType, Entity } from '../types'; +import { applyDamage } from './DamageEngine'; +import { emitParticles } from '../render/ParticleSystem'; +import { playCollectXPSound } from './SoundEngine'; +import { QuadTree, Boundary } from './QuadTree'; +import { WORLD_WIDTH, WORLD_HEIGHT, DAMAGE_COLORS } from '../constants'; + +export const checkCollisions = ( + state: GameState, + time: number, + onLevelUp: () => void, + onGameOver: () => void, + onShake: (amt: number) => void, + createEffect: (x: number, y: number, text: string, color: string) => void +) => { + const { player, enemies, projectiles, xpDrops } = state; + + const enemyTree = new QuadTree({ + x: WORLD_WIDTH / 2, + y: WORLD_HEIGHT / 2, + w: WORLD_WIDTH / 2, + h: WORLD_HEIGHT / 2 + }, 10); + + enemies.forEach(e => { + if (!e.dead) enemyTree.insert(e); + }); + + projectiles.forEach(p => { + if (p.dead) return; + + if (p.ownerId === 'player') { + const searchRange: Boundary = { + x: p.x, + y: p.y, + w: p.radius + 50, + h: p.radius + 50 + }; + + const candidates = enemyTree.query(searchRange); + + for (const e of candidates) { + if (e.dead || p.dead) continue; + const dx = p.x - e.x; + const dy = p.y - e.y; + const distSq = dx * dx + dy * dy; + const radiusSum = p.radius + e.radius; + + if (distSq < radiusSum * radiusSum) { + let finalPacket = { ...p.packet }; + + if (p.packet.type === DamageType.KINETIC) { + if (e.marks) e.marks.count = Math.min(5, e.marks.count + 1); + } else if (p.packet.type === DamageType.EM && e.marks && e.marks.count > 0) { + finalPacket.amount *= (1 + e.marks.count * 0.4); + finalPacket.isSynergy = true; + e.marks.count = 0; + emitParticles(state, e.x, e.y, '#22d3ee', 15, 10); + createEffect(e.x, e.y, "RESONANCE!", "#22d3ee"); + } + + if (p.packet.type === DamageType.THERMAL && e.marks && e.marks.count >= 3) { + createEffect(e.x, e.y, "OVERLOAD!", "#fb923c"); + emitParticles(state, e.x, e.y, '#fb923c', 25, 15); + onShake(10); + const nearby = enemyTree.query({ x: e.x, y: e.y, w: 200, h: 200 }); + nearby.forEach(ne => { + applyDamage(ne, { amount: 50, type: DamageType.EXPLOSIVE, penetration: 0, isCrit: false }, time); + }); + e.marks.count = 0; + } + + applyDamage(e, finalPacket, time); + + // FEEDBACK VISUEL : Dégâts sur Ennemi (couleur selon le type) + const damageText = Math.floor(finalPacket.amount).toString(); + const damageColor = finalPacket.isCrit ? '#ffffff' : DAMAGE_COLORS[finalPacket.type]; + createEffect(e.x, e.y, finalPacket.isCrit ? `CRIT! ${damageText}` : damageText, damageColor); + + emitParticles(state, p.x, p.y, p.color, 4, 4); + p.dead = true; + + state.heat = Math.max(0, state.heat - p.heatGenerated * 0.3); + state.comboCount++; + state.comboTimer = player.runtimeStats.comboWindow; + + if (e.defense.hull <= 0) { + e.dead = true; + state.totalKills++; + state.waveKills++; + state.score += (e.subtype === 'boss' ? 5000 : 50); + emitParticles(state, e.x, e.y, e.subtype === 'boss' ? '#facc15' : '#f87171', e.subtype === 'boss' ? 100 : 15, 8); + + let dropCount = 2; + let amountPerDrop = 15; + switch (e.subtype) { + case 'boss': dropCount = 50; amountPerDrop = 30; break; + case 'sniper': dropCount = 4; amountPerDrop = 25; break; + case 'kamikaze': dropCount = 3; amountPerDrop = 20; break; + case 'swarmer': dropCount = 1; amountPerDrop = 12; break; + default: dropCount = 2; amountPerDrop = 15; break; + } + + for (let i = 0; i < dropCount; i++) { + state.xpDrops.push({ + id: Math.random().toString(), + x: e.x, y: e.y, + amount: amountPerDrop * player.runtimeStats.xpMult, + vx: (Math.random() - 0.5) * 14, + vy: (Math.random() - 0.5) * 14, + collected: false + }); + } + } + } + } + } else { + const dx = p.x - player.x; + const dy = p.y - player.y; + const distSq = dx * dx + dy * dy; + const radiusSum = p.radius + player.radius; + + if (distSq < radiusSum * radiusSum) { + applyDamage(player, p.packet, time); + + // FEEDBACK VISUEL : Dégâts sur Joueur (Rouge) + if (!player.isGodMode) { + createEffect(player.x, player.y, Math.floor(p.packet.amount).toString(), "#ef4444"); + } + + onShake(15); + p.dead = true; + if (player.defense.hull <= 0) onGameOver(); + } + } + }); + + xpDrops.forEach(drop => { + const dx = player.x - drop.x; + const dy = player.y - drop.y; + const distSq = dx * dx + dy * dy; + const radiusSum = player.radius + 15; + + if (distSq < radiusSum * radiusSum) { + drop.collected = true; + state.experience += drop.amount; + playCollectXPSound(); + emitParticles(state, drop.x, drop.y, '#38bdf8', 4, 2); + if (state.experience >= state.expToNextLevel) onLevelUp(); + } + }); + + const nearbyEnemies = enemyTree.query({ x: player.x, y: player.y, w: 150, h: 150 }); + nearbyEnemies.forEach(e => { + const dx = player.x - e.x; + const dy = player.y - e.y; + const distSq = dx * dx + dy * dy; + const radiusSum = player.radius + e.radius; + + if (distSq < radiusSum * radiusSum) { + if (e.subtype === 'kamikaze') { + applyDamage(player, { amount: 45, type: DamageType.EXPLOSIVE, penetration: 0, isCrit: false }, time); + if (!player.isGodMode) createEffect(player.x, player.y, "45", "#ef4444"); + e.dead = true; + state.totalKills++; + state.waveKills++; + emitParticles(state, e.x, e.y, '#ef4444', 30, 15); + onShake(30); + } else { + const dps = 5.0 * (1/60); + applyDamage(player, { amount: dps, type: DamageType.KINETIC, penetration: 0, isCrit: false }, time); + // On n'affiche pas les micro-dégâts de collision pour éviter le spam de texte + onShake(2); + } + if (player.defense.hull <= 0) onGameOver(); + } + }); + + state.projectiles = state.projectiles.filter(p => !p.dead); + state.enemies = state.enemies.filter(e => !e.dead); + state.xpDrops = state.xpDrops.filter(d => !d.collected); +}; diff --git a/engine/CoreEngine.ts b/engine/CoreEngine.ts new file mode 100644 index 0000000..6653335 --- /dev/null +++ b/engine/CoreEngine.ts @@ -0,0 +1,192 @@ + +import { GameState, Entity, DamageType } from '../types'; +import { INITIAL_STATS } from '../constants'; +import { calculateRuntimeStats, syncDefenseState } from './StatsCalculator'; +import { updateParticles } from '../render/ParticleSystem'; +import { updatePhysics } from './PhysicsEngine'; +import { checkCollisions } from './CollisionSystem'; +import { updateEnemyAI } from '../ai/EnemyAI'; +import { updateLootMagnetism } from './LootSystem'; +import { handlePlayerControls } from './PlayerController'; +import { updateEnvironmentalEvents } from './EventSystem'; + +export const createEffect = (state: GameState, x: number, y: number, text: string, color: string) => { + state.effects.push({ + id: Math.random().toString(), + x, y, text, color, + life: 1.0, // Un peu plus court pour éviter l'encombrement + vx: (Math.random() - 0.5) * 1.5, + vy: -1.0 - Math.random() * 1.5, // Remontée plus douce + }); +}; + +export const spawnEnemy = (wave: number, player: Entity, forcedSubtype?: any, customDist?: number): Entity => { + const angle = Math.random() * Math.PI * 2; + const distance = customDist || (1000 + Math.random() * 200); + let x = player.x + Math.cos(angle) * distance; + let y = player.y + Math.sin(angle) * distance; + + const difficulty = 1 + (wave - 1) * 0.15; + const rand = Math.random(); + let subtype: any = 'basic'; + + if (forcedSubtype) { + subtype = forcedSubtype; + } else { + if (wave >= 6) { + const pSniper = Math.min(0.25, 0.05 + (wave - 6) * 0.02); + const pKamikaze = Math.min(0.20, 0.10 + (wave - 4) * 0.02); + const pSwarmer = 0.25; + + if (rand < pSniper) subtype = 'sniper'; + else if (rand < pSniper + pKamikaze) subtype = 'kamikaze'; + else if (rand < pSniper + pKamikaze + pSwarmer) subtype = 'swarmer'; + else subtype = 'basic'; + } else if (wave >= 4) { + if (rand < 0.15) subtype = 'kamikaze'; + else if (rand < 0.40) subtype = 'swarmer'; + else subtype = 'basic'; + } else if (wave >= 2) { + if (rand < 0.30) subtype = 'swarmer'; + else subtype = 'basic'; + } else { + subtype = 'basic'; + } + } + + const base = { ...INITIAL_STATS }; + let radius = 32; + + if (subtype === 'boss') { + base.maxHull = 6000 * difficulty; + base.speed = 1.4; + radius = 110; + } else if (subtype === 'sniper') { + base.maxHull = 60 * difficulty; + base.speed = 2.6; + radius = 28; + } else if (subtype === 'kamikaze') { + base.maxHull = 35 * difficulty; + base.speed = 7.8; + radius = 24; + } else if (subtype === 'swarmer') { + base.maxHull = 20 * difficulty; + base.speed = 5.2; + radius = 16; + } else { + const hpFactor = wave <= 2 ? 0.45 : 1.0; + base.maxHull = 85 * difficulty * hpFactor; + base.speed = 2.4; + radius = 32; + } + + return { + id: Math.random().toString(), + x, y, vx: 0, vy: 0, rotation: 0, radius, + type: subtype === 'boss' ? 'boss' : 'enemy', + subtype, + baseStats: base, + runtimeStats: base, + modifiers: [], + statsDirty: false, + defense: { shield: base.maxShield, armor: base.maxArmor, hull: base.maxHull }, + lastFired: 0, + marks: { type: 'resonance', count: 0 } + }; +}; + +export const updateGameState = ( + state: GameState, + deltaTime: number, + time: number, + keys: Set, + mouseWorld: { x: number, y: number }, + onLevelUp: () => void, + onGameOver: () => void, + onShake: (amount: number) => void +) => { + const { player } = state; + + if (player.statsDirty) { + player.runtimeStats = calculateRuntimeStats(player, state); + syncDefenseState(player); + state.maxHeat = player.runtimeStats.maxHeat; + player.statsDirty = false; + } + + if (state.comboCount > 0) { + state.comboTimer -= deltaTime; + if (state.comboTimer <= 0) { + state.comboCount = 0; + state.currentMisses = 0; + } + } + + if (state.status === 'playing') { + if (state.waveKills >= state.waveQuota) { + state.wave++; + state.waveKills = 0; + state.waveQuota = Math.floor(10 + (state.wave * 6)); + + if (state.wave % 10 === 0) { + state.enemies.push(spawnEnemy(state.wave, player, 'boss')); + state.bossSpawned = true; + } + + onShake(15); + createEffect(state, player.x, player.y - 120, `VAGUE ${state.wave} ACTIVE`, "#22d3ee"); + } + } + + updateEnvironmentalEvents(state, deltaTime, time); + handlePlayerControls(state, deltaTime, time, keys, mouseWorld); + + state.enemies.forEach(e => { + updateEnemyAI(e, player, state, deltaTime, time); + + if (e.subtype === 'boss' && time - (e.lastFired || 0) > 1100) { + e.lastFired = time; + for(let i=0; i<12; i++) { + const a = Math.atan2(player.y - e.y, player.x - e.x) + (Math.PI*2/12)*i; + state.projectiles.push({ x: e.x, y: e.y, vx: Math.cos(a)*7.5, vy: Math.sin(a)*7.5, packet: { amount: 15, type: DamageType.EXPLOSIVE, penetration: 0, isCrit: false }, color: '#facc15', ownerId: e.id, radius: 10, distanceTraveled: 0, maxRange: 2000, heatGenerated: 0 }); + } + } + if (e.subtype === 'sniper' && time - (e.lastFired || 0) > 2800) { + e.lastFired = time; + const a = Math.atan2(player.y - e.y, player.x - e.x); + state.projectiles.push({ x: e.x, y: e.y, vx: Math.cos(a)*20, vy: Math.sin(a)*20, packet: { amount: 20, type: DamageType.KINETIC, penetration: 0.2, isCrit: false }, color: '#ffffff', ownerId: e.id, radius: 4, distanceTraveled: 0, maxRange: 1800, heatGenerated: 0 }); + } + }); + + updatePhysics(state, deltaTime); + updateLootMagnetism(state, deltaTime); + checkCollisions(state, time, onLevelUp, onGameOver, onShake, (x, y, txt, col) => createEffect(state, x, y, txt, col)); + + if (state.heat > 0) state.heat = Math.max(0, state.heat - player.runtimeStats.cooling * deltaTime); + if (state.isOverheated && state.heat <= state.maxHeat * 0.9) state.isOverheated = false; + + const SHIELD_RECHARGE_DELAY = 3000; + if (player.defense.shield < player.runtimeStats.maxShield) { + const timeSinceDamage = time - (player.lastDamageTime || 0); + if (timeSinceDamage > SHIELD_RECHARGE_DELAY) { + player.defense.shield = Math.min(player.runtimeStats.maxShield, player.defense.shield + player.runtimeStats.shieldRegen * deltaTime); + } + } + + const maxPop = Math.min(35, 8 + (state.wave * 2)); + const currentEnemies = state.enemies.length; + + if (state.status === 'playing' && currentEnemies < maxPop && (currentEnemies + state.waveKills) < state.waveQuota) { + const spawnProb = 0.03 + (state.wave * 0.005); + if (Math.random() < spawnProb) { + state.enemies.push(spawnEnemy(state.wave, player)); + } + } + + updateParticles(state, deltaTime); + state.effects.forEach(ef => { + ef.x += ef.vx; ef.y += ef.vy; + ef.life -= deltaTime * 1.2; + }); + state.effects = state.effects.filter(ef => ef.life > 0); +}; diff --git a/engine/DamageEngine.ts b/engine/DamageEngine.ts new file mode 100644 index 0000000..3417b52 --- /dev/null +++ b/engine/DamageEngine.ts @@ -0,0 +1,58 @@ + +import { DamagePacket, DamageType, Entity } from '../types'; + +export const applyDamage = (target: Entity, packet: DamagePacket, time?: number): void => { + // Si le God Mode est actif sur l'entité, on ignore purement les dégâts + if (target.isGodMode) return; + + const stats = target.runtimeStats; + const defense = target.defense; + + // Marquer le temps du dernier impact pour le délai de régénération + target.lastDamageTime = time || performance.now(); + + // Appliquer le multiplicateur de dégâts reçus (ex: Microwarpdrive) + let remainingDamage = packet.amount * (stats.dmgTakenMult || 1.0); + + // 1. Résistances Globales (Éléments) + let resistance = 0; + switch (packet.type) { + case DamageType.EM: resistance = stats.res_EM; break; + case DamageType.KINETIC: resistance = stats.res_Kinetic; break; + case DamageType.EXPLOSIVE: resistance = stats.res_Explosive; break; + case DamageType.THERMAL: resistance = stats.res_Thermal; break; + } + remainingDamage *= (1 - Math.min(0.9, resistance)); + + // 2. Couche BOUCLIER (Shield) + if (defense.shield > 0) { + const shieldEfficiency = packet.type === DamageType.EM ? 1.5 : 0.8; + const effectiveDmg = remainingDamage * shieldEfficiency; + if (defense.shield >= effectiveDmg) { + defense.shield -= effectiveDmg; + return; + } else { + remainingDamage -= defense.shield / shieldEfficiency; + defense.shield = 0; + } + } + + // 3. Couche ARMURE (Armor) + if (defense.armor > 0) { + const armorEfficiency = (packet.type === DamageType.KINETIC || packet.type === DamageType.EXPLOSIVE) ? 1.2 : 0.7; + remainingDamage *= (1 - stats.armorHardness); + const effectiveDmg = remainingDamage * armorEfficiency; + if (defense.armor >= effectiveDmg) { + defense.armor -= effectiveDmg; + return; + } else { + remainingDamage -= defense.armor / armorEfficiency; + defense.armor = 0; + } + } + + // 4. Couche COQUE (Hull) - Appliquer la résistance de coque (ex: Damage Control) + const hullEfficiency = (packet.type === DamageType.THERMAL || packet.type === DamageType.EXPLOSIVE) ? 1.3 : 1.0; + const hullResistance = stats.res_Hull || 0; + defense.hull = Math.max(0, defense.hull - (remainingDamage * hullEfficiency * (1 - Math.min(0.9, hullResistance)))); +}; diff --git a/engine/EventSystem.ts b/engine/EventSystem.ts new file mode 100644 index 0000000..f101bfd --- /dev/null +++ b/engine/EventSystem.ts @@ -0,0 +1,73 @@ + +import { GameState, EnvEventType, EnvironmentalEvent, DamageType } from '../types'; +import { applyDamage } from './DamageEngine'; +import { WORLD_WIDTH, WORLD_HEIGHT } from '../constants'; +import { emitParticles } from '../render/ParticleSystem'; + +export const updateEnvironmentalEvents = (state: GameState, deltaTime: number, time: number) => { + const { player, enemies, activeEvents } = state; + + // 1. Spawning aléatoire (Chance rare par frame) + if (state.status === 'playing' && activeEvents.length === 0 && Math.random() < 0.001) { + const types = [EnvEventType.SOLAR_STORM, EnvEventType.BLACK_HOLE, EnvEventType.MAGNETIC_STORM]; + const type = types[Math.floor(Math.random() * types.length)]; + + activeEvents.push({ + id: Math.random().toString(), + type, + x: type === EnvEventType.BLACK_HOLE ? player.x + (Math.random() - 0.5) * 800 : 0, + y: type === EnvEventType.BLACK_HOLE ? player.y + (Math.random() - 0.5) * 800 : 0, + radius: type === EnvEventType.BLACK_HOLE ? 400 : 0, + duration: 15, + maxDuration: 15, + intensity: 1.0 + }); + } + + // 2. Traitement des événements actifs + for (let i = activeEvents.length - 1; i >= 0; i--) { + const event = activeEvents[i]; + event.duration -= deltaTime; + + if (event.duration <= 0) { + activeEvents.splice(i, 1); + continue; + } + + // Effets spécifiques + switch (event.type) { + case EnvEventType.SOLAR_STORM: + // Chauffe accrue et micro-dégâts + state.heat = Math.min(state.maxHeat, state.heat + 2 * deltaTime); + if (Math.random() < 0.1) { + applyDamage(player, { amount: 0.5, type: DamageType.THERMAL, penetration: 0, isCrit: false }, time); + } + break; + + case EnvEventType.BLACK_HOLE: + // Attraction gravitationnelle + const pull = (target: any) => { + const dx = event.x - target.x; + const dy = event.y - target.y; + const distSq = dx * dx + dy * dy; + const dist = Math.sqrt(distSq); + if (dist < 1000) { + const force = (1 - dist / 1000) * 5; + target.vx += (dx / dist) * force; + target.vy += (dy / dist) * force; + if (dist < 50) { + applyDamage(target, { amount: 10 * deltaTime, type: DamageType.KINETIC, penetration: 1, isCrit: false }, time); + } + } + }; + pull(player); + enemies.forEach(pull); + if (Math.random() < 0.3) emitParticles(state, event.x + (Math.random()-0.5)*800, event.y + (Math.random()-0.5)*800, '#1e1b4b', 1, 2); + break; + + case EnvEventType.MAGNETIC_STORM: + // Malus de cooldown et glitch + break; + } + } +}; diff --git a/engine/InputManager.ts b/engine/InputManager.ts new file mode 100644 index 0000000..a2df1e1 --- /dev/null +++ b/engine/InputManager.ts @@ -0,0 +1,73 @@ + +export class InputManager { + private keys: Set = new Set(); + private mousePos = { x: 0, y: 0 }; + private canvas: HTMLCanvasElement | null = null; + + constructor() { + window.addEventListener('keydown', this.handleKeyDown); + window.addEventListener('keyup', this.handleKeyUp); + // On utilise l'évènement mousedown sur le window pour capturer l'intention, + // mais on filtre strictement la cible. + window.addEventListener('mousedown', this.handleMouseDown); + window.addEventListener('mouseup', this.handleMouseUp); + window.addEventListener('mousemove', this.handleMouseMove); + } + + public setCanvas(canvas: HTMLCanvasElement) { + this.canvas = canvas; + } + + private handleKeyDown = (e: KeyboardEvent) => { + // Ne pas capturer les touches si on tape dans un input (ex: recherche dev menu) + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + this.keys.add(e.key.toLowerCase()); + }; + + private handleKeyUp = (e: KeyboardEvent) => { + this.keys.delete(e.key.toLowerCase()); + }; + + private handleMouseDown = (e: MouseEvent) => { + // CRITIQUE : Seul le clic direct sur le canvas de simulation active le tir + if (this.canvas && (e.target === this.canvas)) { + this.keys.add('mousedown'); + } + }; + + private handleMouseUp = (e: MouseEvent) => { + this.keys.delete('mousedown'); + }; + + private handleMouseMove = (e: MouseEvent) => { + if (this.canvas) { + const rect = this.canvas.getBoundingClientRect(); + this.mousePos = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + }; + + public isPressed(key: string): boolean { + return this.keys.has(key.toLowerCase()); + } + + public getMousePos() { + return this.mousePos; + } + + public getKeys() { + return this.keys; + } + + public dispose() { + window.removeEventListener('keydown', this.handleKeyDown); + window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('mousedown', this.handleMouseDown); + window.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('mousemove', this.handleMouseMove); + } +} + +export const input = new InputManager(); diff --git a/engine/LootSystem.ts b/engine/LootSystem.ts new file mode 100644 index 0000000..012ce35 --- /dev/null +++ b/engine/LootSystem.ts @@ -0,0 +1,26 @@ + +import { GameState, XPDrop, Entity } from '../types'; + +export const updateLootMagnetism = (state: GameState, deltaTime: number) => { + const { player, xpDrops } = state; + const magnetRangeSq = player.runtimeStats.magnetRange ** 2; + + xpDrops.forEach(drop => { + const dx = player.x - drop.x; + const dy = player.y - drop.y; + const distSq = dx * dx + dy * dy; + + if (distSq < magnetRangeSq) { + const dist = Math.sqrt(distSq); + const pullStrength = 1.2; // Force d'attraction + drop.vx += (dx / dist) * pullStrength; + drop.vy += (dy / dist) * pullStrength; + } + + // Physique de base du loot + drop.x += drop.vx; + drop.y += drop.vy; + drop.vx *= 0.92; // Friction spatiale plus forte pour les items + drop.vy *= 0.92; + }); +}; diff --git a/engine/PhysicsEngine.ts b/engine/PhysicsEngine.ts new file mode 100644 index 0000000..5657870 --- /dev/null +++ b/engine/PhysicsEngine.ts @@ -0,0 +1,19 @@ + +import { GameState } from '../types'; +import { WORLD_WIDTH, WORLD_HEIGHT } from '../constants'; + +export const updatePhysics = (state: GameState, deltaTime: number) => { + const { player, projectiles } = state; + + // 1. Player World Bounds + player.x = Math.max(player.radius, Math.min(WORLD_WIDTH - player.radius, player.x)); + player.y = Math.max(player.radius, Math.min(WORLD_HEIGHT - player.radius, player.y)); + + // 2. Projectiles Movement + projectiles.forEach(p => { + p.x += p.vx; + p.y += p.vy; + p.distanceTraveled += Math.sqrt(p.vx * p.vx + p.vy * p.vy); + if (p.distanceTraveled > p.maxRange) p.dead = true; + }); +}; diff --git a/engine/PlayerController.ts b/engine/PlayerController.ts new file mode 100644 index 0000000..4732930 --- /dev/null +++ b/engine/PlayerController.ts @@ -0,0 +1,72 @@ + +import { GameState, DamageType } from '../types'; +import { TECH_MULTIPLIERS, CONTROLS } from '../constants'; +import { playShotSound } from './SoundEngine'; +import { updateAbilities } from './AbilitySystem'; + +export const handlePlayerControls = ( + state: GameState, + deltaTime: number, + time: number, + keys: Set, + mouseWorld: { x: number, y: number } +) => { + const { player } = state; + + // 1. Mouvement de base utilisant la config centralisée + const isUp = CONTROLS.MOVE_UP.some(k => keys.has(k)); + const isDown = CONTROLS.MOVE_DOWN.some(k => keys.has(k)); + const isLeft = CONTROLS.MOVE_LEFT.some(k => keys.has(k)); + const isRight = CONTROLS.MOVE_RIGHT.some(k => keys.has(k)); + + const moveX = (isRight ? 1 : 0) - (isLeft ? 1 : 0); + const moveY = (isDown ? 1 : 0) - (isUp ? 1 : 0); + + player.vx = moveX * player.runtimeStats.speed; + player.vy = moveY * player.runtimeStats.speed; + player.x += player.vx; + player.y += player.vy; + + // Rotation vers la souris + player.rotation = Math.atan2(mouseWorld.y - player.y, mouseWorld.x - player.x); + + // 2. Gestion des Compétences (Abilities) + updateAbilities(state, deltaTime, keys); + + // 3. Gestion du Tir + const isFiring = CONTROLS.FIRE.some(k => keys.has(k)); + if (isFiring && !state.isOverheated) { + state.activeWeapons.forEach(w => { + const techMult = TECH_MULTIPLIERS[w.level] || 1.0; + const cooldown = 1000 / (w.fireRate * player.runtimeStats.fireRate * techMult); + + if (time - w.lastFired > cooldown) { + w.lastFired = time; + playShotSound(w.type); + const isCrit = Math.random() < player.runtimeStats.critChance; + + state.projectiles.push({ + x: player.x + Math.cos(player.rotation) * player.radius, + y: player.y + Math.sin(player.rotation) * player.radius, + vx: Math.cos(player.rotation) * w.bulletSpeed * player.runtimeStats.projectileSpeedMult, + vy: Math.sin(player.rotation) * w.bulletSpeed * player.runtimeStats.projectileSpeedMult, + packet: { + amount: w.damage * player.runtimeStats.damageMult * techMult * (isCrit ? player.runtimeStats.critMult : 1), + type: w.type, + penetration: 0, + isCrit + }, + color: w.bulletColor, + ownerId: 'player', + radius: 5, + distanceTraveled: 0, + maxRange: w.range * player.runtimeStats.rangeMult, + heatGenerated: w.heatPerShot, + }); + + state.heat += w.heatPerShot; + if (state.heat >= state.maxHeat) state.isOverheated = true; + } + }); + } +}; diff --git a/engine/QuadTree.ts b/engine/QuadTree.ts new file mode 100644 index 0000000..87cf606 --- /dev/null +++ b/engine/QuadTree.ts @@ -0,0 +1,104 @@ + +export interface Boundary { + x: number; + y: number; + w: number; + h: number; +} + +export class QuadTree { + private capacity: number; + private boundary: Boundary; + private points: T[] = []; + private divided: boolean = false; + + private northwest?: QuadTree; + private northeast?: QuadTree; + private southwest?: QuadTree; + private southeast?: QuadTree; + + constructor(boundary: Boundary, capacity: number = 8) { + this.boundary = boundary; + this.capacity = capacity; + } + + private subdivide() { + const { x, y, w, h } = this.boundary; + const nw = { x: x - w / 2, y: y - h / 2, w: w / 2, h: h / 2 }; + this.northwest = new QuadTree(nw, this.capacity); + const ne = { x: x + w / 2, y: y - h / 2, w: w / 2, h: h / 2 }; + this.northeast = new QuadTree(ne, this.capacity); + const sw = { x: x - w / 2, y: y + h / 2, w: w / 2, h: h / 2 }; + this.southwest = new QuadTree(sw, this.capacity); + const se = { x: x + w / 2, y: y + h / 2, w: w / 2, h: h / 2 }; + this.southeast = new QuadTree(se, this.capacity); + this.divided = true; + } + + // Added contains method to check if a point is within the current boundary + private contains(point: T): boolean { + return ( + point.x >= this.boundary.x - this.boundary.w && + point.x < this.boundary.x + this.boundary.w && + point.y >= this.boundary.y - this.boundary.h && + point.y < this.boundary.y + this.boundary.h + ); + } + + // Fixed insert method to properly call insert on children and return boolean + public insert(point: T): boolean { + if (!this.contains(point)) return false; + + if (this.points.length < this.capacity) { + this.points.push(point); + return true; + } + + if (!this.divided) this.subdivide(); + + return ( + this.northwest!.insert(point) || + this.northeast!.insert(point) || + this.southwest!.insert(point) || + this.southeast!.insert(point) + ); + } + + // Added query method for spatial searches + public query(range: Boundary, found: T[] = []): T[] { + if (!this.intersects(range)) return found; + + for (const p of this.points) { + if (this.pointInRange(p, range)) { + found.push(p); + } + } + + if (this.divided) { + this.northwest!.query(range, found); + this.northeast!.query(range, found); + this.southwest!.query(range, found); + this.southeast!.query(range, found); + } + + return found; + } + + private intersects(range: Boundary): boolean { + return !( + range.x - range.w > this.boundary.x + this.boundary.w || + range.x + range.w < this.boundary.x - this.boundary.w || + range.y - range.h > this.boundary.y + this.boundary.h || + range.y + range.h < this.boundary.y - this.boundary.h + ); + } + + private pointInRange(point: T, range: Boundary): boolean { + return ( + point.x >= range.x - range.w && + point.x < range.x + range.w && + point.y >= range.y - range.h && + point.y < range.y + range.h + ); + } +} diff --git a/engine/SoundEngine.ts b/engine/SoundEngine.ts new file mode 100644 index 0000000..0479f85 --- /dev/null +++ b/engine/SoundEngine.ts @@ -0,0 +1,156 @@ + +import { DamageType } from '../types'; + +let audioCtx: AudioContext | null = null; +let bgmOscillators: { osc: OscillatorNode, gain: GainNode }[] = []; +let bgmInterval: any = null; + +const initAudio = () => { + if (!audioCtx) { + audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } +}; + +/** + * Musique de fond procédurale 8-bit + */ +export const startBGM = () => { + initAudio(); + if (!audioCtx || bgmInterval) return; + + const tempo = 130; + const noteDuration = 60 / tempo / 2; // Croche + let step = 0; + + const progression = [ + { bass: 110, chord: [220, 261.63, 329.63] }, // A2, A3, C4, E4 + { bass: 87.31, chord: [174.61, 220, 261.63] }, // F2, F3, A3, C4 + { bass: 130.81, chord: [261.63, 329.63, 392] }, // C3, C4, E4, G4 + { bass: 98, chord: [196, 246.94, 293.66] } // G2, G3, B3, D4 + ]; + + bgmInterval = setInterval(() => { + if (!audioCtx || audioCtx.state === 'suspended') return; + const now = audioCtx.currentTime; + const measure = Math.floor(step / 16) % progression.length; + const beatInMeasure = step % 16; + const current = progression[measure]; + + if (beatInMeasure % 4 === 0) { + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(current.bass, now); + gain.gain.setValueAtTime(0.03, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + noteDuration * 2); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(now + noteDuration * 2); + } + + if (beatInMeasure % 2 === 0) { + const noteIdx = (beatInMeasure / 2) % current.chord.length; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(current.chord[noteIdx], now); + gain.gain.setValueAtTime(0.02, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + noteDuration); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(now + noteDuration); + } + + step++; + }, noteDuration * 1000); +}; + +export const stopBGM = () => { + if (bgmInterval) { + clearInterval(bgmInterval); + bgmInterval = null; + } +}; + +export const playCollectXPSound = () => { + initAudio(); + if (!audioCtx) return; + const now = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, now); + osc.frequency.exponentialRampToValueAtTime(1760, now + 0.1); + gain.gain.setValueAtTime(0.05, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(now + 0.15); +}; + +export const playShotSound = (type: DamageType) => { + initAudio(); + if (!audioCtx) return; + const now = audioCtx.currentTime; + const masterGain = audioCtx.createGain(); + masterGain.connect(audioCtx.destination); + masterGain.gain.setValueAtTime(0.08, now); + masterGain.gain.exponentialRampToValueAtTime(0.01, now + 0.2); + + switch (type) { + case DamageType.EM: { + const osc = audioCtx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(800, now); + osc.frequency.exponentialRampToValueAtTime(100, now + 0.1); + osc.connect(masterGain); + osc.start(); osc.stop(now + 0.1); + break; + } + case DamageType.KINETIC: { + const bufferSize = audioCtx.sampleRate * 0.05; + const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1; + const noise = audioCtx.createBufferSource(); + noise.buffer = buffer; + const filter = audioCtx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(1000, now); + noise.connect(filter); + filter.connect(masterGain); + noise.start(); + break; + } + case DamageType.THERMAL: { + const osc = audioCtx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(400, now); + osc.frequency.linearRampToValueAtTime(600, now + 0.15); + const filter = audioCtx.createBiquadFilter(); + filter.type = 'highpass'; + filter.frequency.setValueAtTime(2000, now); + osc.connect(filter); + filter.connect(masterGain); + osc.start(); osc.stop(now + 0.15); + break; + } + case DamageType.EXPLOSIVE: { + const osc = audioCtx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(150, now); + osc.frequency.exponentialRampToValueAtTime(40, now + 0.3); + masterGain.gain.setValueAtTime(0.12, now); + masterGain.gain.exponentialRampToValueAtTime(0.01, now + 0.4); + osc.connect(masterGain); + osc.start(); osc.stop(now + 0.4); + break; + } + } +}; diff --git a/engine/StatsCalculator.ts b/engine/StatsCalculator.ts new file mode 100644 index 0000000..1cfaae4 --- /dev/null +++ b/engine/StatsCalculator.ts @@ -0,0 +1,59 @@ + +import { Entity, Stats, GameState } from '../types'; + +/** + * Calcule les statistiques d'une entité en prenant en compte : + * 1. Les statistiques de base + * 2. Les modificateurs des passifs avec RENDEMENT DÉGRESSIF + * 3. Les modificateurs directs (Keystones, Buffs temporaires) + */ +export const calculateRuntimeStats = (entity: Entity, gameState?: GameState): Stats => { + const result = { ...entity.baseStats }; + + if (gameState && entity.id === 'player') { + // 1. Appliquer les passifs avec rendement dégressif + gameState.activePassives.forEach(({ passive, stacks }) => { + passive.modifiers.forEach(mod => { + // Formule de rendement dégressif : + // Le premier stack est à 100%, le second à 80%, le troisième à 64% (0.8^n-1) + // Somme des efficacités : Σ (0.8^i) pour i de 0 à stacks-1 + let effectiveStackValue = 0; + for (let i = 0; i < stacks; i++) { + effectiveStackValue += Math.pow(0.8, i); + } + + if (mod.type === 'additive') { + (result[mod.property] as number) += mod.value * effectiveStackValue; + } else { + // Pour le multiplicatif, on applique la puissance de l'effet + // Si bonus de +10% (1.10), on fait 1 + (0.10 * effectiveStackValue) + const multiplierBase = mod.value - 1; + (result[mod.property] as number) *= (1 + (multiplierBase * effectiveStackValue)); + } + }); + }); + + // 2. Appliquer les Keystones (Directement, pas de rendement dégressif sur les keystones car uniques) + gameState.keystones.forEach(ks => { + ks.modifiers.forEach(mod => { + if (mod.type === 'additive') (result[mod.property] as number) += mod.value; + else (result[mod.property] as number) *= mod.value; + }); + }); + } + + // 3. Modificateurs directs sur l'entité (utilisé pour les ennemis ou buffs globaux) + const additives = entity.modifiers.filter(m => m.type === 'additive'); + const multiplicatives = entity.modifiers.filter(m => m.type === 'multiplicative'); + + additives.forEach(m => { (result[m.property] as number) += m.value; }); + multiplicatives.forEach(m => { (result[m.property] as number) *= m.value; }); + + return result; +}; + +export const syncDefenseState = (entity: Entity) => { + entity.defense.shield = Math.min(entity.defense.shield, entity.runtimeStats.maxShield); + entity.defense.armor = Math.min(entity.defense.armor, entity.runtimeStats.maxArmor); + entity.defense.hull = Math.min(entity.defense.hull, entity.runtimeStats.maxHull); +}; diff --git a/index.html b/index.html index ac5f832..2d7a0ec 100644 --- a/index.html +++ b/index.html @@ -1,1399 +1,62 @@ + - - - - Space InZader - Roguelite Space Shooter + + + + Space InZader + + + + - -
- -
- - - - - - - -
-
-

PAUSE

- -
-
- - -
-
-

COMMANDES

-
-
WASD / ZQSD - Déplacer
-
Tir automatique sur les ennemis
-
ESC - Pause
-
Souris - Naviguer dans les menus
-
- -
-
- - -
-
-

OPTIONS

-
- - - - - 50% -
-
- - - - - 50% -
-
- - -
- -
-
- - -
-
-

MEILLEURS SCORES

-
- -
-
- - -
-
-

CRÉDITS

-
-
Créé par: LinkAtPlug
- -
LNK Compagnie
-
Moteur: JavaScript + Canvas
-
Audio: Web Audio API
-
Version: V 444719.00
-
- -
-
- - -
-

NIVEAU SUPÉRIEUR !

-

Choisissez une amélioration :

-
-
- - - -
-
-

NOUVEAU RECORD!

-

Entrez votre nom:

- -
- - -
-
-
- - -
-
-

🏆 CLASSEMENT 🏆

-
- -
- -
-
- -
-

GAME OVER

-
-
- - -
-
- - -
-

AMÉLIORATIONS

-
- - -
- - -
-
Temps: 0:00
-
-
-
-
📊 STATS
-
- Dégâts: - 100% -
-
- Cadence: - 100% -
-
- Vitesse: - 220 -
-
- Armure: - 0 -
-
- Vol de vie: - 0% -
-
- Régén: - 0.0/s -
-
- Crit: - 5% -
-
-
-
-
- Vague 1 - | - Kills: 0 - | - Score: 0 -
-
-
-
-
-
-
PV: 100/100
- - -
-
-
Niveau: 1
-
-
-
-
-
-
-
⚔️ ARMES
-
-
-
-
✨ BONUS ACTIFS
-
-
-
- - -
- - -
-

🎮 CONTRÔLES

-

W A S D ou Z Q S D - Se déplacer

-

ESC - Pause

-

🔫 Tir automatique sur l'ennemi le plus proche

-

✨ Collectez les orbes verts pour gagner XP

-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+
+
+ + + diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..aaa0c6e --- /dev/null +++ b/index.tsx @@ -0,0 +1,16 @@ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/js/Game.js b/js/Game.js deleted file mode 100644 index befe657..0000000 --- a/js/Game.js +++ /dev/null @@ -1,1305 +0,0 @@ -/** - * @file Game.js - * @description Main game class that coordinates all systems and manages game loop - */ - -// Default stats blueprint - ALL stats must be defined to prevent undefined errors -// Base Stats: Core stats from ship + meta progression -// Derived Stats: Calculated from passives/synergies (multipliers apply to base) -const DEFAULT_STATS = { - // === CORE DAMAGE STATS === - damage: 1, - damageMultiplier: 1, - - // === FIRE RATE STATS === - fireRate: 1, - fireRateMultiplier: 1, - - // === MOVEMENT STATS === - speed: 1, - speedMultiplier: 1, - - // === HEALTH STATS === - maxHealth: 1, - maxHealthMultiplier: 1, - maxHealthAdd: 0, - healthRegen: 0, - - // === DEFENSE STATS === - armor: 0, - shield: 0, - shieldRegen: 0, - shieldRegenDelay: 3.0, - dodgeChance: 0, - - // === LIFESTEAL & SUSTAIN === - lifesteal: 0, - - // === CRITICAL STATS === - critChance: 0, - critDamage: 1.5, - - // === UTILITY STATS === - luck: 0, - xpBonus: 1, - magnetRange: 0, - - // === PROJECTILE STATS === - projectileSpeed: 1, - projectileSpeedMultiplier: 1, - range: 1, - rangeMultiplier: 1, - piercing: 0, - - // === SPECIAL EFFECTS (defaults for passives) === - overheatReduction: 0, - explosionChance: 0, - explosionDamage: 0, - explosionRadius: 0, - stunChance: 0, - reflectDamage: 0, - projectileCount: 0, - ricochetChance: 0, - chainLightning: 0, - slowChance: 0 -}; - -class Game { - constructor() { - this.canvas = document.getElementById('gameCanvas'); - this.ctx = this.canvas.getContext('2d'); - - // Core systems - this.world = new World(); - this.gameState = new GameState(); - this.saveManager = new SaveManager(); - this.audioManager = new AudioManager(); - this.scoreManager = new ScoreManager(); - - // Debug system - this.debugOverlay = null; - - // Screen effects - this.screenEffects = new ScreenEffects(this.canvas); - - // Load save data - this.saveData = this.saveManager.load(); - - // Game systems - this.systems = { - movement: new MovementSystem(this.world, this.canvas), - particle: new ParticleSystem(this.world), - collision: new CollisionSystem(this.world, this.gameState, this.audioManager, null), - combat: new CombatSystem(this.world, this.gameState, this.audioManager), - ai: new AISystem(this.world, this.canvas), - spawner: new SpawnerSystem(this.world, this.gameState, this.canvas), - pickup: new PickupSystem(this.world, this.gameState), - render: new RenderSystem(this.canvas, this.world, this.gameState), - ui: new UISystem(this.world, this.gameState), - wave: new WaveSystem(this.gameState), - weather: new WeatherSystem(this.world, this.canvas, this.audioManager, this.gameState) - }; - - // Synergy system (initialized when game starts) - this.synergySystem = null; - - // Keystone and reroll tracking - this.keystonesOffered = new Set(); - this.rerollsRemaining = 2; - this.levelsUntilRareGuarantee = 4; - - // ESC key debounce protection - this.escapePressed = false; - - // Set particle system reference in collision system - this.systems.collision.particleSystem = this.systems.particle; - - // Set screen effects reference - this.systems.collision.screenEffects = this.screenEffects; - this.systems.render.screenEffects = this.screenEffects; - - // Connect wave system to UI - this.systems.wave.onWaveStart = (waveNumber) => { - this.systems.ui.showWaveAnnouncement(waveNumber); - this.audioManager.playWaveStart(); - this.systems.spawner.triggerWaveSpawns(this.gameState.stats.time); - }; - - // Give UI system reference to wave system for display updates - this.systems.ui.waveSystem = this.systems.wave; - - // Game loop - this.lastTime = 0; - this.running = false; - this.player = null; - - // Expose to window for system access - window.game = this; - - // Initialize - this.init(); - } - - init() { - logger.info('Game', 'Initializing Space InZader...'); - - // Initialize debug overlay - this.debugOverlay = new DebugOverlay(this); - - // Apply volume settings - this.audioManager.setMusicVolume(this.saveData.settings.musicVolume); - this.audioManager.setSFXVolume(this.saveData.settings.sfxVolume); - - // Setup UI event listeners - this.setupUIListeners(); - - // Start in menu - this.gameState.setState(GameStates.MENU); - this.systems.ui.showScreen('menu'); - - // Start render loop - this.startRenderLoop(); - } - - setupUIListeners() { - // Start button - document.getElementById('startButton').addEventListener('click', () => { - if (this.gameState.selectedShip) { - this.startGame(); - } else { - alert('Please select a ship first!'); - } - }); - - // Meta button - document.getElementById('metaButton').addEventListener('click', () => { - this.gameState.setState(GameStates.META_SCREEN); - this.systems.ui.showScreen('meta'); - }); - - // Back to menu from meta - document.getElementById('backToMenuButton').addEventListener('click', () => { - this.gameState.setState(GameStates.MENU); - this.systems.ui.showScreen('menu'); - }); - - // Return to menu from game over - document.getElementById('returnMenuButton').addEventListener('click', () => { - this.gameState.setState(GameStates.MENU); - this.systems.ui.showScreen('menu'); - }); - - // View scoreboard from game over - document.getElementById('viewScoreboardButton').addEventListener('click', () => { - this.systems.ui.showScoreboard(); - }); - - // Scoreboard back button - document.getElementById('scoreboardBackButton').addEventListener('click', () => { - this.systems.ui.hideScoreboard(); - this.systems.ui.showGameOver(); - }); - - // Submit score with name - document.getElementById('submitScoreButton').addEventListener('click', () => { - this.submitScore(); - }); - - // Skip score entry - document.getElementById('skipScoreButton').addEventListener('click', () => { - this.systems.ui.hideNameEntryDialog(); - this.systems.ui.showGameOver(); - }); - - // Allow Enter key to submit name - document.getElementById('playerNameInput').addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - this.submitScore(); - } - }); - - // Listen for ship selection - window.addEventListener('shipSelected', (e) => { - this.gameState.selectedShip = e.detail.ship; - }); - - // Listen for boost selection - BULLETPROOF handler - window.addEventListener('boostSelected', (e) => { - try { - // Safety guards - if (!e || !e.detail) { - logger.error('Game', 'Invalid boostSelected event - no detail'); - return; - } - - const boost = e.detail.boost; - - if (!boost) { - logger.error('Game', 'Invalid boostSelected event - no boost'); - return; - } - - if (!boost.type) { - logger.error('Game', 'Invalid boost - missing type', boost); - return; - } - - if (!boost.key) { - logger.error('Game', 'Invalid boost - missing key', boost); - return; - } - - // Apply the boost - this.applyBoost(boost); - - } catch (error) { - logger.error('Game', 'Error applying boost', error); - console.error('Boost application error:', error); - } finally { - // ALWAYS resume game, no matter what happened - try { - this.gameState.setState(GameStates.RUNNING); - this.running = true; - this.systems.ui.showScreen('game'); - logger.info('Game', 'Game resumed after boost selection'); - } catch (resumeError) { - logger.error('Game', 'Critical error resuming game', resumeError); - console.error('Resume error:', resumeError); - // Last resort - force state - this.running = true; - } - } - }); - - // Listen for reroll event - window.addEventListener('rerollBoosts', (e) => { - if (this.rerollsRemaining > 0) { - this.rerollsRemaining--; - const boosts = this.generateBoostOptions(); - this.gameState.pendingBoosts = boosts; - this.systems.ui.showLevelUp(boosts, this.rerollsRemaining); - logger.info('Game', `Rerolled boosts, ${this.rerollsRemaining} rerolls remaining`); - } - }); - - // Pause/Resume with ESC key - window.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - // Prevent key repeat from triggering multiple times - if (e.repeat) return; - - // Check if we're in a sub-screen (commands or options) - const uiSystem = this.systems?.ui; - if (uiSystem?.isScreenActive('commandsScreen')) { - // If commands screen is active, go back to pause menu - uiSystem.showPauseMenu(); - return; - } - if (uiSystem?.isScreenActive('optionsScreen')) { - // If options screen is active, go back based on return screen - if (uiSystem.optionsReturnScreen === 'pause') { - uiSystem.showPauseMenu(); - } else { - uiSystem.showMainMenu(); - } - return; - } - - // Normal pause/resume logic - if (this.gameState.isState(GameStates.RUNNING)) { - this.pauseGame(); - } else if (this.gameState.isState(GameStates.PAUSED)) { - this.resumeGame(); - } - } - }); - - // Initialize audio on first user interaction - let audioInitialized = false; - const initAudio = () => { - if (!audioInitialized) { - this.audioManager.init(); - audioInitialized = true; - - // Start background music immediately after initialization - this.audioManager.startBackgroundMusic(); - console.log('Audio initialized and music started'); - - document.removeEventListener('click', initAudio); - document.removeEventListener('keydown', initAudio); - } - }; - document.addEventListener('click', initAudio); - document.addEventListener('keydown', initAudio); - } - - startGame() { - logger.info('Game', 'Starting game with ship: ' + this.gameState.selectedShip); - - // Reset world and stats - this.world.clear(); - this.gameState.resetStats(); - this.gameState.setState(GameStates.RUNNING); - - // Reset keystone and reroll tracking - this.keystonesOffered.clear(); - this.rerollsRemaining = 2; - this.levelsUntilRareGuarantee = 4; - - // Create player - this.createPlayer(); - - // Initialize synergy system - this.synergySystem = new SynergySystem(this.world, this.player); - this.world.particleSystem = this.systems.particle; - - // Reset systems - this.systems.spawner.reset(); - this.systems.render.reset(); - this.systems.wave.reset(); - this.systems.weather.reset(); - this.screenEffects.reset(); - - // Hide menu, show game - this.systems.ui.showScreen('game'); - - // Start background music - this.audioManager.startBackgroundMusic(); - - logger.info('Game', 'Game started successfully'); - - // Start game loop - this.running = true; - } - - createPlayer() { - const shipData = ShipData.getShipData(this.gameState.selectedShip); - if (!shipData) { - console.error('Invalid ship:', this.gameState.selectedShip); - return; - } - - // Apply meta-progression bonuses - const metaHealth = this.saveData.upgrades.maxHealth * 10; - const metaDamage = 1 + (this.saveData.upgrades.baseDamage * 0.05); - const metaXP = 1 + (this.saveData.upgrades.xpBonus * 0.1); - - this.player = this.world.createEntity('player'); - - const maxHealth = shipData.baseStats.maxHealth + metaHealth; - - this.player.addComponent('position', Components.Position( - this.canvas.width / 2, - this.canvas.height / 2 - )); - - this.player.addComponent('velocity', Components.Velocity(0, 0)); - this.player.addComponent('collision', Components.Collision(15)); - - this.player.addComponent('health', Components.Health(maxHealth, maxHealth)); - - // Add shield component (starts at 0) - this.player.addComponent('shield', Components.Shield(0, 0, 0)); - - const playerComp = Components.Player(); - playerComp.speed = shipData.baseStats.speed; - - // Initialize stats from DEFAULT_STATS blueprint to prevent undefined errors - playerComp.stats = structuredClone(DEFAULT_STATS); - - // Apply ship-specific stats (using metaDamage and metaXP from above) - playerComp.stats.damage = shipData.baseStats.damageMultiplier * metaDamage; - playerComp.stats.damageMultiplier = shipData.baseStats.damageMultiplier * metaDamage; - playerComp.stats.fireRate = shipData.baseStats.fireRateMultiplier; - playerComp.stats.fireRateMultiplier = shipData.baseStats.fireRateMultiplier; - playerComp.stats.speed = shipData.baseStats.speed / 200; // Normalize speed - playerComp.stats.speedMultiplier = 1; - playerComp.stats.maxHealth = 1; - playerComp.stats.critChance = shipData.baseStats.critChance; - playerComp.stats.critDamage = shipData.baseStats.critMultiplier; - playerComp.stats.lifesteal = shipData.baseStats.lifesteal; - playerComp.stats.healthRegen = shipData.baseStats.healthRegen || 0; - playerComp.stats.xpBonus = metaXP; - playerComp.stats.armor = shipData.baseStats.armor || 0; - - // Store base stats snapshot for delta calculations in UI - // This represents ship stats + meta progression before any passives - playerComp.baseStats = { - damageMultiplier: playerComp.stats.damageMultiplier, - fireRateMultiplier: playerComp.stats.fireRateMultiplier, - speed: playerComp.stats.speed, - maxHealth: maxHealth, - armor: playerComp.stats.armor, - critChance: playerComp.stats.critChance, - critDamage: playerComp.stats.critDamage, - lifesteal: playerComp.stats.lifesteal, - healthRegen: playerComp.stats.healthRegen, - rangeMultiplier: 1, - projectileSpeedMultiplier: 1 - }; - - this.player.addComponent('player', playerComp); - - this.player.addComponent('renderable', Components.Renderable( - shipData.color, - 15, - 'triangle' - )); - - // Add starting weapon - this.addWeaponToPlayer(shipData.startingWeapon); - - console.log('Player created:', this.player); - } - - addWeaponToPlayer(weaponType) { - if (!this.player) { - logger.warn('Game', 'Cannot add weapon - no player'); - return; - } - - const playerComp = this.player.getComponent('player'); - if (!playerComp) { - logger.warn('Game', 'Cannot add weapon - no player component'); - return; - } - - const weaponData = WeaponData.getWeaponData(weaponType); - if (!weaponData) { - logger.error('Game', `Invalid weapon: ${weaponType}`); - return; - } - - // Check if weapon already exists - const existing = playerComp.weapons.find(w => w.type === weaponType); - if (existing) { - // Level up weapon - if (existing.level < weaponData.maxLevel) { - existing.level++; - logger.info('Game', `Leveled up ${weaponType} to level ${existing.level}`); - } else { - logger.warn('Game', `Weapon ${weaponType} already at max level`); - } - } else { - // Add new weapon - playerComp.weapons.push({ - type: weaponType, - level: 1, - data: weaponData, - cooldown: 0, - evolved: false - }); - logger.info('Game', `Added weapon: ${weaponType}`); - } - - this.systems.ui.updateHUD(); - } - - addPassiveToPlayer(passiveType) { - if (!this.player) { - logger.warn('Game', 'Cannot add passive - no player'); - return; - } - - const playerComp = this.player.getComponent('player'); - if (!playerComp) { - logger.warn('Game', 'Cannot add passive - no player component'); - return; - } - - // Check if it's a keystone (try KeystoneData first) - let passiveData = KeystoneData.getKeystone(passiveType); - let isKeystone = !!passiveData; - - if (!passiveData) { - passiveData = PassiveData.getPassiveData(passiveType); - } - - if (!passiveData) { - logger.error('Game', `Invalid passive: ${passiveType}`); - return; - } - - // Track keystone acquisition - if (isKeystone) { - this.keystonesOffered.add(passiveType); - logger.info('Game', `Acquired keystone: ${passiveType}`); - } - - // Check if passive already exists - const existing = playerComp.passives.find(p => p.type === passiveType); - if (existing) { - // Stack passive (keystones can't stack) - if (!isKeystone && existing.stacks < passiveData.maxStacks) { - existing.stacks++; - logger.info('Game', `Stacked ${passiveType} to ${existing.stacks}`); - } else { - logger.warn('Game', `Passive ${passiveType} already at max stacks`); - } - } else { - // Add new passive - playerComp.passives.push({ - type: passiveType, - data: passiveData, - stacks: 1, - id: passiveType - }); - logger.info('Game', `Added passive: ${passiveType}`); - } - - // Recalculate stats - const health = this.player.getComponent('health'); - console.log(`Before recalculate - HP: ${health ? health.current + '/' + health.max : 'N/A'}`); - this.recalculatePlayerStats(); - - // Log after recalculation to verify - if (health) { - console.log(`After recalculate - HP: ${health.current}/${health.max}`); - } - } - - recalculatePlayerStats() { - if (!this.player) return; - - const playerComp = this.player.getComponent('player'); - if (!playerComp) return; - - // Store old health and shield values before recalculation - const health = this.player.getComponent('health'); - const shield = this.player.getComponent('shield'); - const oldMaxHP = health ? health.max : 100; - const oldCurrentHP = health ? health.current : 100; - const oldMaxShield = shield ? shield.max : 0; - const oldCurrentShield = shield ? shield.current : 0; - - // Reset stats to DEFAULT_STATS blueprint to prevent undefined errors - playerComp.stats = structuredClone(DEFAULT_STATS); - - // Apply ship-specific base stats - const shipData = ShipData.getShipData(this.gameState.selectedShip); - const metaDamage = 1 + (this.saveData.upgrades.baseDamage * 0.05); - const metaXP = 1 + (this.saveData.upgrades.xpBonus * 0.1); - - playerComp.stats.damage = shipData.baseStats.damageMultiplier * metaDamage; - playerComp.stats.damageMultiplier = shipData.baseStats.damageMultiplier * metaDamage; - playerComp.stats.fireRate = shipData.baseStats.fireRateMultiplier; - playerComp.stats.fireRateMultiplier = shipData.baseStats.fireRateMultiplier; - playerComp.stats.speed = shipData.baseStats.speed / 200; // Normalize speed - playerComp.stats.speedMultiplier = 1; - playerComp.stats.critChance = shipData.baseStats.critChance; - playerComp.stats.critDamage = shipData.baseStats.critMultiplier; - playerComp.stats.lifesteal = shipData.baseStats.lifesteal; - playerComp.stats.healthRegen = shipData.baseStats.healthRegen || 0; - playerComp.stats.xpBonus = metaXP; - playerComp.stats.armor = shipData.baseStats.armor || 0; - playerComp.stats.projectileSpeed = 1; - playerComp.stats.projectileSpeedMultiplier = 1; - playerComp.stats.range = 1; - playerComp.stats.rangeMultiplier = 1; - playerComp.stats.shield = 0; - playerComp.stats.shieldRegen = 0; - playerComp.stats.shieldRegenDelay = 3.0; - - // Apply all passives - for (const passive of playerComp.passives) { - PassiveData.applyPassiveEffects(passive, playerComp.stats); - } - - // Recalculate max HP using base stats vs derived stats formula - if (health) { - // Store old values - const oldMax = health.max; - const oldCurrent = health.current; - const ratio = oldMax > 0 ? oldCurrent / oldMax : 1; - - // Calculate base max HP (ship stats + meta upgrades) - const metaHealth = this.saveData.upgrades.maxHealth * 10; - const baseMaxHP = shipData.baseStats.maxHealth + metaHealth; - - // Get multiplier and flat additions from passives - const hpMultiplier = playerComp.stats.maxHealthMultiplier || 1; - const hpAdd = playerComp.stats.maxHealthAdd || 0; - - // Calculate new max: floor(baseMaxHP * hpMultiplier + hpAdd), minimum 1 - const newMax = Math.max(1, Math.floor(baseMaxHP * hpMultiplier + hpAdd)); - - console.log(`HP Calculation: base=${baseMaxHP}, multiplier=${hpMultiplier}, add=${hpAdd}, newMax=${newMax}`); - - // Apply new max - health.max = newMax; - - // Adjust current HP: clamp(ceil(newMax * ratio), 1, newMax) - health.current = Math.max(1, Math.min(Math.ceil(newMax * ratio), newMax)); - - console.log(`Max HP recalculated: ${oldMax} -> ${health.max}, Current: ${oldCurrent} -> ${health.current}`); - } - - // Update shield component based on stats with ratio preservation - if (shield && playerComp.stats.shield > 0) { - const newMaxShield = playerComp.stats.shield; - - // Preserve shield ratio - const shieldRatio = oldMaxShield > 0 ? oldCurrentShield / oldMaxShield : 1; - shield.max = newMaxShield; - shield.current = Math.max(0, Math.min(Math.ceil(shield.max * shieldRatio), shield.max)); - shield.regen = playerComp.stats.shieldRegen; - shield.regenDelayMax = playerComp.stats.shieldRegenDelay; - - console.log(`Shield recalculated: ${oldMaxShield} -> ${shield.max}, Current: ${oldCurrentShield} -> ${shield.current}`); - } else if (shield) { - // No shield stats, reset shield - shield.current = 0; - shield.max = 0; - shield.regen = 0; - } - - // Force synergy system to recalculate - if (this.synergySystem) { - this.synergySystem.forceRecalculate(); - } - - // Apply soft caps to prevent infinite stacking - this.applySoftCaps(playerComp.stats); - - // Validate stats and log warnings - this.validateStats(playerComp.stats); - - console.log('Player stats recalculated:', playerComp.stats); - } - - /** - * Apply soft caps to stats to prevent infinite stacking - * @param {Object} stats - Player stats object - */ - applySoftCaps(stats) { - // Lifesteal cap at 50% to prevent invincibility - if (stats.lifesteal > 0.5) { - console.warn(`Lifesteal capped at 50% (was ${(stats.lifesteal * 100).toFixed(1)}%)`); - stats.lifesteal = 0.5; - } - - // Health regen cap at 10/s to prevent trivializing damage - if (stats.healthRegen > 10) { - console.warn(`Health regen capped at 10/s (was ${stats.healthRegen.toFixed(1)}/s)`); - stats.healthRegen = 10; - } - - // Fire rate minimum 0.1 (max 10x speed) to prevent freeze - if (stats.fireRate < 0.1) { - console.warn(`Fire rate capped at minimum 0.1 (was ${stats.fireRate.toFixed(2)})`); - stats.fireRate = 0.1; - } - - // Fire rate maximum 10 to prevent performance issues - if (stats.fireRate > 10) { - console.warn(`Fire rate capped at 10 (was ${stats.fireRate.toFixed(2)})`); - stats.fireRate = 10; - } - - // Speed minimum 0.2 to prevent getting stuck - if (stats.speed < 0.2) { - console.warn(`Speed capped at minimum 0.2 (was ${stats.speed.toFixed(2)})`); - stats.speed = 0.2; - } - - // Speed maximum 5 to prevent control issues - if (stats.speed > 5) { - console.warn(`Speed capped at 5 (was ${stats.speed.toFixed(2)})`); - stats.speed = 5; - } - - // Crit chance cap at 100% - if (stats.critChance > 1.0) { - console.warn(`Crit chance capped at 100% (was ${(stats.critChance * 100).toFixed(1)}%)`); - stats.critChance = 1.0; - } - - // Dodge chance cap at 75% to maintain some risk - if (stats.dodgeChance > 0.75) { - console.warn(`Dodge chance capped at 75% (was ${(stats.dodgeChance * 100).toFixed(1)}%)`); - stats.dodgeChance = 0.75; - } - } - - /** - * Validate stats for sanity and log warnings for extreme values - * @param {Object} stats - Player stats object - */ - validateStats(stats) { - const warnings = []; - - // Check for undefined stats (critical error) - for (const [key, value] of Object.entries(stats)) { - if (value === undefined) { - warnings.push(`CRITICAL: Stat '${key}' is undefined!`); - } - } - - // Check for extreme values - if (stats.damageMultiplier > 10) { - warnings.push(`Very high damage multiplier: ${stats.damageMultiplier.toFixed(2)}x`); - } - - if (stats.fireRateMultiplier > 5) { - warnings.push(`Very high fire rate multiplier: ${stats.fireRateMultiplier.toFixed(2)}x`); - } - - if (stats.speedMultiplier > 3) { - warnings.push(`Very high speed multiplier: ${stats.speedMultiplier.toFixed(2)}x`); - } - - if (stats.lifesteal > 0.3) { - warnings.push(`High lifesteal: ${(stats.lifesteal * 100).toFixed(1)}%`); - } - - if (stats.healthRegen > 5) { - warnings.push(`High health regen: ${stats.healthRegen.toFixed(1)}/s`); - } - - // Log all warnings grouped - if (warnings.length > 0) { - console.group('%c⚠️ Stats Validation Warnings', 'color: #ffaa00; font-weight: bold;'); - warnings.forEach(w => console.warn(w)); - console.groupEnd(); - } - } - - triggerLevelUp() { - logger.info('Game', 'Player leveled up!'); - this.gameState.setState(GameStates.LEVEL_UP); - - // Generate 3 random boosts - const boosts = this.generateBoostOptions(); - this.gameState.pendingBoosts = boosts; - - this.systems.ui.showLevelUp(boosts); - - // Play level up sound - this.audioManager.playSFX('levelup'); - } - - generateBoostOptions() { - const options = []; - const playerComp = this.player.getComponent('player'); - - if (!playerComp) return options; - - const luck = playerComp.stats.luck; - const shipData = ShipData.getShipData(this.gameState.selectedShip); - - // Check if we should offer keystone - const keystone = KeystoneData.getKeystoneForClass(shipData.id); - let keystoneOffered = false; - if (keystone && !this.keystonesOffered.has(keystone.id)) { - // 25% chance to offer keystone if not yet obtained - if (Math.random() < 0.25) { - options.push({ - type: 'passive', - key: keystone.id, - data: keystone, - isKeystone: true - }); - keystoneOffered = true; - } - } - - // Determine if rare guarantee applies - let forceRare = false; - if (this.levelsUntilRareGuarantee <= 0) { - forceRare = true; - this.levelsUntilRareGuarantee = 4; - } else { - this.levelsUntilRareGuarantee--; - } - - // Generate remaining options (3 total, or 2 if keystone offered) - const numOptions = keystoneOffered ? 2 : 3; - let attempts = 0; - const maxAttempts = 100; // Prevent infinite loops - - while (options.length < (keystoneOffered ? 3 : 3) && attempts < maxAttempts) { - const constraintLevel = Math.floor(attempts / 20); // Relax constraints every 20 attempts - const boost = this.selectRandomBoost(luck, options, forceRare && options.length === (keystoneOffered ? 1 : 0), constraintLevel); - if (boost) { - options.push(boost); - attempts = 0; // Reset attempts on success - } else { - attempts++; - } - } - - // Fallback: If still not enough options, try absolute last resort - while (options.length < (keystoneOffered ? 3 : 3)) { - const boost = this.selectRandomBoostLastResort(options); - if (boost) { - options.push(boost); - } else { - break; // No more options possible - } - } - - return options; - } - - selectRandomBoost(luck, existing, forceRare = false, constraintLevel = 0) { - const playerComp = this.player.getComponent('player'); - if (!playerComp) return null; - - const shipData = ShipData.getShipData(this.gameState.selectedShip); - const preferredTags = shipData.preferredTags || []; - const bannedTags = shipData.bannedTags || []; - - // Progressive constraint relaxation: - // 0: Use all constraints (preferred tags, rarity, banned tags) - // 1: Ignore preferred tags - // 2: Ignore rarity restrictions - // 3: Ignore banned tags (last resort) - const usePreferredTags = constraintLevel < 1; - const useRarityFilter = constraintLevel < 2; - const useBannedTags = constraintLevel < 3; - - // Try rarities in order based on luck, with fallbacks - const rarities = ['legendary', 'epic', 'rare', 'common']; - - // Determine starting rarity based on luck or force rare - let startIndex; - if (forceRare) { - startIndex = 2; // Force rare or better - } else { - const roll = Math.random() + luck * 0.1; - - if (roll > 0.95) startIndex = 0; // legendary - else if (roll > 0.8) startIndex = 1; // epic - else if (roll > 0.5) startIndex = 2; // rare - else startIndex = 3; // common - } - - // If not using rarity filter, try all rarities - if (!useRarityFilter) { - startIndex = 0; - } - - // Try each rarity starting from the rolled one - for (let i = startIndex; i < rarities.length; i++) { - const rarity = rarities[i]; - - // 60% chance to use preferred tags, 40% for global pool (only if using preferred tags) - const usePreferred = usePreferredTags && Math.random() < 0.6; - - // Get available weapons with tag filtering - const availableWeapons = Object.keys(WeaponData.WEAPONS).filter(key => { - const weapon = WeaponData.WEAPONS[key]; - const saveWeapon = this.saveData.weapons[weapon.id]; - if (!saveWeapon || !saveWeapon.unlocked) return false; - if (useRarityFilter && weapon.rarity !== rarity) return false; - - // Check if weapon already at max level - const existing = playerComp.weapons.find(w => w.type === weapon.id); - if (existing && existing.level >= weapon.maxLevel) return false; - - // Filter by banned tags (unless relaxed) - if (useBannedTags) { - const hasBannedTag = weapon.tags?.some(t => bannedTags.includes(t)); - if (hasBannedTag) return false; - } - - // If using preferred tags, check for match - if (usePreferred) { - return weapon.tags?.some(t => preferredTags.includes(t)); - } - - return true; - }); - - // Get available passives with tag filtering - const availablePassives = Object.keys(PassiveData.PASSIVES).filter(key => { - const passive = PassiveData.PASSIVES[key]; - const savePassive = this.saveData.passives[passive.id]; - if (!savePassive || !savePassive.unlocked) return false; - if (useRarityFilter && passive.rarity !== rarity) return false; - - // Check if passive already at maxStacks - const existing = playerComp.passives.find(p => p.id === passive.id); - if (existing && existing.stacks >= passive.maxStacks) return false; - - // Filter by banned tags (unless relaxed) - if (useBannedTags) { - const hasBannedTag = passive.tags?.some(t => bannedTags.includes(t)); - if (hasBannedTag) return false; - } - - // If using preferred tags, check for match - if (usePreferred) { - return passive.tags?.some(t => preferredTags.includes(t)); - } - - return true; - }); - - let all = [ - ...availableWeapons.map(w => ({ type: 'weapon', key: WeaponData.WEAPONS[w].id, data: WeaponData.WEAPONS[w] })), - ...availablePassives.map(p => ({ type: 'passive', key: PassiveData.PASSIVES[p].id, data: PassiveData.PASSIVES[p] })) - ]; - - // FIX: If preferred pool is empty, fallback to global pool for this rarity - if (all.length === 0 && usePreferred) { - logger.debug('Game', `No preferred options at ${rarity}, trying global pool`); - - // Retry without preferred filter - const globalWeapons = Object.keys(WeaponData.WEAPONS).filter(key => { - const weapon = WeaponData.WEAPONS[key]; - const saveWeapon = this.saveData.weapons[weapon.id]; - if (!saveWeapon || !saveWeapon.unlocked) return false; - if (weapon.rarity !== rarity) return false; - - const existing = playerComp.weapons.find(w => w.type === weapon.id); - if (existing && existing.level >= weapon.maxLevel) return false; - - const hasBannedTag = weapon.tags?.some(t => bannedTags.includes(t)); - if (hasBannedTag) return false; - - return true; - }); - - const globalPassives = Object.keys(PassiveData.PASSIVES).filter(key => { - const passive = PassiveData.PASSIVES[key]; - const savePassive = this.saveData.passives[passive.id]; - if (!savePassive || !savePassive.unlocked) return false; - if (passive.rarity !== rarity) return false; - - const existing = playerComp.passives.find(p => p.id === passive.id); - if (existing && existing.stacks >= passive.maxStacks) return false; - - const hasBannedTag = passive.tags?.some(t => bannedTags.includes(t)); - if (hasBannedTag) return false; - - return true; - }); - - all = [ - ...globalWeapons.map(w => ({ type: 'weapon', key: WeaponData.WEAPONS[w].id, data: WeaponData.WEAPONS[w] })), - ...globalPassives.map(p => ({ type: 'passive', key: PassiveData.PASSIVES[p].id, data: PassiveData.PASSIVES[p] })) - ]; - } - - // Filter out duplicates - const filtered = all.filter(item => { - return !existing.some(e => e.type === item.type && e.key === item.key); - }); - - logger.debug('Game', `Rarity ${rarity}: found ${filtered.length} options (before dedup: ${all.length})`); - - // If we found options, select one - if (filtered.length > 0) { - const selected = MathUtils.randomChoice(filtered); - logger.info('Game', `Selected ${selected.type}: ${selected.key} (${rarity})`); - return { - type: selected.type, - key: selected.key, - name: selected.data.name, - description: selected.data.description, - rarity: selected.data.rarity, - color: selected.data.color - }; - } - } - - logger.warn('Game', 'No boost options available at any rarity level'); - - // No options available at any rarity level - return null; - } - - /** - * Last resort boost selection - ignores all constraints except duplicates - */ - selectRandomBoostLastResort(existing) { - const playerComp = this.player.getComponent('player'); - if (!playerComp) return null; - - // Get ALL available weapons (not maxed) - const availableWeapons = Object.keys(WeaponData.WEAPONS).filter(key => { - const weapon = WeaponData.WEAPONS[key]; - const saveWeapon = this.saveData.weapons[weapon.id]; - if (!saveWeapon || !saveWeapon.unlocked) return false; - - const existingWeapon = playerComp.weapons.find(w => w.type === weapon.id); - if (existingWeapon && existingWeapon.level >= weapon.maxLevel) return false; - - return true; - }); - - // Get ALL available passives (not maxed) - const availablePassives = Object.keys(PassiveData.PASSIVES).filter(key => { - const passive = PassiveData.PASSIVES[key]; - const savePassive = this.saveData.passives[passive.id]; - if (!savePassive || !savePassive.unlocked) return false; - - const existingPassive = playerComp.passives.find(p => p.id === passive.id); - if (existingPassive && existingPassive.stacks >= passive.maxStacks) return false; - - return true; - }); - - const all = [ - ...availableWeapons.map(w => ({ type: 'weapon', key: WeaponData.WEAPONS[w].id, data: WeaponData.WEAPONS[w] })), - ...availablePassives.map(p => ({ type: 'passive', key: PassiveData.PASSIVES[p].id, data: PassiveData.PASSIVES[p] })) - ]; - - // Filter out duplicates - const filtered = all.filter(item => { - return !existing.some(e => e.type === item.type && e.key === item.key); - }); - - if (filtered.length > 0) { - const selected = MathUtils.randomChoice(filtered); - return { - type: selected.type, - key: selected.key, - name: selected.data.name, - description: selected.data.description, - rarity: selected.data.rarity, - color: selected.data.color - }; - } - - return null; - } - - /** - * Update music theme based on game intensity - */ - updateMusicTheme() { - if (!this.audioManager || !this.audioManager.initialized) return; - - const enemies = this.world.getEntitiesByType('enemy'); - const enemyCount = enemies.length; - const gameTime = this.gameState.stats.time; - - // Count boss/elite enemies (size >= BOSS_SIZE_THRESHOLD is boss) - const bosses = enemies.filter(e => { - const renderable = e.getComponent('renderable'); - return renderable && renderable.size >= BOSS_SIZE_THRESHOLD; - }); - - // Determine theme based on game state - let targetTheme = 'calm'; - - if (bosses.length > 0) { - // Boss present -> boss theme - targetTheme = 'boss'; - } else if (enemyCount > 20 || gameTime > 180) { - // Many enemies or late game -> action theme - targetTheme = 'action'; - } else if (enemyCount > 10 || gameTime > 60) { - // Some action -> action theme - targetTheme = 'action'; - } - - // Switch theme if different (AudioManager handles crossfade) - if (this.audioManager.currentTheme !== targetTheme) { - this.audioManager.setMusicTheme(targetTheme); - } - } - - applyBoost(boost) { - if (!boost) return; - - logger.info('Game', `Applying boost: ${boost.type} - ${boost.name}`); - - if (boost.type === 'weapon') { - this.addWeaponToPlayer(boost.key); - } else if (boost.type === 'passive') { - this.addPassiveToPlayer(boost.key); - } - - logger.debug('Game', 'Boost applied successfully', boost); - } - - pauseGame() { - // Prevent double pause (PAUSED -> PAUSED) - if (this.gameState.isState(GameStates.PAUSED)) return; - - if (this.gameState.isState(GameStates.RUNNING)) { - this.gameState.setState(GameStates.PAUSED); - this.running = false; - // Show pause UI - if (this.systems && this.systems.ui) { - this.systems.ui.showPauseMenu(); - } - } - } - - resumeGame() { - if (this.gameState.isState(GameStates.PAUSED) || this.gameState.isState(GameStates.LEVEL_UP)) { - this.gameState.setState(GameStates.RUNNING); - this.running = true; - if (this.systems && this.systems.ui) { - this.systems.ui.hidePauseMenu(); - this.systems.ui.showScreen('game'); - } - } - } - - gameOver() { - this.running = false; - this.gameState.setState(GameStates.GAME_OVER); - - // Calculate rewards - const credits = this.gameState.calculateNoyaux(); - this.saveManager.addNoyaux(credits, this.saveData); - this.saveManager.updateStats(this.gameState.stats, this.saveData); - - // Keep background music playing (instead of stopping it) - // Music will continue seamlessly from gameplay to game over screen - - // Check if score qualifies for leaderboard - const finalScore = this.gameState.stats.score; - if (this.scoreManager.qualifiesForLeaderboard(finalScore)) { - // Show name entry dialog - this.systems.ui.showNameEntryDialog(); - } else { - // Show game over screen directly - this.systems.ui.showGameOver(); - } - - // Play death sound - this.audioManager.playSFX('death'); - } - - /** - * Submit score to leaderboard - */ - submitScore() { - const nameInput = document.getElementById('playerNameInput'); - const playerName = nameInput ? nameInput.value.trim() : ''; - - if (!playerName) { - alert('Veuillez entrer un nom'); - return; - } - - const stats = this.gameState.stats; - const waveNumber = this.systems.wave?.getWaveNumber() || 1; - - const scoreData = { - playerName: playerName, - score: stats.score, - time: stats.time, - kills: stats.kills, - level: stats.highestLevel, - wave: waveNumber - }; - - const rank = this.scoreManager.addScore(scoreData); - - // Hide name entry and show game over - this.systems.ui.hideNameEntryDialog(); - this.systems.ui.showGameOver(); - - // Show a congratulations message if in top 3 - if (rank > 0 && rank <= 3) { - const medals = ['🥇', '🥈', '🥉']; - alert(`Félicitations! Vous êtes ${rank}${rank === 1 ? 'er' : 'ème'}! ${medals[rank - 1]}`); - } - } - - startRenderLoop() { - const loop = (currentTime) => { - requestAnimationFrame(loop); - - const deltaTime = (currentTime - this.lastTime) / 1000; - this.lastTime = currentTime; - - // Track render time - const renderStart = performance.now(); - this.systems.render.render(deltaTime); - const renderEnd = performance.now(); - if (this.debugOverlay) { - this.debugOverlay.setRenderTime(renderEnd - renderStart); - } - - // Only update game logic if running - if (this.running && this.gameState.isState(GameStates.RUNNING)) { - const updateStart = performance.now(); - this.update(Math.min(deltaTime, 0.1)); // Cap delta to prevent spiral of death - const updateEnd = performance.now(); - if (this.debugOverlay) { - this.debugOverlay.setUpdateTime(updateEnd - updateStart); - } - } - - // Update debug overlay - if (this.debugOverlay) { - this.debugOverlay.update(); - } - }; - - requestAnimationFrame(loop); - } - - update(deltaTime) { - // Update game time - this.gameState.stats.time += deltaTime; - - // Update wave system - this.systems.wave.update(deltaTime); - this.systems.spawner.setWaveNumber(this.systems.wave.getWaveNumber()); - - // Update synergy system - if (this.synergySystem) { - this.synergySystem.update(deltaTime); - } - - // Update all systems - this.systems.movement.update(deltaTime); - this.systems.ai.update(deltaTime); - this.systems.combat.update(deltaTime); - this.systems.weather.update(deltaTime); - this.systems.collision.update(deltaTime); - - // Update spawner with wave spawn permission - this.systems.spawner.update(deltaTime, this.systems.wave.canSpawn()); - - this.systems.pickup.update(deltaTime); - this.systems.particle.update(deltaTime); - this.screenEffects.update(deltaTime); - - // Update invulnerability - if (this.player) { - const health = this.player.getComponent('health'); - if (health && health.invulnerable) { - health.invulnerableTime -= deltaTime; - if (health.invulnerableTime <= 0) { - health.invulnerable = false; - } - } - - // Update shield regeneration - const shield = this.player.getComponent('shield'); - if (shield && shield.max > 0) { - // Update regen delay - if (shield.regenDelay > 0) { - shield.regenDelay -= deltaTime; - } else { - // Regenerate shield - if (shield.current < shield.max && shield.regen > 0) { - shield.current += shield.regen * deltaTime; - shield.current = Math.min(shield.current, shield.max); - } - } - } - - // Check for game over - if (health && health.current <= 0) { - this.gameOver(); - } - } - - // Process pending entity removals - this.world.processPendingRemovals(); - - // Update UI - this.systems.ui.updateHUD(); - } -} diff --git a/js/constants.js b/js/constants.js deleted file mode 100644 index c567f80..0000000 --- a/js/constants.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Global Constants for Space InZader - * Centralized constants to avoid redeclaration errors - */ - -// Enemy size threshold for boss detection -const BOSS_SIZE_THRESHOLD = 35; - -// Game balance constants -const BASE_XP_TO_LEVEL = 100; -const XP_SCALING_FACTOR = 1.5; - -// Difficulty scaling -const DIFFICULTY_TIME_MULTIPLIER = 0.15; -const DIFFICULTY_WAVE_HEALTH_MULTIPLIER = 0.12; -const DIFFICULTY_WAVE_SPEED_MULTIPLIER = 0.05; - -// Soft caps -const MAX_ENEMY_COUNT_MULTIPLIER = 3.5; -const MAX_ENEMY_HEALTH_MULTIPLIER = 6.0; -const MAX_ENEMY_SPEED_MULTIPLIER = 2.0; - -// Wave system -const WAVE_DURATION_SECONDS = 45; -const INTER_WAVE_PAUSE_SECONDS = 2; -const ELITE_SPAWN_INTERVAL_WAVES = 5; -const BOSS_SPAWN_INTERVAL_WAVES = 10; - -// Audio -const DEFAULT_MUSIC_VOLUME = 0.7; -const DEFAULT_SFX_VOLUME = 0.8; - -// Scoreboard -const MAX_SCOREBOARD_ENTRIES = 10; -const SCORE_KILLS_MULTIPLIER = 10; -const SCORE_WAVE_MULTIPLIER = 500; -const SCORE_BOSS_MULTIPLIER = 2000; diff --git a/js/core/ECS.js b/js/core/ECS.js deleted file mode 100644 index 4702209..0000000 --- a/js/core/ECS.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @file ECS.js - * @description Entity Component System architecture - */ - -/** - * Entity class - */ -class Entity { - constructor(id, type) { - this.id = id; - this.type = type; - this.components = {}; - this.active = true; - } - - addComponent(name, data) { - this.components[name] = data; - return this; - } - - getComponent(name) { - return this.components[name]; - } - - hasComponent(name) { - return this.components[name] !== undefined; - } - - removeComponent(name) { - delete this.components[name]; - return this; - } -} - -/** - * World class - manages all entities - */ -class World { - constructor() { - this.entities = new Map(); - this.nextEntityId = 0; - this.entitiesToRemove = []; - } - - createEntity(type) { - const entity = new Entity(this.nextEntityId++, type); - this.entities.set(entity.id, entity); - return entity; - } - - removeEntity(entityId) { - this.entitiesToRemove.push(entityId); - } - - getEntity(entityId) { - return this.entities.get(entityId); - } - - getEntitiesByType(type) { - return Array.from(this.entities.values()).filter(e => e.type === type && e.active); - } - - getEntitiesWithComponent(componentName) { - return Array.from(this.entities.values()).filter( - e => e.active && e.hasComponent(componentName) - ); - } - - processPendingRemovals() { - for (const id of this.entitiesToRemove) { - this.entities.delete(id); - } - this.entitiesToRemove = []; - } - - clear() { - this.entities.clear(); - this.nextEntityId = 0; - this.entitiesToRemove = []; - } -} - -/** - * Common Component Factory Functions - * These functions create component data objects for entities - * Note: Kept as global functions for backward compatibility - */ - -// Position component -function createPosition(x, y) { - return { x, y }; -} - -// Velocity component -function createVelocity(vx, vy) { - return { vx, vy }; -} - -// Health component -function createHealth(current, max) { - return { - current, - max, - invulnerable: false, - invulnerableTime: 0 - }; -} - -// Shield component -function createShield(current, max, regen) { - return { - current, - max, - regen: regen || 0, // Shield regen per second - regenDelay: 0, // Time since last damage before regen starts - regenDelayMax: 3.0 // Seconds to wait before regen starts - }; -} - -// Collision component -function createCollision(radius) { - return { radius }; -} - -// Renderable component -function createRenderable(color, size, shape = 'circle') { - return { - color, - size, - shape, - rotation: 0 - }; -} - -// Player component -function createPlayer() { - return { - speed: 250, - level: 1, - xp: 0, - xpRequired: 100, - weapons: [], - passives: [], - stats: { - damage: 1, - fireRate: 1, - speed: 1, - maxHealth: 1, - critChance: 0, - critDamage: 1.5, - lifesteal: 0, - luck: 0, - xpBonus: 1, - armor: 0, - projectileSpeed: 1, - range: 1 - } - }; -} - -// Enemy component -function createEnemy(aiType, baseHealth, damage, speed, xpValue) { - return { - aiType, - baseHealth, - damage, - speed, - xpValue, - attackCooldown: 0, - target: null - }; -} - -// Weapon component -function createWeapon(type, level, data) { - return { - type, - level, - data, - cooldown: 0, - currentAmmo: data.ammo || Infinity, - evolved: false - }; -} - -// Projectile component -function createProjectile(damage, speed, lifetime, owner, weaponType) { - return { - damage, - speed, - lifetime, - maxLifetime: lifetime, - owner, - weaponType, - piercing: 0, - homing: false - }; -} - -// Pickup component -function createPickup(type, value) { - return { - type, // 'xp', 'health', 'noyaux' - value, - magnetRange: 150, - collected: false - }; -} - -// Particle component -function createParticle(lifetime, vx, vy, decay = 0.98) { - return { - lifetime, - maxLifetime: lifetime, - vx, - vy, - decay, - alpha: 1 - }; -} - -// Boss component -function createBoss(phase, patterns) { - return { - phase, - patterns, - phaseTime: 0, - nextPhaseHealth: 0.5 - }; -} - -// === Backward compatibility Components wrapper === -// DO NOT REMOVE: used by Game.createPlayer and legacy systems -const Components = { - Position: (x, y) => ({ x, y }), - Velocity: (vx, vy) => ({ vx, vy }), - Health: (current, max) => ({ - current, - max, - invulnerable: false, - invulnerableTime: 0 - }), - Shield: (current, max, regen) => ({ - current, - max, - regen: regen || 0, - regenDelay: 0, - regenDelayMax: 3.0 - }), - Sprite: (sprite) => ({ sprite }), - Collider: (radius) => ({ radius }), - Collision: (radius) => ({ radius }), // Alias for Collider - Weapon: (id) => ({ id }), - Player: () => ({ - speed: 250, - level: 1, - xp: 0, - xpRequired: 100, - weapons: [], - passives: [], - stats: { - damage: 1, - fireRate: 1, - speed: 1, - maxHealth: 1, - critChance: 0, - critDamage: 1.5, - lifesteal: 0, - luck: 0, - xpBonus: 1, - armor: 0, - projectileSpeed: 1, - range: 1, - shield: 0, - shieldRegen: 0, - shieldRegenDelay: 3.0 - } - }), - Renderable: (color, size, shape = 'circle') => ({ - color, - size, - shape, - rotation: 0 - }), - Projectile: (damage, speed, lifetime, owner, weaponType) => ({ - damage, - speed, - lifetime, - maxLifetime: lifetime, - owner, - weaponType, - piercing: 0, - homing: false - }), - Pickup: (type, value) => ({ - type, - value, - magnetRange: 150, - collected: false - }), - Particle: (lifetime, vx, vy, decay = 0.98) => ({ - lifetime, - maxLifetime: lifetime, - vx, - vy, - decay, - alpha: 1 - }), - Enemy: (aiType, baseHealth, damage, speed, xpValue) => ({ - aiType, - baseHealth, - damage, - speed, - xpValue, - attackCooldown: 0, - target: null - }), - Boss: (phase, patterns) => ({ - phase, - patterns, - phaseTime: 0, - nextPhaseHealth: 0.5 - }) -}; diff --git a/js/core/GameState.js b/js/core/GameState.js deleted file mode 100644 index d17feda..0000000 --- a/js/core/GameState.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file GameState.js - * @description Game state machine and management - */ - -const GameStates = { - BOOT: 'BOOT', - MENU: 'MENU', - RUNNING: 'RUNNING', - LEVEL_UP: 'LEVEL_UP', - PAUSED: 'PAUSED', - GAME_OVER: 'GAME_OVER', - META_SCREEN: 'META_SCREEN' -}; - -class GameState { - constructor() { - this.currentState = GameStates.BOOT; - this.previousState = null; - - // Game statistics - this.stats = { - time: 0, - kills: 0, - score: 0, - highestLevel: 1, - noyauxEarned: 0, - damageDealt: 0, - damageTaken: 0 - }; - - // Selected ship - this.selectedShip = null; - - // Pending level up boosts - this.pendingBoosts = []; - } - - setState(newState) { - this.previousState = this.currentState; - this.currentState = newState; - console.log(`State changed: ${this.previousState} -> ${this.currentState}`); - } - - isState(state) { - return this.currentState === state; - } - - resetStats() { - this.stats = { - time: 0, - kills: 0, - score: 0, - highestLevel: 1, - noyauxEarned: 0, - damageDealt: 0, - damageTaken: 0 - }; - } - - addKill(xpValue) { - this.stats.kills++; - this.stats.score += xpValue * 10; - } - - calculateNoyaux() { - // Calculate Noyaux based on performance - const baseNoyaux = Math.floor(this.stats.time / 60); // 1 per minute - const killBonus = Math.floor(this.stats.kills / 10); // 1 per 10 kills - const levelBonus = (this.stats.highestLevel - 1) * 2; - - this.stats.noyauxEarned = baseNoyaux + killBonus + levelBonus; - return this.stats.noyauxEarned; - } -} diff --git a/js/data/EnemyData.js b/js/data/EnemyData.js deleted file mode 100644 index 2b4efc9..0000000 --- a/js/data/EnemyData.js +++ /dev/null @@ -1,548 +0,0 @@ -/** - * @fileoverview Enemy data definitions for Space InZader - * Defines all enemy types with stats, behaviors, and spawn parameters - */ - -/** - * @typedef {Object} AttackPattern - * @property {string} type - Attack type (none/shoot/melee/special) - * @property {number} [damage] - Attack damage - * @property {number} [cooldown] - Attack cooldown in seconds - * @property {number} [range] - Attack range - * @property {number} [projectileSpeed] - Speed of projectile attacks - * @property {string} [projectileColor] - Color of projectiles - */ - -/** - * @typedef {Object} EnemyData - * @property {string} id - Unique identifier - * @property {string} name - Display name - * @property {number} health - Base health points - * @property {number} damage - Contact damage - * @property {number} speed - Movement speed - * @property {number} xpValue - XP dropped on death - * @property {string} aiType - AI behavior type - * @property {number} size - Enemy radius/size - * @property {string} color - Primary color (neon) - * @property {string} secondaryColor - Secondary color - * @property {number} spawnCost - Cost for director system - * @property {AttackPattern} attackPattern - Attack behavior - * @property {number} [armor] - Damage reduction - * @property {number} [splitCount] - Number of enemies spawned on death - * @property {string} [splitType] - Type of enemy spawned on death - */ - -const ENEMIES = { - DRONE_BASIQUE: { - id: 'drone_basique', - name: 'Drone Basique', - health: 30, - damage: 10, - speed: 100, - xpValue: 5, - aiType: 'chase', - size: 12, - color: '#FF1493', - secondaryColor: '#FF69B4', - spawnCost: 1, - attackPattern: { - type: 'none' - }, - armor: 0 - }, - - CHASSEUR_RAPIDE: { - id: 'chasseur_rapide', - name: 'Chasseur Rapide', - health: 18, - damage: 15, - speed: 180, - xpValue: 8, - aiType: 'weave', - size: 10, - color: '#00FF00', - secondaryColor: '#32CD32', - spawnCost: 2, - attackPattern: { - type: 'none' - }, - armor: 0 - }, - - TANK: { - id: 'tank', - name: 'Tank', - health: 120, - damage: 20, - speed: 60, - xpValue: 15, - aiType: 'chase', - size: 20, - color: '#4169E1', - secondaryColor: '#6495ED', - spawnCost: 5, - attackPattern: { - type: 'none' - }, - armor: 5 - }, - - TIREUR: { - id: 'tireur', - name: 'Tireur', - health: 35, - damage: 8, - speed: 80, - xpValue: 12, - aiType: 'kite', - size: 11, - color: '#FFD700', - secondaryColor: '#FFA500', - spawnCost: 3, - attackPattern: { - type: 'shoot', - damage: 12, - cooldown: 2.0, - range: 300, - projectileSpeed: 250, - projectileColor: '#FFFF00' - }, - armor: 0 - }, - - ELITE: { - id: 'elite', - name: 'Élite', - health: 220, - damage: 25, - speed: 120, - xpValue: 40, - aiType: 'aggressive', - size: 18, - color: '#FF4500', - secondaryColor: '#FF6347', - spawnCost: 12, - attackPattern: { - type: 'shoot', - damage: 20, - cooldown: 1.5, - range: 250, - projectileSpeed: 300, - projectileColor: '#FF0000' - }, - armor: 3, - splitCount: 2, - splitType: 'drone_basique' - }, - - BOSS: { - id: 'boss', - name: 'Boss', - health: 1500, - damage: 40, - speed: 90, - xpValue: 200, - aiType: 'boss', - size: 40, - color: '#DC143C', - secondaryColor: '#8B0000', - spawnCost: 100, - attackPattern: { - type: 'special', - damage: 30, - cooldown: 0.8, - range: 400, - projectileSpeed: 350, - projectileColor: '#FF00FF' - }, - armor: 10, - splitCount: 5, - splitType: 'elite' - }, - - TANK_BOSS: { - id: 'tank_boss', - name: 'Tank Boss', - health: 2500, - damage: 60, - speed: 50, - xpValue: 300, - aiType: 'chase', - size: 50, - color: '#4169E1', - secondaryColor: '#1E3A8A', - spawnCost: 150, - attackPattern: { - type: 'melee', - damage: 80, - cooldown: 2.0, - range: 60 - }, - armor: 25, - splitCount: 8, - splitType: 'tank' - }, - - SWARM_BOSS: { - id: 'swarm_boss', - name: 'Swarm Boss', - health: 800, - damage: 25, - speed: 120, - xpValue: 250, - aiType: 'weave', - size: 35, - color: '#00FF00', - secondaryColor: '#008000', - spawnCost: 120, - attackPattern: { - type: 'shoot', - damage: 20, - cooldown: 0.5, - range: 350, - projectileSpeed: 300, - projectileColor: '#00FF00' - }, - armor: 5, - splitCount: 15, - splitType: 'chasseur_rapide' - }, - - SNIPER_BOSS: { - id: 'sniper_boss', - name: 'Sniper Boss', - health: 1200, - damage: 30, - speed: 80, - xpValue: 280, - aiType: 'kite', - size: 38, - color: '#FFD700', - secondaryColor: '#B8860B', - spawnCost: 130, - attackPattern: { - type: 'shoot', - damage: 50, - cooldown: 1.5, - range: 600, - projectileSpeed: 500, - projectileColor: '#FFFF00' - }, - armor: 8, - splitCount: 6, - splitType: 'tireur' - }, - - // New Enemy Variants - EXPLOSIF: { - id: 'explosif', - name: 'Drone Explosif', - health: 15, - damage: 30, // High contact damage - speed: 150, - xpValue: 10, - aiType: 'kamikaze', - size: 14, - color: '#FF6600', - secondaryColor: '#FF3300', - spawnCost: 3, - attackPattern: { - type: 'explode', - damage: 40, - explosionRadius: 80, - explosionColor: '#FF4500' - }, - armor: 0, - isExplosive: true // Flag for explosion on death - }, - - TIREUR_LOURD: { - id: 'tireur_lourd', - name: 'Tireur Lourd', - health: 45, - damage: 15, - speed: 60, - xpValue: 18, - aiType: 'kite', - size: 15, - color: '#8B4513', - secondaryColor: '#A0522D', - spawnCost: 5, - attackPattern: { - type: 'shoot', - damage: 25, - cooldown: 2.5, - range: 400, - projectileSpeed: 200, - projectileColor: '#FF8C00' - }, - armor: 3 - }, - - DEMON_VITESSE: { - id: 'demon_vitesse', - name: 'Démon de Vitesse', - health: 8, - damage: 25, - speed: 250, - xpValue: 15, - aiType: 'aggressive', - size: 9, - color: '#00FFFF', - secondaryColor: '#00CED1', - spawnCost: 4, - attackPattern: { - type: 'none' - }, - armor: 0 - }, - - TOURELLE: { - id: 'tourelle', - name: 'Tourelle', - health: 60, - damage: 5, - speed: 0, // Stationary - xpValue: 20, - aiType: 'stationary', - size: 18, - color: '#696969', - secondaryColor: '#808080', - spawnCost: 6, - attackPattern: { - type: 'shoot', - damage: 18, - cooldown: 1.2, - range: 500, - projectileSpeed: 400, - projectileColor: '#FFA500' - }, - armor: 5 - } -}; - -/** - * AI behavior configurations - */ -const AI_BEHAVIORS = { - chase: { - description: 'Direct pursuit of player', - updateInterval: 0.1, - predictionFactor: 0.0 - }, - weave: { - description: 'Zigzag movement towards player', - updateInterval: 0.15, - weaveAmplitude: 50, - weaveFrequency: 3 - }, - kite: { - description: 'Maintain distance and shoot', - updateInterval: 0.2, - minDistance: 200, - maxDistance: 350 - }, - aggressive: { - description: 'Fast pursuit with prediction', - updateInterval: 0.08, - predictionFactor: 0.5 - }, - boss: { - description: 'Complex multi-phase behavior', - updateInterval: 0.05, - phases: [ - { healthThreshold: 1.0, pattern: 'chase' }, - { healthThreshold: 0.66, pattern: 'shoot_spiral' }, - { healthThreshold: 0.33, pattern: 'enrage' } - ] - }, - kamikaze: { - description: 'Rush directly at player for suicide attack', - updateInterval: 0.05, - predictionFactor: 0.3, - speedBoost: 1.2 // Gets faster as it approaches - }, - stationary: { - description: 'Stays in place and shoots', - updateInterval: 0.3, - rotationSpeed: 2.0 - } -}; - -/** - * Spawn wave configurations for director system - */ -const SPAWN_WAVES = { - early: { - timeRange: [0, 300], // 0-5 minutes - budgetPerSecond: 3, // Increased from 2 - enemyPool: ['drone_basique', 'chasseur_rapide', 'explosif', 'demon_vitesse'], - spawnInterval: 1.5 // Reduced from 2.0 for more frequent spawns - }, - mid: { - timeRange: [300, 600], // 5-10 minutes - budgetPerSecond: 5, // Increased from 4 - enemyPool: ['drone_basique', 'chasseur_rapide', 'tireur', 'tank', 'explosif', 'tireur_lourd', 'demon_vitesse'], - spawnInterval: 1.2 // Reduced from 1.5 - }, - late: { - timeRange: [600, 1200], // 10-20 minutes - budgetPerSecond: 10, // Increased from 8 - enemyPool: ['chasseur_rapide', 'tireur', 'tank', 'elite', 'tireur_lourd', 'tourelle', 'demon_vitesse'], - spawnInterval: 0.9 // Reduced from 1.0 - }, - endgame: { - timeRange: [1200, 9999], // 20+ minutes - budgetPerSecond: 18, // Increased from 15 - enemyPool: ['tank', 'elite', 'boss', 'tireur_lourd', 'tourelle'], - spawnInterval: 0.7 // Reduced from 0.8 - } -}; - -/** - * Get enemy data by ID - * @param {string} enemyId - Enemy identifier - * @returns {EnemyData|null} - */ -function getEnemyData(enemyId) { - return ENEMIES[enemyId.toUpperCase()] || null; -} - -/** - * Scale enemy stats based on time/difficulty - * @param {EnemyData} enemyData - Base enemy data - * @param {number} gameTime - Current game time in seconds - * @param {number} difficultyMultiplier - Additional difficulty scaling - * @returns {EnemyData} - */ -function scaleEnemyStats(enemyData, gameTime, difficultyMultiplier = 1.0) { - const timeFactor = 1 + (gameTime / 300) * 0.3; // +30% every 5 minutes - const scaling = timeFactor * difficultyMultiplier; - - return { - ...enemyData, - health: Math.floor(enemyData.health * scaling), - damage: Math.floor(enemyData.damage * scaling), - xpValue: Math.floor(enemyData.xpValue * timeFactor), // XP scales with time only - attackPattern: enemyData.attackPattern.damage ? { - ...enemyData.attackPattern, - damage: Math.floor(enemyData.attackPattern.damage * scaling) - } : enemyData.attackPattern - }; -} - -/** - * Get current wave configuration based on game time - * @param {number} gameTime - Current game time in seconds - * @returns {Object} - */ -function getCurrentWave(gameTime) { - for (const [key, wave] of Object.entries(SPAWN_WAVES)) { - if (gameTime >= wave.timeRange[0] && gameTime < wave.timeRange[1]) { - return { key, ...wave }; - } - } - return SPAWN_WAVES.endgame; -} - -/** - * Select enemies to spawn based on available budget - * @param {number} budget - Available spawn budget - * @param {Array} enemyPool - Available enemy types - * @param {number} gameTime - Current game time - * @returns {Array} - */ -function selectEnemySpawn(budget, enemyPool, gameTime) { - const enemies = []; - let remainingBudget = budget; - - // Sort pool by spawn cost (descending) for efficient budget use - const sortedPool = enemyPool - .map(id => ({ id, cost: getEnemyData(id).spawnCost })) - .sort((a, b) => b.cost - a.cost); - - while (remainingBudget > 0) { - // Find affordable enemies - const affordable = sortedPool.filter(e => e.cost <= remainingBudget); - - if (affordable.length === 0) break; - - // Weighted random selection (prefer cheaper enemies) - const weights = affordable.map((e, i) => affordable.length - i); - const totalWeight = weights.reduce((sum, w) => sum + w, 0); - - let random = Math.random() * totalWeight; - let selected = null; - - for (let i = 0; i < affordable.length; i++) { - random -= weights[i]; - if (random <= 0) { - selected = affordable[i]; - break; - } - } - - if (selected) { - enemies.push(selected.id); - remainingBudget -= selected.cost; - } else { - break; - } - } - - return enemies; -} - -/** - * Calculate spawn position on screen edge - * @param {number} playerX - Player X position - * @param {number} playerY - Player Y position - * @param {number} screenWidth - Screen width - * @param {number} screenHeight - Screen height - * @returns {{x: number, y: number}} - */ -function getSpawnPosition(playerX, playerY, screenWidth, screenHeight) { - const margin = 50; - const edge = Math.floor(Math.random() * 4); // 0=top, 1=right, 2=bottom, 3=left - - switch (edge) { - case 0: // Top - return { - x: playerX + (Math.random() - 0.5) * screenWidth, - y: playerY - screenHeight / 2 - margin - }; - case 1: // Right - return { - x: playerX + screenWidth / 2 + margin, - y: playerY + (Math.random() - 0.5) * screenHeight - }; - case 2: // Bottom - return { - x: playerX + (Math.random() - 0.5) * screenWidth, - y: playerY + screenHeight / 2 + margin - }; - case 3: // Left - return { - x: playerX - screenWidth / 2 - margin, - y: playerY + (Math.random() - 0.5) * screenHeight - }; - default: - return { x: playerX, y: playerY }; - } -} - -// Export to global namespace -const EnemyData = { - ENEMIES, - AI_BEHAVIORS, - SPAWN_WAVES, - getEnemyData, - scaleEnemyStats, - getCurrentWave, - selectEnemySpawn, - getSpawnPosition -}; - -if (typeof window !== 'undefined') { - window.EnemyData = EnemyData; -} diff --git a/js/data/KeystoneData.js b/js/data/KeystoneData.js deleted file mode 100644 index 9fc4430..0000000 --- a/js/data/KeystoneData.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @fileoverview Keystone passive data definitions for Space InZader - * Keystones are unique, powerful passives tied to specific ship classes - */ - -/** - * @typedef {Object} KeystoneEffect - * @property {string} type - Effect type (e.g., 'stacking_on_hit', 'fire_rate_damage_link') - * @property {number} [stackValue] - Value per stack - * @property {number} [maxStacks] - Maximum stacks - * @property {number} [resetDelayMs] - Delay before stacks reset - * @property {number} [damageBonus] - Damage bonus multiplier - * @property {number} [overheatMultiplier] - Overheat penalty multiplier - * @property {number} [damageReduction] - Damage reduction when active - * @property {number} [explosionBonus] - Explosion radius bonus - * @property {number} [stationaryDelayMs] - Delay to activate when stationary - * @property {number} [damagePerHit] - Damage increase per consecutive hit - * @property {number} [maxHits] - Maximum consecutive hits - * @property {number} [damagePerSummon] - Damage per summon - * @property {number} [rangePerSummon] - Range per summon - * @property {number} [maxSummons] - Max summons to count - * @property {number} [hpThreshold] - HP threshold to activate - * @property {number} [damageMultiplier] - Damage multiplier when active - * @property {number} [speedMultiplier] - Speed multiplier when active - */ - -/** - * @typedef {Object} KeystoneData - * @property {string} id - Unique identifier - * @property {string} name - Display name - * @property {string} description - Effect description - * @property {string} rarity - Always 'epic' for keystones - * @property {string[]} tags - Category tags - * @property {boolean} uniquePerRun - Can only be obtained once per run - * @property {string[]} classOnly - Ship classes that can obtain this - * @property {KeystoneEffect} effect - Keystone effect definition - * @property {string} color - Display color - * @property {string} icon - Icon character - */ - -const KEYSTONES = { - blood_frenzy: { - id: 'blood_frenzy', - name: 'Blood Frenzy', - description: 'Each hit grants +0.25% lifesteal (max 40 stacks). Resets after 3s without hitting.', - rarity: 'epic', - tags: ['vampire', 'on_hit'], - uniquePerRun: true, - classOnly: ['vampire'], - effect: { - type: 'stacking_on_hit', - stackValue: 0.0025, - maxStacks: 40, - resetDelayMs: 3000 - }, - color: '#DC143C', - icon: '🩸' - }, - - overclock_core: { - id: 'overclock_core', - name: 'Overclock Core', - description: '+35% damage per fire rate bonus. 35% more overheat.', - rarity: 'epic', - tags: ['fire_rate', 'heat', 'glass_cannon'], - uniquePerRun: true, - classOnly: ['mitrailleur'], - effect: { - type: 'fire_rate_damage_link', - damageBonus: 0.35, - overheatMultiplier: 1.35 - }, - color: '#FFD700', - icon: '⚡' - }, - - fortress_mode: { - id: 'fortress_mode', - name: 'Fortress Mode', - description: 'When stationary for 700ms: -50% damage taken, +25% explosion radius.', - rarity: 'epic', - tags: ['armor', 'aoe', 'utility'], - uniquePerRun: true, - classOnly: ['tank'], - effect: { - type: 'stationary_buff', - damageReduction: 0.5, - explosionBonus: 0.25, - stationaryDelayMs: 700 - }, - color: '#00BFFF', - icon: '🛡️' - }, - - dead_eye: { - id: 'dead_eye', - name: 'Dead Eye', - description: '+15% damage per consecutive hit (max 8). Reset on miss.', - rarity: 'epic', - tags: ['crit', 'range', 'precision'], - uniquePerRun: true, - classOnly: ['sniper'], - effect: { - type: 'streak_no_miss', - damagePerHit: 0.15, - maxHits: 8 - }, - color: '#9370DB', - icon: '🎯' - }, - - machine_network: { - id: 'machine_network', - name: 'Machine Network', - description: '+6% damage and +5% range per summon (max 10).', - rarity: 'epic', - tags: ['summon', 'turret', 'utility'], - uniquePerRun: true, - classOnly: ['engineer'], - effect: { - type: 'summon_network', - damagePerSummon: 0.06, - rangePerSummon: 0.05, - maxSummons: 10 - }, - color: '#00FF88', - icon: '🤖' - }, - - rage_engine: { - id: 'rage_engine', - name: 'Rage Engine', - description: 'When HP < 30%: 2x damage, 1.3x speed.', - rarity: 'epic', - tags: ['berserk', 'glass_cannon', 'speed'], - uniquePerRun: true, - classOnly: ['berserker'], - effect: { - type: 'hp_threshold', - hpThreshold: 0.3, - damageMultiplier: 2.0, - speedMultiplier: 1.3 - }, - color: '#FF4444', - icon: '💢' - } -}; - -/** - * Get keystone data by ID - * @param {string} keystoneId - Keystone identifier - * @returns {KeystoneData|null} - */ -function getKeystone(keystoneId) { - return KEYSTONES[keystoneId] || null; -} - -/** - * Get all keystones - * @returns {KeystoneData[]} - */ -function getAllKeystones() { - return Object.values(KEYSTONES); -} - -/** - * Get keystone for a specific ship class - * @param {string} classId - Ship class identifier - * @returns {KeystoneData|null} - */ -function getKeystoneForClass(classId) { - return getAllKeystones().find(k => - k.classOnly && k.classOnly.includes(classId) - ) || null; -} - -/** - * Check if keystone is available for class - * @param {string} keystoneId - Keystone identifier - * @param {string} classId - Ship class identifier - * @returns {boolean} - */ -function isKeystoneAvailableForClass(keystoneId, classId) { - const keystone = getKeystone(keystoneId); - if (!keystone) return false; - return keystone.classOnly.includes(classId); -} - -// Export to global namespace -const KeystoneData = { - KEYSTONES, - getKeystone, - getAllKeystones, - getKeystoneForClass, - isKeystoneAvailableForClass -}; - -if (typeof window !== 'undefined') { - window.KeystoneData = KeystoneData; -} diff --git a/js/data/PassiveData.js b/js/data/PassiveData.js deleted file mode 100644 index 6dbdef4..0000000 --- a/js/data/PassiveData.js +++ /dev/null @@ -1,1437 +0,0 @@ -/** - * @fileoverview Passive upgrade data definitions for Space InZader - * Defines all passive items that modify player stats - */ - -/** - * @typedef {Object} PassiveEffects - * @property {number} [damageMultiplier] - Multiplier for damage - * @property {number} [fireRateMultiplier] - Multiplier for fire rate - * @property {number} [critChance] - Critical hit chance (0-1) - * @property {number} [critMultiplier] - Critical damage multiplier - * @property {number} [lifesteal] - Lifesteal percentage (0-1) - * @property {number} [maxHealthMultiplier] - Max health multiplier - * @property {number} [electricDamageBonus] - Bonus electric damage - * @property {number} [stunChance] - Chance to stun (0-1) - * @property {number} [rangeMultiplier] - Range multiplier - * @property {number} [projectileSpeedMultiplier] - Projectile speed multiplier - * @property {number} [magnetRange] - XP/pickup magnet range - * @property {number} [xpMultiplier] - XP gain multiplier - * @property {number} [armor] - Flat damage reduction - * @property {number} [speedMultiplier] - Movement speed multiplier - * @property {number} [dashCooldownReduction] - Dash cooldown reduction (0-1) - * @property {number} [luck] - Luck bonus (affects drops and rarities) - * @property {number} [overheatReduction] - Reduces weapon overheat (0-1) - */ - -/** - * @typedef {Object} PassiveData - * @property {string} id - Unique identifier - * @property {string[]} tags - Category tags for filtering/search (see valid tags below) - * @property {string} name - Display name - * @property {string} description - Passive description - * @property {string} rarity - Rarity tier (common/uncommon/rare/epic) - * @property {PassiveEffects} effects - Stat modifications - * @property {number} maxStacks - Maximum number of times this can be taken - * @property {string} color - Neon color for visuals - * @property {string} icon - Icon character/emoji - */ - -/** - * Valid tag values for categorizing passives: - * - 'vampire' - lifesteal/healing on hit - * - 'on_hit' - triggers on hitting enemies - * - 'on_kill' - triggers on killing enemies - * - 'crit' - critical hit related - * - 'regen' - health regeneration - * - 'shield' - shield/barrier related - * - 'summon' - summons/minions - * - 'fire_rate' - attack speed - * - 'heat' - overheat mechanics - * - 'projectile' - projectile modifiers - * - 'beam' - beam weapons - * - 'slow_time' - time manipulation - * - 'armor' - armor/defense - * - 'aoe' - area of effect - * - 'thorns' - reflect damage - * - 'dash' - dash/mobility - * - 'glass_cannon' - high risk/reward - * - 'range' - range modifiers - * - 'piercing' - piercing shots - * - 'slow' - slowing enemies - * - 'shotgun' - spread weapons - * - 'short_range' - close range - * - 'turret' - turret related - * - 'utility' - general utility - * - 'berserk' - damage at low health - * - 'melee' - melee range - * - 'speed' - movement speed - * - 'sustain' - survivability - * - 'xp' - experience gain - * - 'luck' - luck/rng - * - 'explosive' - explosions - */ - -const PASSIVES = { - SURCHAUFFE: { - id: 'surchauffe', - tags: ['fire_rate', 'heat', 'glass_cannon'], - name: 'Surchauffe', - description: 'Augmente les dégâts laser. Plus de puissance, plus de chaleur.', - rarity: 'common', - effects: { - damageMultiplier: 0.15, - overheatReduction: -0.1 - }, - maxStacks: 5, - color: '#FF4500', - icon: '🔥' - }, - - RADIATEUR: { - id: 'radiateur', - tags: ['fire_rate', 'heat', 'utility'], - name: 'Radiateur', - description: 'Refroidissement amélioré. Tire plus vite sans surchauffe.', - rarity: 'uncommon', - effects: { - fireRateMultiplier: 0.12, - overheatReduction: 0.15 - }, - maxStacks: 5, - color: '#00BFFF', - icon: '❄️' - }, - - SANG_FROID: { - id: 'sang_froid', - tags: ['crit', 'vampire', 'on_hit', 'sustain'], - name: 'Sang Froid', - description: 'Augmente les chances de coup critique et le vol de vie.', - rarity: 'rare', - effects: { - critChance: 0.08, - critMultiplier: 0.2, - lifesteal: 0.03 - }, - maxStacks: 4, - color: '#4169E1', - icon: '💎' - }, - - COEUR_NOIR: { - id: 'coeur_noir', - tags: ['vampire', 'on_hit', 'glass_cannon'], - name: 'Cœur Noir', - description: 'Énergie vampirique puissante au prix de ta vitalité.', - rarity: 'rare', - effects: { - lifesteal: 0.08, - maxHealthMultiplier: -0.1, - damageMultiplier: 0.08 - }, - maxStacks: 3, - color: '#8B0000', - icon: '🖤' - }, - - BOBINES_TESLA: { - id: 'bobines_tesla', - tags: ['on_hit', 'utility', 'aoe'], - name: 'Bobines Tesla', - description: 'Amplifie les dégâts électriques et ajoute une chance d\'étourdissement.', - rarity: 'uncommon', - effects: { - electricDamageBonus: 0.25, - stunChance: 0.05, - damageMultiplier: 0.1 - }, - maxStacks: 4, - color: '#00FFFF', - icon: '⚡' - }, - - FOCALISEUR: { - id: 'focaliseur', - tags: ['range', 'projectile', 'utility'], - name: 'Focaliseur', - description: 'Augmente la portée et la vitesse des projectiles.', - rarity: 'uncommon', - effects: { - rangeMultiplier: 0.15, - projectileSpeedMultiplier: 0.20 - }, - maxStacks: 5, - color: '#FF00FF', - icon: '🔍' - }, - - MAG_TRACTOR: { - id: 'mag_tractor', - tags: ['xp', 'utility'], - name: 'Mag-Tractor', - description: 'Attire l\'XP et les bonus de plus loin. Gain d\'XP amélioré.', - rarity: 'common', - effects: { - magnetRange: 50, - xpMultiplier: 0.10 - }, - maxStacks: 6, - color: '#FFD700', - icon: '🧲' - }, - - PLATING: { - id: 'plating', - tags: ['armor', 'sustain'], - name: 'Plating', - description: 'Blindage renforcé qui réduit les dégâts reçus.', - rarity: 'common', - effects: { - armor: 2, - maxHealthMultiplier: 0.05 - }, - maxStacks: 8, - color: '#C0C0C0', - icon: '🛡️' - }, - - REACTEUR: { - id: 'reacteur', - tags: ['speed', 'dash', 'utility'], - name: 'Réacteur', - description: 'Moteurs surpuissants. Plus rapide, dash rechargé plus vite.', - rarity: 'uncommon', - effects: { - speedMultiplier: 0.10, - dashCooldownReduction: 0.12 - }, - maxStacks: 5, - color: '#FF6347', - icon: '🚀' - }, - - CHANCE: { - id: 'chance', - tags: ['luck', 'crit', 'utility'], - name: 'Chance', - description: 'Améliore la chance. Objets rares plus fréquents.', - rarity: 'rare', - effects: { - luck: 0.15, - critChance: 0.03 - }, - maxStacks: 5, - color: '#FFD700', - icon: '🍀' - }, - - // ===== NEW PASSIVES (30+) ===== - - // Common (simple stat boosts) - MUNITIONS_LOURDES: { - id: 'munitions_lourdes', - tags: ['utility'], - name: 'Munitions Lourdes', - description: '+Dégâts bruts. Frappe plus fort.', - rarity: 'common', - effects: { - damageMultiplier: 0.12 - }, - maxStacks: 8, - color: '#FF8C00', - icon: '💥' - }, - - CADENCE_RAPIDE: { - id: 'cadence_rapide', - tags: ['fire_rate', 'utility'], - name: 'Cadence Rapide', - description: 'Tire plus vite. Plus de projectiles par seconde.', - rarity: 'common', - effects: { - fireRateMultiplier: 0.10 - }, - maxStacks: 8, - color: '#00FF00', - icon: '⚡' - }, - - VITALITE: { - id: 'vitalite', - tags: ['sustain'], - name: 'Vitalité', - description: '+Santé maximale. Survie améliorée.', - rarity: 'common', - effects: { - maxHealthMultiplier: 0.10 - }, - maxStacks: 6, - color: '#32CD32', - icon: '❤️' - }, - - REGENERATION: { - id: 'regeneration', - tags: ['regen', 'sustain'], - name: 'Régénération', - description: 'Récupère de la santé avec le temps.', - rarity: 'common', - effects: { - healthRegen: 0.2 - }, - maxStacks: 6, - color: '#00FA9A', - icon: '💚' - }, - - MOBILITE: { - id: 'mobilite', - tags: ['speed', 'utility'], - name: 'Mobilité', - description: 'Déplacement plus rapide. Esquive facilitée.', - rarity: 'common', - effects: { - speedMultiplier: 0.08 - }, - maxStacks: 7, - color: '#00CED1', - icon: '💨' - }, - - COLLECTEUR: { - id: 'collecteur', - tags: ['xp', 'utility'], - name: 'Collecteur', - description: 'Augmente le rayon magnétique pour ramasser l\'XP.', - rarity: 'common', - effects: { - magnetRange: 40 - }, - maxStacks: 6, - color: '#DAA520', - icon: '🔰' - }, - - // Uncommon (combo effects) - PERFORANT: { - id: 'perforant', - tags: ['piercing', 'projectile'], - name: 'Perforant', - description: 'Les projectiles traversent un ennemi supplémentaire.', - rarity: 'uncommon', - effects: { - piercing: 1, - damageMultiplier: 0.08 - }, - maxStacks: 3, - color: '#9370DB', - icon: '🎯' - }, - - RICOCHET: { - id: 'ricochet', - tags: ['projectile', 'aoe', 'utility'], - name: 'Ricochet', - description: 'Chance de faire rebondir les projectiles sur les ennemis.', - rarity: 'uncommon', - effects: { - ricochetChance: 0.15, - bounceCount: 1 - }, - maxStacks: 4, - color: '#FF1493', - icon: '🔄' - }, - - EXPLOSION_IMPACT: { - id: 'explosion_impact', - tags: ['explosive', 'aoe', 'on_hit'], - name: 'Explosion d\'Impact', - description: 'Les tirs ont une chance d\'exploser en zone.', - rarity: 'uncommon', - effects: { - explosionChance: 0.12, - explosionRadius: 30, - explosionDamage: 0.5 - }, - maxStacks: 3, - color: '#FF4500', - icon: '💣' - }, - - MULTI_TIR: { - id: 'multi_tir', - tags: ['shotgun', 'projectile', 'aoe'], - name: 'Multi-Tir', - description: '+1 projectile par salve. Couverture améliorée.', - rarity: 'uncommon', - effects: { - projectileCount: 1, - damageMultiplier: -0.05 - }, - maxStacks: 4, - color: '#FF6347', - icon: '🌟' - }, - - PRECISION: { - id: 'precision', - tags: ['crit', 'projectile', 'utility'], - name: 'Précision', - description: 'Augmente les critiques et la vitesse des projectiles.', - rarity: 'uncommon', - effects: { - critChance: 0.06, - projectileSpeedMultiplier: 0.15 - }, - maxStacks: 5, - color: '#4682B4', - icon: '🎲' - }, - - BOUCLIER_ENERGIE: { - id: 'bouclier_energie', - tags: ['shield', 'regen', 'sustain'], - name: 'Bouclier d\'Énergie', - description: 'Absorbe des dégâts périodiquement.', - rarity: 'uncommon', - effects: { - shield: 20, - shieldRegen: 2 - }, - maxStacks: 4, - color: '#00BFFF', - icon: '🛡️' - }, - - VAMPIRISME: { - id: 'vampirisme', - tags: ['vampire', 'on_hit', 'sustain'], - name: 'Vampirisme', - description: 'Convertit les dégâts en santé.', - rarity: 'uncommon', - effects: { - lifesteal: 0.04 - }, - maxStacks: 5, - color: '#DC143C', - icon: '🧛' - }, - - PORTEE_ETENDUE: { - id: 'portee_etendue', - tags: ['range', 'utility'], - name: 'Portée Étendue', - description: 'Armes plus efficaces à longue distance.', - rarity: 'uncommon', - effects: { - rangeMultiplier: 0.20, - damageMultiplier: 0.05 - }, - maxStacks: 4, - color: '#6A5ACD', - icon: '📡' - }, - - ECONOMIE_ENERGIE: { - id: 'economie_energie', - tags: ['heat', 'fire_rate', 'utility'], - name: 'Économie d\'Énergie', - description: 'Réduit surchauffe et améliore cadence.', - rarity: 'uncommon', - effects: { - overheatReduction: 0.20, - fireRateMultiplier: 0.08 - }, - maxStacks: 4, - color: '#20B2AA', - icon: '⚙️' - }, - - // Rare (powerful combos) - EXECUTION: { - id: 'execution', - tags: ['on_hit', 'utility'], - name: 'Exécution', - description: '+Dégâts sur ennemis à faible santé. -5% dégâts de base.', - rarity: 'rare', - effects: { - executeThreshold: 0.25, - executeDamageBonus: 0.50, - damageMultiplier: -0.05 - }, - maxStacks: 3, - color: '#8B0000', - icon: '⚔️' - }, - - FUREUR_COMBAT: { - id: 'fureur_combat', - tags: ['on_kill', 'utility'], - name: 'Fureur de Combat', - description: 'Stack de dégâts qui augmente avec les kills. -5% santé max.', - rarity: 'rare', - effects: { - furyPerKill: 0.02, - furyMax: 0.50, - furyDecay: 0.01, - maxHealthMultiplier: -0.05 - }, - maxStacks: 3, - color: '#FF0000', - icon: '🔥' - }, - - PREDATEUR: { - id: 'predateur', - tags: ['on_kill', 'xp', 'sustain'], - name: 'Prédateur', - description: 'Bonus XP et santé sur kill. -5% vitesse de tir.', - rarity: 'rare', - effects: { - xpMultiplier: 0.20, - healOnKill: 2, - damageMultiplier: 0.12, - fireRateMultiplier: -0.05 - }, - maxStacks: 3, - color: '#FFD700', - icon: '👑' - }, - - CHAINE_FOUDRE: { - id: 'chaine_foudre', - tags: ['on_hit', 'aoe'], - name: 'Chaîne de Foudre', - description: 'Les attaques électriques sautent entre ennemis. -8% portée.', - rarity: 'rare', - effects: { - chainLightning: 1, - electricDamageBonus: 0.30, - chainRange: 150, - rangeMultiplier: -0.08 - }, - maxStacks: 4, - color: '#00FFFF', - icon: '⚡' - }, - - TEMPS_RALENTI: { - id: 'temps_ralenti', - tags: ['slow', 'on_hit', 'slow_time'], - name: 'Temps Ralenti', - description: 'Chance de ralentir les ennemis touchés.', - rarity: 'rare', - effects: { - slowChance: 0.20, - slowAmount: 0.40, - slowDuration: 2.0 - }, - maxStacks: 3, - color: '#4169E1', - icon: '⏰' - }, - - LAME_TOURNOYANTE: { - id: 'lame_tournoyante', - tags: ['aoe', 'melee', 'short_range'], - name: 'Lame Tournoyante', - description: 'Dégâts de zone autour du vaisseau. -10% portée.', - rarity: 'rare', - effects: { - orbitDamage: 5, - orbitRadius: 80, - orbitSpeed: 2.0, - rangeMultiplier: -0.10 - }, - maxStacks: 4, - color: '#FF00FF', - icon: '🌀' - }, - - CRITIQUE_MORTEL: { - id: 'critique_mortel', - tags: ['crit', 'glass_cannon'], - name: 'Critique Mortel', - description: 'Critiques dévastateurs mais moins fréquents.', - rarity: 'rare', - effects: { - critChance: -0.02, - critMultiplier: 0.80 - }, - maxStacks: 3, - color: '#DC143C', - icon: '💀' - }, - - SURVIVANT: { - id: 'survivant', - tags: ['shield', 'regen', 'sustain'], - name: 'Survivant', - description: 'Bouclier et régénération quand blessé. -10% dégâts.', - rarity: 'rare', - effects: { - lowHealthShield: 30, - lowHealthRegen: 2.0, - lowHealthThreshold: 0.30, - damageMultiplier: -0.10 - }, - maxStacks: 2, - color: '#32CD32', - icon: '🩹' - }, - - DOUBLE_TIR: { - id: 'double_tir', - tags: ['fire_rate', 'utility'], - name: 'Double Tir', - description: 'Chance de tirer deux fois simultanément.', - rarity: 'rare', - effects: { - doubleShotChance: 0.18, - fireRateMultiplier: 0.10 - }, - maxStacks: 3, - color: '#FF69B4', - icon: '🎆' - }, - - // Epic (game-changing) - ARSENAL_ORBITAL: { - id: 'arsenal_orbital', - tags: ['summon', 'turret', 'aoe', 'fire_rate'], - name: 'Arsenal Orbital', - description: 'Satellites armés tournent autour du vaisseau. -10% vitesse de déplacement.', - rarity: 'epic', - effects: { - orbitCount: 2, - orbitDamage: 15, - orbitRadius: 120, - fireRateMultiplier: 0.15, - speedMultiplier: -0.10 - }, - maxStacks: 2, - color: '#9400D3', - icon: '🛸' - }, - - PHOENIX: { - id: 'phoenix', - tags: ['sustain', 'utility'], - name: 'Phoenix', - description: 'Reviens à la vie une fois par vague. -15% dégâts de base.', - rarity: 'epic', - effects: { - revive: 1, - reviveHealth: 0.50, - damageMultiplier: -0.15 - }, - maxStacks: 1, - color: '#FF4500', - icon: '🔥' - }, - - TEMPETE_PROJECTILES: { - id: 'tempete_projectiles', - tags: ['shotgun', 'fire_rate', 'glass_cannon'], - name: 'Tempête de Projectiles', - description: '+3 projectiles, cadence folle, dégâts réduits.', - rarity: 'epic', - effects: { - projectileCount: 3, - fireRateMultiplier: 0.40, - damageMultiplier: -0.20 - }, - maxStacks: 2, - color: '#FFD700', - icon: '🌪️' - }, - - NEXUS_ENERGIE: { - id: 'nexus_energie', - tags: ['utility', 'fire_rate', 'speed', 'crit'], - name: 'Nexus d\'Énergie', - description: 'Toutes les stats augmentent légèrement. -5% santé max.', - rarity: 'epic', - effects: { - damageMultiplier: 0.15, - fireRateMultiplier: 0.15, - speedMultiplier: 0.15, - maxHealthMultiplier: 0.10, - critChance: 0.05 - }, - maxStacks: 2, - color: '#00FFFF', - icon: '⭐' - }, - - DEVASTATION: { - id: 'devastation', - tags: ['piercing', 'explosive', 'aoe', 'glass_cannon'], - name: 'Dévastation', - description: 'Énormes dégâts, pénétration, zone d\'effet.', - rarity: 'epic', - effects: { - damageMultiplier: 0.50, - piercing: 2, - explosionChance: 0.25, - explosionRadius: 60, - fireRateMultiplier: -0.15 - }, - maxStacks: 1, - color: '#8B0000', - icon: '☄️' - }, - - GARDIEN: { - id: 'gardien', - tags: ['shield', 'armor', 'sustain'], - name: 'Gardien', - description: 'Bouclier massif et armure renforcée. -20% vitesse.', - rarity: 'epic', - effects: { - shield: 100, - shieldRegen: 5, - armor: 5, - maxHealthMultiplier: 0.30, - speedMultiplier: -0.20 - }, - maxStacks: 2, - color: '#4169E1', - icon: '🏰' - }, - - INSTINCT_TUEUR: { - id: 'instinct_tueur', - tags: ['on_kill', 'speed', 'sustain'], - name: 'Instinct Tueur', - description: 'Bonus massif sur kill: vitesse, dégâts, heal. -10% santé max.', - rarity: 'epic', - effects: { - killSpeedBoost: 0.20, - killDamageBoost: 0.15, - healOnKill: 5, - killBoostDuration: 3.0, - maxHealthMultiplier: -0.10 - }, - maxStacks: 2, - color: '#FF1493', - icon: '🗡️' - }, - - SURCHARGE_ARCANIQUE: { - id: 'surcharge_arcanique', - tags: ['piercing', 'projectile', 'glass_cannon'], - name: 'Surcharge Arcanique', - description: 'Projectiles géants, lents mais dévastateurs.', - rarity: 'epic', - effects: { - projectileSizeMultiplier: 2.0, - damageMultiplier: 0.80, - projectileSpeedMultiplier: -0.30, - piercing: 3 - }, - maxStacks: 1, - color: '#9400D3', - icon: '🔮' - }, - - SIPHON_VITAL: { - id: 'siphon_vital', - tags: ['vampire', 'regen', 'on_hit', 'sustain'], - name: 'Siphon Vital', - description: 'Lifesteal extrême et régénération. -10% dégâts.', - rarity: 'epic', - effects: { - lifesteal: 0.12, - healthRegen: 0.8, - maxHealthMultiplier: 0.20, - damageMultiplier: -0.10 - }, - maxStacks: 2, - color: '#DC143C', - icon: '🩸' - }, - - MAITRE_TEMPS: { - id: 'maitre_temps', - tags: ['slow', 'slow_time', 'dash', 'speed'], - name: 'Maître du Temps', - description: 'Ralentit tous les ennemis proches.', - rarity: 'epic', - effects: { - auraSlowAmount: 0.30, - auraRadius: 200, - dashCooldownReduction: 0.30, - speedMultiplier: 0.20 - }, - maxStacks: 1, - color: '#4682B4', - icon: '⌛' - }, - - EXPLOSION_CHAIN: { - id: 'explosion_chain', - tags: ['explosive', 'aoe', 'on_kill'], - name: 'Réaction en Chaîne', - description: 'Les ennemis explosent en mourant, infligeant des dégâts de zone.', - rarity: 'rare', - effects: { - explosionOnKill: true, - explosionRadius: 80, - explosionDamage: 30 - }, - maxStacks: 3, - color: '#FF4500', - icon: '💥' - }, - - AIM_ASSIST: { - id: 'aim_assist', - tags: ['projectile', 'range', 'utility'], - name: 'Guidage Automatique', - description: 'Vos projectiles suivent légèrement les ennemis.', - rarity: 'rare', - effects: { - homingStrength: 0.3, - rangeMultiplier: 0.15 - }, - maxStacks: 2, - color: '#00CED1', - icon: '🎯' - }, - - DASH_MASTERY: { - id: 'dash_mastery', - tags: ['dash', 'utility'], - name: 'Maîtrise du Dash', - description: 'Dash amélioré avec invincibilité.', - rarity: 'rare', - effects: { - dashCooldownReduction: 0.25, - dashDistance: 0.30, - dashInvincibility: 0.5 - }, - maxStacks: 2, - color: '#9370DB', - icon: '⚡' - }, - - THORNS: { - id: 'thorns', - tags: ['thorns', 'armor'], - name: 'Épines', - description: 'Renvoie des dégâts aux ennemis qui vous touchent.', - rarity: 'uncommon', - effects: { - reflectDamage: 0.25, - armor: 2 - }, - maxStacks: 4, - color: '#8B4513', - icon: '🌵' - }, - - SPEED_BURST: { - id: 'speed_burst', - tags: ['speed', 'on_kill'], - name: 'Rafale de Vitesse', - description: 'Gain de vitesse temporaire après un kill.', - rarity: 'uncommon', - effects: { - speedBurstOnKill: 0.40, - speedBurstDuration: 2.0 - }, - maxStacks: 3, - color: '#32CD32', - icon: '💨' - }, - - XP_MAGNET: { - id: 'xp_magnet', - tags: ['xp', 'utility'], - name: 'Aimant d\'XP', - description: 'Augmente considérablement la portée de collecte.', - rarity: 'common', - effects: { - magnetRange: 150, - xpMultiplier: 0.10 - }, - maxStacks: 3, - color: '#FFD700', - icon: '🧲' - }, - - BERSERKER: { - id: 'berserker', - tags: ['berserk', 'glass_cannon'], - name: 'Berserker', - description: 'Plus de dégâts à faible santé.', - rarity: 'rare', - effects: { - lowHealthDamageBonus: 0.50, - lowHealthThreshold: 0.30 - }, - maxStacks: 2, - color: '#8B0000', - icon: '😡' - }, - - GLASS_CANNON: { - id: 'glass_cannon', - tags: ['glass_cannon', 'fire_rate'], - name: 'Canon de Verre', - description: 'Énormes dégâts mais santé réduite.', - rarity: 'epic', - effects: { - damageMultiplier: 0.60, - fireRateMultiplier: 0.30, - maxHealthMultiplier: -0.30 - }, - maxStacks: 1, - color: '#FF1493', - icon: '💎' - }, - - VAMPIRE_LORD: { - id: 'vampire_lord', - tags: ['vampire', 'on_hit', 'sustain', 'glass_cannon'], - name: 'Seigneur Vampire', - description: 'Lifesteal massif mais vitesse réduite.', - rarity: 'epic', - effects: { - lifesteal: 0.15, - maxHealthMultiplier: 0.40, - speedMultiplier: -0.20, - damageMultiplier: 0.15 - }, - maxStacks: 1, - color: '#8B0000', - icon: '🧛' - }, - - CRIT_MASTER: { - id: 'crit_master', - tags: ['crit'], - name: 'Maître Critique', - description: 'Critique chance et dégâts augmentés.', - rarity: 'rare', - effects: { - critChance: 0.15, - critMultiplier: 0.50 - }, - maxStacks: 3, - color: '#FFD700', - icon: '⭐' - }, - - RAPID_FIRE: { - id: 'rapid_fire', - tags: ['fire_rate', 'heat'], - name: 'Tir Rapide', - description: 'Cadence de tir drastiquement augmentée.', - rarity: 'uncommon', - effects: { - fireRateMultiplier: 0.35, - overheatReduction: -0.15 - }, - maxStacks: 4, - color: '#FF6347', - icon: '🔫' - }, - - PENETRATING_SHOTS: { - id: 'penetrating_shots', - tags: ['piercing', 'projectile'], - name: 'Tirs Pénétrants', - description: 'Vos projectiles traversent les ennemis.', - rarity: 'rare', - effects: { - piercing: 2, - damageMultiplier: 0.20 - }, - maxStacks: 2, - color: '#4169E1', - icon: '➡️' - }, - - SHIELD_GENERATOR: { - id: 'shield_generator', - tags: ['shield', 'regen', 'sustain'], - name: 'Générateur de Bouclier', - description: 'Régénère un bouclier périodique.', - rarity: 'rare', - effects: { - shieldAmount: 25, - shieldRegenTime: 10.0, - maxHealthMultiplier: 0.15 - }, - maxStacks: 2, - color: '#00BFFF', - icon: '🛡️' - }, - - MULTISHOT: { - id: 'multishot', - tags: ['shotgun', 'projectile', 'aoe'], - name: 'Tir Multiple', - description: 'Tire plusieurs projectiles à la fois.', - rarity: 'epic', - effects: { - extraProjectiles: 2, - damageMultiplier: -0.15, - fireRateMultiplier: -0.10 - }, - maxStacks: 2, - color: '#FF69B4', - icon: '🔷' - }, - - SLOW_AURA: { - id: 'slow_aura', - tags: ['slow', 'aoe', 'slow_time'], - name: 'Aura Ralentissante', - description: 'Les ennemis proches sont ralentis.', - rarity: 'uncommon', - effects: { - auraSlowAmount: 0.20, - auraRadius: 150 - }, - maxStacks: 3, - color: '#4682B4', - icon: '❄️' - }, - - LUCKY_CLOVER: { - id: 'lucky_clover', - tags: ['luck', 'crit', 'xp'], - name: 'Trèfle Porte-Bonheur', - description: 'Augmente votre chance pour les drops et critiques.', - rarity: 'uncommon', - effects: { - luck: 15, - critChance: 0.05, - xpMultiplier: 0.15 - }, - maxStacks: 4, - color: '#00FF00', - icon: '🍀' - }, - - ENERGY_SHIELD: { - id: 'energy_shield', - tags: ['shield', 'armor'], - name: 'Bouclier Énergétique', - description: 'Absorbe les dégâts périodiquement.', - rarity: 'rare', - effects: { - damageAbsorption: 0.15, - armor: 5 - }, - maxStacks: 2, - color: '#00FFFF', - icon: '🔵' - }, - - RAGE_MODE: { - id: 'rage_mode', - tags: ['on_kill', 'utility'], - name: 'Mode Rage', - description: 'Les kills augmentent temporairement les dégâts.', - rarity: 'rare', - effects: { - rageStackDamage: 0.08, - rageMaxStacks: 10, - rageDuration: 5.0 - }, - maxStacks: 2, - color: '#DC143C', - icon: '😤' - }, - - DODGE_MASTER: { - id: 'dodge_master', - tags: ['speed', 'utility'], - name: 'Maître de l\'Esquive', - description: 'Chance d\'esquiver complètement les dégâts.', - rarity: 'epic', - effects: { - dodgeChance: 0.15, - speedMultiplier: 0.20 - }, - maxStacks: 2, - color: '#9370DB', - icon: '👻' - }, - - OVERCHARGE: { - id: 'overcharge', - tags: ['heat', 'utility'], - name: 'Surcharge', - description: 'Les dégâts augmentent avec la surchauffe.', - rarity: 'rare', - effects: { - overheatDamageBonus: 0.50, - overheatReduction: 0.20 - }, - maxStacks: 2, - color: '#FF8C00', - icon: '🔥' - }, - - // New passives with strategic maluses - BLOOD_PACT: { - id: 'blood_pact', - tags: ['vampire', 'risk', 'glass_cannon'], - name: 'Pacte de Sang', - description: 'Lifesteal puissant mais santé maximale réduite. -20% PV max.', - rarity: 'rare', - effects: { - lifesteal: 0.12, - maxHealthMultiplier: -0.20 - }, - maxStacks: 2, - color: '#8B0000', - icon: '🩸' - }, - - INFINITE_HUNGER: { - id: 'infinite_hunger', - tags: ['vampire', 'projectile', 'glass_cannon'], - name: 'Soif Infinie', - description: 'Lifesteal sur tous les projectiles. -30% dégâts.', - rarity: 'epic', - effects: { - lifesteal: 0.08, - projectileLifesteal: true, - damageMultiplier: -0.30 - }, - maxStacks: 1, - color: '#DC143C', - icon: '🧛' - }, - - UNSTABLE_CANNON: { - id: 'unstable_cannon', - tags: ['damage', 'glass_cannon', 'risk'], - name: 'Canon Instable', - description: '+40% dégâts mais +30% dégâts reçus. Haute puissance, haute fragilité.', - rarity: 'rare', - effects: { - damageMultiplier: 0.40, - damageTakenMultiplier: 0.30 - }, - maxStacks: 2, - color: '#FF4500', - icon: '💥' - }, - - DEVASTATING_CRIT: { - id: 'devastating_crit', - tags: ['crit', 'glass_cannon'], - name: 'Critique Dévastateur', - description: '+60% multiplicateur critique mais -15% chance de crit.', - rarity: 'rare', - effects: { - critMultiplier: 0.60, - critChance: -0.15 - }, - maxStacks: 2, - color: '#FFD700', - icon: '💫' - }, - - OVERHEATED_ENGINE: { - id: 'overheated_engine', - tags: ['heat', 'fire_rate', 'glass_cannon'], - name: 'Moteur Surchargé', - description: '+50% cadence de tir mais génère 50% de chaleur en plus.', - rarity: 'uncommon', - effects: { - fireRateMultiplier: 0.50, - heatGeneration: 0.50 - }, - maxStacks: 3, - color: '#FF6600', - icon: '⚡' - }, - - UNSTABLE_FUSION: { - id: 'unstable_fusion', - tags: ['explosive', 'aoe', 'glass_cannon'], - name: 'Fusion Instable', - description: 'Vos tirs explosent à l\'impact. 30% des dégâts d\'explosion vous affectent.', - rarity: 'epic', - effects: { - explosionChance: 1.0, - explosionRadius: 80, - explosionDamage: 0.50, - selfExplosionDamage: 0.30 - }, - maxStacks: 1, - color: '#FF8C00', - icon: '☢️' - }, - - SLOW_FIELD: { - id: 'slow_field', - tags: ['utility', 'slow', 'control'], - name: 'Champ Ralentissant', - description: 'Ralentit les ennemis de 35% mais vous aussi de 15%.', - rarity: 'rare', - effects: { - enemySlow: 0.35, - slowRadius: 200, - speedMultiplier: -0.15 - }, - maxStacks: 2, - color: '#4169E1', - icon: '🌀' - }, - - TUNNEL_VISION: { - id: 'tunnel_vision', - tags: ['range', 'projectile'], - name: 'Vision Tunnel', - description: '+50% portée mais -25% angle de dispersion.', - rarity: 'uncommon', - effects: { - rangeMultiplier: 0.50, - spreadAngleMultiplier: -0.25 - }, - maxStacks: 3, - color: '#9370DB', - icon: '👁️' - }, - - HEAVY_SHELL: { - id: 'heavy_shell', - tags: ['armor', 'sustain'], - name: 'Carapace Lourde', - description: '+4 armure mais -20% cadence de tir.', - rarity: 'uncommon', - effects: { - armor: 4, - fireRateMultiplier: -0.20 - }, - maxStacks: 3, - color: '#708090', - icon: '🛡️' - }, - - REACTIVE_SHIELD: { - id: 'reactive_shield', - tags: ['shield', 'sustain'], - name: 'Bouclier Réactif', - description: 'Double la régénération de bouclier mais désactive la régénération de santé.', - rarity: 'rare', - effects: { - shieldRegenMultiplier: 1.0, - healthRegenDisabled: true - }, - maxStacks: 2, - color: '#00CED1', - icon: '🔰' - }, - - ALL_OR_NOTHING: { - id: 'all_or_nothing', - tags: ['risk', 'burst', 'glass_cannon'], - name: 'Tout ou Rien', - description: 'Double dégâts pendant 10s, puis 50% dégâts pendant 10s. Cycle continu.', - rarity: 'epic', - effects: { - burstDamageMultiplier: 1.0, - burstDuration: 10, - postBurstDamageMultiplier: -0.50, - postBurstDuration: 10 - }, - maxStacks: 1, - color: '#FF1493', - icon: '🎲' - }, - - LAST_BREATH: { - id: 'last_breath', - tags: ['risk', 'berserk', 'glass_cannon'], - name: 'Dernier Souffle', - description: 'Double dégâts sous 30% PV. Mort instantanée si vous tombez sous 10% PV.', - rarity: 'epic', - effects: { - lowHealthDamageMultiplier: 1.0, - lowHealthThreshold: 0.30, - instantDeathThreshold: 0.10 - }, - maxStacks: 1, - color: '#DC143C', - icon: '💀' - } -}; - -/** - * Get passive data by ID - * @param {string} passiveId - Passive identifier - * @returns {PassiveData|null} - */ -function getPassiveData(passiveId) { - return PASSIVES[passiveId.toUpperCase()] || null; -} - -/** - * Calculate total effects from multiple passive stacks - * @param {Array<{id: string, stacks: number}>} passives - Array of passive IDs with stack counts - * @returns {PassiveEffects} - */ -function calculateTotalEffects(passives) { - const totalEffects = {}; - - for (const passive of passives) { - const data = getPassiveData(passive.id); - if (!data) continue; - - const stacks = Math.min(passive.stacks, data.maxStacks); - - for (const [effect, value] of Object.entries(data.effects)) { - if (!totalEffects[effect]) { - totalEffects[effect] = 0; - } - totalEffects[effect] += value * stacks; - } - } - - return totalEffects; -} - -/** - * Get rarity weight for passive selection - * @param {string} rarity - Rarity tier - * @param {number} luck - Player luck stat - * @returns {number} - */ -function getRarityWeight(rarity, luck = 0) { - const baseWeights = { - common: 60, - uncommon: 25, - rare: 10, - epic: 5 - }; - - const weight = baseWeights[rarity] || 0; - - // Luck shifts weights towards rarer items - if (rarity === 'common') { - return Math.max(10, weight - luck * 20); - } else if (rarity === 'uncommon') { - return weight + luck * 10; - } else if (rarity === 'rare') { - return weight + luck * 15; - } else if (rarity === 'epic') { - return weight + luck * 25; - } - - return weight; -} - -/** - * Get random passive based on rarity weights and luck - * @param {number} luck - Player luck stat - * @param {Array} exclude - Passive IDs to exclude - * @returns {PassiveData|null} - */ -function getRandomPassive(luck = 0, exclude = []) { - const available = Object.values(PASSIVES).filter( - p => !exclude.includes(p.id) - ); - - if (available.length === 0) return null; - - const weights = available.map(p => getRarityWeight(p.rarity, luck)); - const totalWeight = weights.reduce((sum, w) => sum + w, 0); - - let random = Math.random() * totalWeight; - - for (let i = 0; i < available.length; i++) { - random -= weights[i]; - if (random <= 0) { - return available[i]; - } - } - - return available[available.length - 1]; -} - -/** - * Apply passive effects to player stats - * @param {Object} passive - Passive object with data and stacks properties - * @param {Object} stats - Player stats object to modify - */ -function applyPassiveEffects(passive, stats) { - if (!passive || !passive.data || !passive.data.effects) { - console.warn('Invalid passive data:', passive); - return; - } - - const effects = passive.data.effects; - const stacks = passive.stacks || 1; - - // Apply each effect from the passive - for (const [effectKey, effectValue] of Object.entries(effects)) { - const totalValue = effectValue * stacks; - - // Handle multiplier effects (these modify existing stat values multiplicatively) - if (effectKey === 'damageMultiplier') { - stats.damage = (stats.damage || 1) * (1 + totalValue); - } else if (effectKey === 'fireRateMultiplier') { - stats.fireRate = (stats.fireRate || 1) * (1 + totalValue); - } else if (effectKey === 'speedMultiplier') { - stats.speed = (stats.speed || 1) * (1 + totalValue); - } else if (effectKey === 'maxHealthMultiplier') { - // Accumulate multiplier for HP calculation (base stats vs derived stats) - stats.maxHealthMultiplier = (stats.maxHealthMultiplier || 1) * (1 + totalValue); - } else if (effectKey === 'rangeMultiplier') { - stats.range = (stats.range || 1) * (1 + totalValue); - } else if (effectKey === 'projectileSpeedMultiplier') { - stats.projectileSpeed = (stats.projectileSpeed || 1) * (1 + totalValue); - } else if (effectKey === 'critMultiplier') { - stats.critDamage = (stats.critDamage || 1) * (1 + totalValue); - } else if (effectKey === 'xpMultiplier') { - stats.xpBonus = (stats.xpBonus || 1) * (1 + totalValue); - } - // Handle additive effects (these add to existing stat values) - else if (effectKey === 'critChance') { - stats.critChance = (stats.critChance || 0) + totalValue; - } else if (effectKey === 'lifesteal') { - stats.lifesteal = (stats.lifesteal || 0) + totalValue; - } else if (effectKey === 'armor') { - stats.armor = (stats.armor || 0) + totalValue; - } else if (effectKey === 'luck') { - stats.luck = (stats.luck || 0) + totalValue; - } - // Handle special effects that don't directly map to basic stats - // These are stored in stats for game systems to check and handle - else { - // Store all other effects in stats for systems to consume - // This includes: electricDamageBonus, stunChance, piercing, ricochetChance, - // explosionChance, projectileCount, shield, healthRegen, executeThreshold, - // furyPerKill, healOnKill, chainLightning, slowChance, orbitDamage, - // revive, doubleShotChance, homingStrength, dashCooldownReduction, - // magnetRange, overheatReduction, and many others - stats[effectKey] = (stats[effectKey] || 0) + totalValue; - } - } -} - -// Export to global namespace -const PassiveData = { - PASSIVES, - getPassiveData, - calculateTotalEffects, - getRarityWeight, - getRandomPassive, - applyPassiveEffects -}; - -if (typeof window !== 'undefined') { - window.PassiveData = PassiveData; -} diff --git a/js/data/ShipData.js b/js/data/ShipData.js deleted file mode 100644 index 4a315d0..0000000 --- a/js/data/ShipData.js +++ /dev/null @@ -1,362 +0,0 @@ -/** - * @fileoverview Ship/Character data definitions for Space InZader - * Defines playable ships with unique stats and starting weapons - */ - -/** - * @typedef {Object} ShipStats - * @property {number} maxHealth - Maximum health points - * @property {number} healthRegen - Health regeneration per second - * @property {number} damageMultiplier - Base damage multiplier - * @property {number} fireRateMultiplier - Base fire rate multiplier - * @property {number} speed - Movement speed - * @property {number} armor - Flat damage reduction - * @property {number} lifesteal - Lifesteal percentage (0-1) - * @property {number} critChance - Critical hit chance (0-1) - * @property {number} critMultiplier - Critical damage multiplier - * @property {number} magnetRange - XP/pickup magnet range - * @property {number} dashCooldown - Dash ability cooldown in seconds - * @property {number} luck - Base luck value - */ - -/** - * @typedef {Object} ShipData - * @property {string} id - Unique identifier - * @property {string} name - Display name - * @property {string} description - Ship description and playstyle - * @property {ShipStats} baseStats - Starting statistics - * @property {string} startingWeapon - ID of starting weapon - * @property {string[]} preferredTags - Tags for upgrade filtering - * @property {string[]} bannedTags - Tags to exclude from upgrades - * @property {string[]} preferredWeapons - Preferred weapon IDs - * @property {string[]} preferredPassives - Preferred passive IDs - * @property {string|null} keystoneId - Unique keystone passive ID - * @property {boolean} unlocked - Whether ship is unlocked - * @property {Object|null} unlockCondition - Unlock requirements - * @property {string} color - Primary ship color (neon) - * @property {string} secondaryColor - Secondary ship color - * @property {string} difficulty - Difficulty rating (easy/medium/hard) - * @property {string} sprite - Sprite identifier or shape - */ - -const SHIPS = { - VAMPIRE: { - id: 'vampire', - name: 'Vampire', - description: 'Sustain through lifesteal and critical strikes', - baseStats: { - maxHealth: 80, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.8, - speed: 220 * 1.05, - armor: 0, - lifesteal: 0.05, - critChance: 0.05, - critMultiplier: 1.5, - magnetRange: 100, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'rayon_vampirique', - preferredTags: ['vampire', 'on_hit', 'on_kill', 'crit', 'regen'], - bannedTags: ['shield', 'summon'], - preferredWeapons: ['rayon_vampirique', 'orbes_orbitaux'], - preferredPassives: ['vampirisme', 'sang_froid', 'coeur_noir'], - keystoneId: 'blood_frenzy', - unlocked: true, - unlockCondition: null, - color: '#DC143C', - secondaryColor: '#8B0000', - difficulty: 'hard', - sprite: 'ship_vampire' - }, - - MITRAILLEUR: { - id: 'mitrailleur', - name: 'Gunner', - description: 'High fire rate, overheat mechanics', - baseStats: { - maxHealth: 100, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.9, - speed: 220, - armor: 0, - lifesteal: 0.0, - critChance: 0.05, - critMultiplier: 1.5, - magnetRange: 100, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'mitraille', - preferredTags: ['fire_rate', 'heat', 'projectile', 'crit'], - bannedTags: ['beam', 'slow_time'], - preferredWeapons: ['mitraille', 'laser_frontal', 'missiles_guides'], - preferredPassives: ['cadence_rapide', 'radiateur', 'multi_tir'], - keystoneId: 'overclock_core', - unlocked: true, - unlockCondition: null, - color: '#FFD700', - secondaryColor: '#FFA500', - difficulty: 'medium', - sprite: 'ship_gunship' - }, - - TANK: { - id: 'tank', - name: 'Fortress', - description: 'High armor and area control', - baseStats: { - maxHealth: 160, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.7, - speed: 220 * 0.85, - armor: 4, - lifesteal: 0.0, - critChance: 0.05, - critMultiplier: 1.5, - magnetRange: 100, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'laser_frontal', - preferredTags: ['armor', 'shield', 'aoe', 'thorns'], - bannedTags: ['dash', 'glass_cannon'], - preferredWeapons: ['canon_lourd', 'mines', 'arc_electrique'], - preferredPassives: ['plating', 'vitalite', 'bouclier_energie'], - keystoneId: 'fortress_mode', - unlocked: true, - unlockCondition: null, - color: '#00BFFF', - secondaryColor: '#1E90FF', - difficulty: 'easy', - sprite: 'ship_tank' - }, - - SNIPER: { - id: 'sniper', - name: 'Dead Eye', - description: 'Critical hits and precision', - baseStats: { - maxHealth: 90, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.75, - speed: 220, - armor: 0, - lifesteal: 0.0, - critChance: 0.08, - critMultiplier: 1.7, - magnetRange: 100 * 1.25, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'laser_frontal', - preferredTags: ['crit', 'range', 'piercing', 'slow'], - bannedTags: ['shotgun', 'short_range'], - preferredWeapons: ['railgun', 'missiles_guides'], - preferredPassives: ['critique_mortel', 'precision', 'focaliseur'], - keystoneId: 'dead_eye', - unlocked: true, - unlockCondition: null, - color: '#9370DB', - secondaryColor: '#8A2BE2', - difficulty: 'medium', - sprite: 'ship_sniper' - }, - - ENGINEER: { - id: 'engineer', - name: 'Engineer', - description: 'Summons and turrets', - baseStats: { - maxHealth: 110, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.8, - speed: 220, - armor: 0, - lifesteal: 0.0, - critChance: 0.05, - critMultiplier: 1.5, - magnetRange: 100, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'orbes_orbitaux', - preferredTags: ['summon', 'turret', 'utility', 'aoe'], - bannedTags: ['vampire', 'glass_cannon'], - preferredWeapons: ['tourelle_drone', 'orbes_orbitaux'], - preferredPassives: ['arsenal_orbital'], - keystoneId: 'machine_network', - unlocked: false, - unlockCondition: { type: 'wave_reached', wave: 15 }, - color: '#00FF88', - secondaryColor: '#00DD66', - difficulty: 'medium', - sprite: 'ship_engineer' - }, - - BERSERKER: { - id: 'berserker', - name: 'Berserker', - description: 'Melee fury and speed', - baseStats: { - maxHealth: 85, - healthRegen: 0.0, - damageMultiplier: 1.0, - fireRateMultiplier: 0.85, - speed: 220 * 1.15, - armor: 0, - lifesteal: 0.0, - critChance: 0.05, - critMultiplier: 1.5, - magnetRange: 100, - dashCooldown: 2.5, - luck: 0.0 - }, - startingWeapon: 'lame_tournoyante', - preferredTags: ['berserk', 'melee', 'speed', 'on_hit'], - bannedTags: ['shield', 'regen'], - preferredWeapons: ['lames_energetiques', 'lame_tournoyante'], - preferredPassives: ['fureur_combat', 'berserker', 'execution'], - keystoneId: 'rage_engine', - unlocked: false, - unlockCondition: { type: 'die_with_tags_count', tagsAnyOf: ['vampire', 'crit'], minCount: 5 }, - color: '#FF4444', - secondaryColor: '#CC0000', - difficulty: 'hard', - sprite: 'ship_berserker' - } -}; - -/** - * Get ship data by ID - * @param {string} shipId - Ship identifier - * @returns {ShipData|null} - */ -function getShipData(shipId) { - return SHIPS[shipId.toUpperCase()] || null; -} - -/** - * Get all ships sorted by difficulty - * @returns {ShipData[]} - */ -function getAllShips() { - const difficultyOrder = { easy: 0, medium: 1, hard: 2 }; - return Object.values(SHIPS).sort((a, b) => { - return difficultyOrder[a.difficulty] - difficultyOrder[b.difficulty]; - }); -} - -/** - * Calculate effective stats with passive bonuses - * @param {ShipStats} baseStats - Base ship statistics - * @param {Object} passiveEffects - Effects from passive items - * @returns {ShipStats} - */ -function calculateEffectiveStats(baseStats, passiveEffects = {}) { - const stats = { ...baseStats }; - - // Apply multiplicative bonuses - if (passiveEffects.damageMultiplier) { - stats.damageMultiplier *= (1 + passiveEffects.damageMultiplier); - } - if (passiveEffects.fireRateMultiplier) { - stats.fireRateMultiplier *= (1 + passiveEffects.fireRateMultiplier); - } - if (passiveEffects.maxHealthMultiplier) { - stats.maxHealth *= (1 + passiveEffects.maxHealthMultiplier); - } - if (passiveEffects.speedMultiplier) { - stats.speed *= (1 + passiveEffects.speedMultiplier); - } - if (passiveEffects.rangeMultiplier) { - // Range is applied to weapons, not ship stats - } - if (passiveEffects.projectileSpeedMultiplier) { - // Applied to weapons, not ship stats - } - - // Apply additive bonuses - if (passiveEffects.armor) { - stats.armor += passiveEffects.armor; - } - if (passiveEffects.lifesteal) { - stats.lifesteal += passiveEffects.lifesteal; - } - if (passiveEffects.critChance) { - stats.critChance = Math.min(1.0, stats.critChance + passiveEffects.critChance); - } - if (passiveEffects.critMultiplier) { - stats.critMultiplier += passiveEffects.critMultiplier; - } - if (passiveEffects.magnetRange) { - stats.magnetRange += passiveEffects.magnetRange; - } - if (passiveEffects.luck) { - stats.luck += passiveEffects.luck; - } - if (passiveEffects.dashCooldownReduction) { - stats.dashCooldown *= (1 - passiveEffects.dashCooldownReduction); - } - - return stats; -} - -/** - * Get ship unlock requirements - * @param {string} shipId - Ship identifier - * @returns {Object|null} - */ -function getShipUnlockRequirements(shipId) { - const ship = getShipData(shipId); - return ship ? ship.unlockCondition : null; -} - -/** - * Check if a ship is unlocked based on player progress - * @param {string} shipId - Ship identifier - * @param {Object} playerProgress - Player progress data - * @returns {boolean} - */ -function isShipUnlocked(shipId, playerProgress) { - const ship = getShipData(shipId); - if (!ship) return false; - if (ship.unlocked) return true; - - const condition = ship.unlockCondition; - if (!condition) return true; - - switch (condition.type) { - case 'wave_reached': - return playerProgress.maxWave >= condition.wave; - case 'die_with_tags_count': - const tagCount = playerProgress.dieWithTagsCounts || {}; - let count = 0; - condition.tagsAnyOf.forEach(tag => { - count += tagCount[tag] || 0; - }); - return count >= condition.minCount; - default: - return false; - } -} - -// Export to global namespace -const ShipData = { - SHIPS, - getShipData, - getAllShips, - calculateEffectiveStats, - getShipUnlockRequirements, - isShipUnlocked -}; - -if (typeof window !== 'undefined') { - window.ShipData = ShipData; -} diff --git a/js/data/SynergyData.js b/js/data/SynergyData.js deleted file mode 100644 index f739147..0000000 --- a/js/data/SynergyData.js +++ /dev/null @@ -1,305 +0,0 @@ -/** - * @fileoverview Synergy system data definitions for Space InZader - * Synergies activate when player has enough items with matching tags - */ - -/** - * @typedef {Object} SynergyBonus - * @property {string} type - Bonus type ('stat_add', 'event', 'mechanic') - * @property {string} [stat] - Stat to modify (for stat_add) - * @property {number} [value] - Value of the bonus - * @property {string} [event] - Event trigger (for event type) - * @property {Object} [effect] - Effect details - */ - -/** - * @typedef {Object} SynergyData - * @property {string} id - Unique identifier - * @property {string} name - Display name - * @property {string} description - Synergy description - * @property {string[]} tagsCounted - Tags that count toward this synergy - * @property {number[]} thresholds - Activation thresholds [tier1, tier2, tier3] - * @property {SynergyBonus} bonus2 - Bonus at tier 1 (2 stacks) - * @property {SynergyBonus} bonus4 - Bonus at tier 2 (4 stacks) - * @property {SynergyBonus} bonus6 - Bonus at tier 3 (6 stacks - powerful but with drawback) - * @property {string} color - Display color - */ - -const SYNERGIES = { - blood: { - id: 'blood', - name: 'Vampirique', - description: '2: +3% Lifesteal | 4: Soigne 15% PV max sur élite | 6: +8% Lifesteal MAIS -25% PV max', - tagsCounted: ['vampire', 'on_hit', 'on_kill'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'lifesteal', - value: 0.03 - }, - bonus4: { - type: 'event', - event: 'on_elite_kill', - effect: { type: 'heal_percent_max', value: 0.15 } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - lifesteal: 0.08, - maxHealthMultiplier: -0.25 - } - }, - color: '#FF1744' - }, - - crit: { - id: 'crit', - name: 'Critique', - description: '2: +15% Dégâts Crit | 4: Crits explosent (40px, 35% dégâts) | 6: +30% Dégâts Crit MAIS -10% chance Crit', - tagsCounted: ['crit'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'critDamage', - value: 0.15 - }, - bonus4: { - type: 'mechanic', - mechanic: 'crit_explosion', - effect: { - radius: 40, - damage: 0.35, - cooldown: 600 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - critDamage: 0.30, - critChance: -0.10 - } - }, - color: '#FFD700' - }, - - explosion: { - id: 'explosion', - name: 'Explosif', - description: '2: +20% Rayon Explosion | 4: Explosions en chaîne (2x, 55px, 40%) | 6: +40% Rayon MAIS 15% auto-dégâts', - tagsCounted: ['explosive', 'aoe'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'explosionRadius', - value: 0.2 - }, - bonus4: { - type: 'mechanic', - mechanic: 'chain_explosion', - effect: { - chains: 2, - radius: 55, - damage: 0.4 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - explosionRadius: 0.40, - selfExplosionDamage: 0.15 - } - }, - color: '#FF6B00' - }, - - heat: { - id: 'heat', - name: 'Chaleur', - description: '2: +25% Refroidissement | 4: Montée dégâts (35% max, 3s) | 6: +50% Refroidissement MAIS surchauffe permanente', - tagsCounted: ['heat', 'fire_rate'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'coolingRate', - value: 0.25 - }, - bonus4: { - type: 'mechanic', - mechanic: 'damage_ramp', - effect: { - maxBonus: 0.35, - timeToMax: 3.0 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - coolingRate: 0.50, - heatGenerationMultiplier: 0.30 - } - }, - color: '#FF4500' - }, - - dash: { - id: 'dash', - name: 'Mobilité', - description: '2: -20% Cooldown Dash | 4: 250ms invulnérabilité sur dash | 6: -40% Cooldown MAIS -15% vitesse', - tagsCounted: ['dash', 'speed'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'dashCooldown', - value: -0.2 - }, - bonus4: { - type: 'mechanic', - mechanic: 'dash_invuln', - effect: { - durationMs: 250 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - dashCooldown: -0.40, - speedMultiplier: -0.15 - } - }, - color: '#00E5FF' - }, - - summon: { - id: 'summon', - name: 'Invocation', - description: '2: +1 Invocation Max | 4: Invocations héritent 25% stats | 6: +2 Invocations MAIS -20% dégâts', - tagsCounted: ['summon', 'turret'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'summonCap', - value: 1 - }, - bonus4: { - type: 'mechanic', - mechanic: 'summon_inherit', - effect: { - inheritPercent: 0.25 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - summonCap: 2, - damageMultiplier: -0.20 - } - }, - color: '#00FF88' - }, - - glass_cannon: { - id: 'glass_cannon', - name: 'Canon de Verre', - description: '2: +15% Dégâts | 4: +30% Dégâts MAIS +20% dégâts reçus | 6: +50% Dégâts MAIS +40% dégâts reçus', - tagsCounted: ['glass_cannon', 'risk'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'damageMultiplier', - value: 0.15 - }, - bonus4: { - type: 'stat_add_multi', - stats: { - damageMultiplier: 0.30, - damageTakenMultiplier: 0.20 - } - }, - bonus6: { - type: 'stat_add_multi', - stats: { - damageMultiplier: 0.50, - damageTakenMultiplier: 0.40 - } - }, - color: '#DC143C' - }, - - utility: { - id: 'utility', - name: 'Utilitaire', - description: '2: +15% Portée | 4: +25% Rayon ramassage | 6: +30% Portée MAIS -10% dégâts', - tagsCounted: ['utility', 'control', 'support'], - thresholds: [2, 4, 6], - bonus2: { - type: 'stat_add', - stat: 'rangeMultiplier', - value: 0.15 - }, - bonus4: { - type: 'stat_add', - stat: 'magnetRange', - value: 0.25 - }, - bonus6: { - type: 'stat_add_multi', - stats: { - rangeMultiplier: 0.30, - damageMultiplier: -0.10 - } - }, - color: '#9370DB' - } -}; - -/** - * Get synergy data by ID - * @param {string} synergyId - Synergy identifier - * @returns {SynergyData|null} - */ -function getSynergy(synergyId) { - return SYNERGIES[synergyId] || null; -} - -/** - * Get all synergies - * @returns {SynergyData[]} - */ -function getAllSynergies() { - return Object.values(SYNERGIES); -} - -/** - * Check which synergies are active for a given tag set - * @param {Map} tagCounts - Map of tag to count - * @returns {Array<{synergy: SynergyData, count: number, threshold: number}>} - */ -function getActiveSynergies(tagCounts) { - const active = []; - - getAllSynergies().forEach(synergy => { - let count = 0; - synergy.tagsCounted.forEach(tag => { - count += tagCounts.get(tag) || 0; - }); - - if (count >= synergy.thresholds[0]) { - const threshold = count >= synergy.thresholds[1] ? 4 : 2; - active.push({ synergy, count, threshold }); - } - }); - - return active; -} - -// Export to global namespace -const SynergyData = { - SYNERGIES, - getSynergy, - getAllSynergies, - getActiveSynergies -}; - -if (typeof window !== 'undefined') { - window.SynergyData = SynergyData; -} diff --git a/js/data/WeaponData.js b/js/data/WeaponData.js deleted file mode 100644 index faf0abf..0000000 --- a/js/data/WeaponData.js +++ /dev/null @@ -1,522 +0,0 @@ -/** - * @fileoverview Weapon data definitions for Space InZader - * Defines all weapons, their properties, and evolution paths - */ - -/** - * @typedef {Object} WeaponLevel - * @property {number} damage - Damage multiplier - * @property {number} [projectileCount] - Number of projectiles - * @property {number} [area] - Area of effect - * @property {number} [duration] - Effect duration - * @property {number} [piercing] - Piercing count - * @property {number} [chainCount] - Chain/bounce count - */ - -/** - * @typedef {Object} WeaponData - * @property {string} id - Unique identifier - * @property {string} name - Display name - * @property {string} description - Weapon description - * @property {number} baseDamage - Base damage value - * @property {number} fireRate - Shots per second - * @property {number} projectileSpeed - Speed of projectiles - * @property {number} maxLevel - Maximum upgrade level - * @property {string} rarity - Rarity tier (common/uncommon/rare/epic) - * @property {string} color - Neon color for visuals - * @property {string} type - Weapon type/category - * @property {WeaponLevel[]} levels - Level progression data - */ - -const WEAPONS = { - LASER_FRONTAL: { - id: 'laser_frontal', - tags: ['projectile', 'fire_rate', 'piercing', 'range'], - name: 'Laser Frontal', - description: 'Tirs laser directs à haute cadence. Classique mais efficace.', - baseDamage: 15, - fireRate: 2.0, - projectileSpeed: 800, - maxLevel: 8, - rarity: 'common', - color: '#00FFFF', - type: 'direct', - levels: [ - { damage: 1.0, projectileCount: 1 }, - { damage: 1.2, projectileCount: 1 }, - { damage: 1.4, projectileCount: 2 }, - { damage: 1.6, projectileCount: 2 }, - { damage: 1.8, projectileCount: 2, piercing: 1 }, - { damage: 2.0, projectileCount: 3, piercing: 1 }, - { damage: 2.3, projectileCount: 3, piercing: 2 }, - { damage: 2.6, projectileCount: 4, piercing: 2 } - ] - }, - - MITRAILLE: { - id: 'mitraille', - tags: ['projectile', 'fire_rate', 'shotgun', 'short_range'], - name: 'Mitraille', - description: 'Cône de projectiles rapides. Excellent contre les essaims.', - baseDamage: 8, - fireRate: 4.0, - projectileSpeed: 600, - maxLevel: 8, - rarity: 'common', - color: '#FFD700', - type: 'spread', - levels: [ - { damage: 1.0, projectileCount: 3, area: 20 }, - { damage: 1.15, projectileCount: 4, area: 25 }, - { damage: 1.3, projectileCount: 5, area: 30 }, - { damage: 1.45, projectileCount: 6, area: 35 }, - { damage: 1.6, projectileCount: 7, area: 40 }, - { damage: 1.8, projectileCount: 8, area: 45 }, - { damage: 2.0, projectileCount: 9, area: 50 }, - { damage: 2.2, projectileCount: 10, area: 55 } - ] - }, - - MISSILES_GUIDES: { - id: 'missiles_guides', - tags: ['projectile', 'homing', 'explosive', 'aoe'], - name: 'Missiles Guidés', - description: 'Missiles à tête chercheuse avec explosion à l\'impact.', - baseDamage: 45, - fireRate: 1.2, - projectileSpeed: 400, - maxLevel: 8, - rarity: 'uncommon', - color: '#FF4500', - type: 'homing', - levels: [ - { damage: 1.0, projectileCount: 1, area: 60 }, - { damage: 1.2, projectileCount: 1, area: 70 }, - { damage: 1.4, projectileCount: 2, area: 80 }, - { damage: 1.6, projectileCount: 2, area: 90 }, - { damage: 1.8, projectileCount: 3, area: 100 }, - { damage: 2.0, projectileCount: 3, area: 110 }, - { damage: 2.3, projectileCount: 4, area: 120 }, - { damage: 2.6, projectileCount: 4, area: 140 } - ] - }, - - ORBES_ORBITAUX: { - id: 'orbes_orbitaux', - tags: ['orbital', 'melee', 'utility'], - name: 'Orbes Orbitaux', - description: 'Sphères d\'énergie en orbite qui endommagent au contact.', - baseDamage: 20, - fireRate: 0, - projectileSpeed: 0, - maxLevel: 8, - rarity: 'uncommon', - color: '#9370DB', - type: 'orbital', - levels: [ - { damage: 1.0, projectileCount: 2, area: 100 }, - { damage: 1.2, projectileCount: 2, area: 110 }, - { damage: 1.4, projectileCount: 3, area: 120 }, - { damage: 1.6, projectileCount: 3, area: 130 }, - { damage: 1.8, projectileCount: 4, area: 140 }, - { damage: 2.0, projectileCount: 4, area: 150 }, - { damage: 2.3, projectileCount: 5, area: 160 }, - { damage: 2.6, projectileCount: 5, area: 180 } - ] - }, - - RAYON_VAMPIRIQUE: { - id: 'rayon_vampirique', - tags: ['beam', 'vampire', 'piercing', 'range'], - name: 'Rayon Vampirique', - description: 'Rayon continu qui draine la vie des ennemis.', - baseDamage: 10, - fireRate: 20.0, - projectileSpeed: 0, - maxLevel: 8, - rarity: 'rare', - color: '#DC143C', - type: 'beam', - levels: [ - { damage: 1.0, area: 200, duration: 1.0 }, - { damage: 1.2, area: 220, duration: 1.0 }, - { damage: 1.4, area: 240, duration: 1.0 }, - { damage: 1.6, area: 260, duration: 1.0 }, - { damage: 1.8, area: 280, duration: 1.0, piercing: 1 }, - { damage: 2.0, area: 300, duration: 1.0, piercing: 2 }, - { damage: 2.3, area: 320, duration: 1.0, piercing: 3 }, - { damage: 2.6, area: 350, duration: 1.0, piercing: 4 } - ] - }, - - MINES: { - id: 'mines', - tags: ['projectile', 'explosive', 'aoe', 'utility'], - name: 'Mines', - description: 'Pose des mines qui explosent au contact des ennemis.', - baseDamage: 80, - fireRate: 0.8, - projectileSpeed: 200, - maxLevel: 8, - rarity: 'uncommon', - color: '#FF1493', - type: 'mine', - levels: [ - { damage: 1.0, projectileCount: 1, area: 80, duration: 10 }, - { damage: 1.2, projectileCount: 1, area: 90, duration: 12 }, - { damage: 1.4, projectileCount: 2, area: 100, duration: 14 }, - { damage: 1.6, projectileCount: 2, area: 110, duration: 16 }, - { damage: 1.8, projectileCount: 3, area: 120, duration: 18 }, - { damage: 2.0, projectileCount: 3, area: 130, duration: 20 }, - { damage: 2.3, projectileCount: 4, area: 140, duration: 22 }, - { damage: 2.6, projectileCount: 4, area: 160, duration: 25 } - ] - }, - - ARC_ELECTRIQUE: { - id: 'arc_electrique', - tags: ['projectile', 'electric', 'aoe', 'range'], - name: 'Arc Électrique', - description: 'Éclair qui rebondit entre les ennemis proches.', - baseDamage: 25, - fireRate: 2.0, - projectileSpeed: 1200, - maxLevel: 8, - rarity: 'rare', - color: '#00FFFF', - type: 'chain', - levels: [ - { damage: 1.0, chainCount: 3, area: 150 }, - { damage: 1.2, chainCount: 4, area: 160 }, - { damage: 1.4, chainCount: 5, area: 170 }, - { damage: 1.6, chainCount: 6, area: 180 }, - { damage: 1.8, chainCount: 7, area: 190 }, - { damage: 2.0, chainCount: 8, area: 200 }, - { damage: 2.3, chainCount: 10, area: 220 }, - { damage: 2.6, chainCount: 12, area: 250 } - ] - }, - - TOURELLE_DRONE: { - id: 'tourelle_drone', - tags: ['summon', 'turret', 'projectile', 'utility'], - name: 'Tourelle Drone', - description: 'Déploie un drone allié qui tire automatiquement.', - baseDamage: 12, - fireRate: 4.0, - projectileSpeed: 700, - maxLevel: 8, - rarity: 'epic', - color: '#00FF00', - type: 'turret', - levels: [ - { damage: 1.0, projectileCount: 1, duration: 15 }, - { damage: 1.2, projectileCount: 1, duration: 18 }, - { damage: 1.4, projectileCount: 2, duration: 20 }, - { damage: 1.6, projectileCount: 2, duration: 22 }, - { damage: 1.8, projectileCount: 2, duration: 25 }, - { damage: 2.0, projectileCount: 3, duration: 28 }, - { damage: 2.3, projectileCount: 3, duration: 30 }, - { damage: 2.6, projectileCount: 3, duration: 35 } - ] - }, - - // New weapons with strategic maluses - RAILGUN: { - id: 'railgun', - tags: ['projectile', 'piercing', 'crit', 'heat', 'glass_cannon'], - name: 'Railgun', - description: 'Canon électromagnétique dévastateur. Pénétration infinie mais surchauffe rapide. -40% cadence de tir.', - baseDamage: 80, - fireRate: 0.4, - projectileSpeed: 2000, - maxLevel: 8, - rarity: 'rare', - color: '#00CCFF', - type: 'railgun', - levels: [ - { damage: 1.0, projectileCount: 1, piercing: 999 }, - { damage: 1.3, projectileCount: 1, piercing: 999 }, - { damage: 1.6, projectileCount: 1, piercing: 999 }, - { damage: 2.0, projectileCount: 1, piercing: 999 }, - { damage: 2.4, projectileCount: 2, piercing: 999 }, - { damage: 2.8, projectileCount: 2, piercing: 999 }, - { damage: 3.3, projectileCount: 2, piercing: 999 }, - { damage: 4.0, projectileCount: 2, piercing: 999 } - ], - malus: { - heatGeneration: 2.0, - fireRateMultiplier: 0.6 - } - }, - - LANCE_FLAMMES: { - id: 'flamethrower', - tags: ['heat', 'aoe', 'dot', 'short_range', 'glass_cannon'], - name: 'Lance-Flammes', - description: 'Projette un cône de flammes. Haute cadence mais désactive les critiques. Courte portée.', - baseDamage: 5, - fireRate: 10.0, - projectileSpeed: 300, - maxLevel: 8, - rarity: 'uncommon', - color: '#FF6600', - type: 'flamethrower', - levels: [ - { damage: 1.0, projectileCount: 5, area: 30 }, - { damage: 1.15, projectileCount: 6, area: 35 }, - { damage: 1.3, projectileCount: 7, area: 40 }, - { damage: 1.5, projectileCount: 8, area: 45 }, - { damage: 1.7, projectileCount: 9, area: 50 }, - { damage: 2.0, projectileCount: 10, area: 55 }, - { damage: 2.3, projectileCount: 11, area: 60 }, - { damage: 2.7, projectileCount: 12, area: 65 } - ], - malus: { - critDisabled: true, - heatGeneration: 3.0, - rangeMultiplier: 0.5 - } - }, - - CANON_GRAVITATIONNEL: { - id: 'gravity_cannon', - tags: ['aoe', 'control', 'utility', 'slow'], - name: 'Canon Gravitationnel', - description: 'Tire des orbes qui attirent les ennemis... et vous aussi! Zone d\'attraction.', - baseDamage: 25, - fireRate: 0.8, - projectileSpeed: 400, - maxLevel: 8, - rarity: 'rare', - color: '#9932CC', - type: 'gravity', - levels: [ - { damage: 1.0, projectileCount: 1, area: 120 }, - { damage: 1.2, projectileCount: 1, area: 140 }, - { damage: 1.4, projectileCount: 1, area: 160 }, - { damage: 1.7, projectileCount: 2, area: 180 }, - { damage: 2.0, projectileCount: 2, area: 200 }, - { damage: 2.3, projectileCount: 2, area: 220 }, - { damage: 2.7, projectileCount: 3, area: 240 }, - { damage: 3.2, projectileCount: 3, area: 260 } - ], - malus: { - playerAttraction: 0.4 - } - }, - - TOURELLE_AUTONOME: { - id: 'auto_turret', - tags: ['summon', 'turret', 'support', 'utility'], - name: 'Tourelle Autonome', - description: 'Déploie une tourelle fixe puissante. Stationnaire mais efficace.', - baseDamage: 18, - fireRate: 1.5, - projectileSpeed: 750, - maxLevel: 8, - rarity: 'uncommon', - color: '#00FF88', - type: 'static_turret', - levels: [ - { damage: 1.0, projectileCount: 1, duration: 20 }, - { damage: 1.2, projectileCount: 1, duration: 24 }, - { damage: 1.5, projectileCount: 2, duration: 28 }, - { damage: 1.8, projectileCount: 2, duration: 32 }, - { damage: 2.1, projectileCount: 2, duration: 36 }, - { damage: 2.5, projectileCount: 3, duration: 40 }, - { damage: 2.9, projectileCount: 3, duration: 45 }, - { damage: 3.5, projectileCount: 3, duration: 50 } - ], - malus: { - stationary: true, - maxSummons: 1 - } - }, - - LAMES_FANTOMES: { - id: 'phantom_blades', - tags: ['melee', 'orbit', 'aoe', 'short_range'], - name: 'Lames Fantômes', - description: 'Lames orbitales éthérées. -40% dégâts contre les boss.', - baseDamage: 20, - fireRate: 0, - projectileSpeed: 0, - maxLevel: 8, - rarity: 'rare', - color: '#9370DB', - type: 'orbit_melee', - levels: [ - { damage: 1.0, projectileCount: 2, area: 90 }, - { damage: 1.2, projectileCount: 2, area: 95 }, - { damage: 1.5, projectileCount: 3, area: 100 }, - { damage: 1.8, projectileCount: 3, area: 105 }, - { damage: 2.1, projectileCount: 4, area: 110 }, - { damage: 2.5, projectileCount: 4, area: 115 }, - { damage: 2.9, projectileCount: 5, area: 120 }, - { damage: 3.5, projectileCount: 5, area: 130 } - ], - malus: { - bossDamageMultiplier: 0.6 - } - }, - - DRONE_KAMIKAZE: { - id: 'kamikaze_drone', - tags: ['summon', 'explosive', 'burst', 'glass_cannon'], - name: 'Drone Kamikaze', - description: 'Drone explosif suicide. Énormes dégâts de zone mais 20% auto-dégâts.', - baseDamage: 120, - fireRate: 0.125, - projectileSpeed: 250, - maxLevel: 8, - rarity: 'epic', - color: '#FF0000', - type: 'kamikaze', - levels: [ - { damage: 1.0, projectileCount: 1, area: 150 }, - { damage: 1.3, projectileCount: 1, area: 170 }, - { damage: 1.6, projectileCount: 1, area: 190 }, - { damage: 2.0, projectileCount: 1, area: 210 }, - { damage: 2.4, projectileCount: 2, area: 230 }, - { damage: 2.9, projectileCount: 2, area: 250 }, - { damage: 3.5, projectileCount: 2, area: 280 }, - { damage: 4.2, projectileCount: 2, area: 320 } - ], - malus: { - selfDamage: 0.2, - cooldown: 8.0 - } - } -}; - -/** - * Weapon evolution definitions - * Combines maxed weapons with passives to create ultimate weapons - */ -const WEAPON_EVOLUTIONS = { - RAYON_PLASMA: { - id: 'rayon_plasma_continu', - tags: ['beam', 'piercing', 'range', 'heat'], - name: 'Rayon Plasma Continu', - description: 'Évolution ultime du laser. Rayon continu dévastateur.', - requiredWeapon: 'laser_frontal', - requiredWeaponLevel: 8, - requiredPassive: 'radiateur', - baseDamage: 40, - fireRate: 0, - projectileSpeed: 0, - color: '#00FFFF', - type: 'continuous_beam', - stats: { - damage: 3.5, - area: 400, - piercing: 999, - duration: 1.0 - } - }, - - SALVES_MULTI: { - id: 'salves_multi_verrouillage', - tags: ['projectile', 'homing', 'explosive', 'aoe'], - name: 'Salves Multi-Verrouillage', - description: 'Évolution des missiles. Salves massives de missiles intelligents.', - requiredWeapon: 'missiles_guides', - requiredWeaponLevel: 8, - requiredPassive: 'focaliseur', - baseDamage: 55, - fireRate: 2.0, - projectileSpeed: 600, - color: '#FF6600', - type: 'mega_homing', - stats: { - damage: 3.0, - projectileCount: 8, - area: 180, - piercing: 0 - } - }, - - COURONNE_GRAVITATIONNELLE: { - id: 'couronne_gravitationnelle', - tags: ['orbital', 'aoe', 'melee', 'slow'], - name: 'Couronne Gravitationnelle', - description: 'Évolution des orbes. Attire et broie les ennemis.', - requiredWeapon: 'orbes_orbitaux', - requiredWeaponLevel: 8, - requiredPassive: 'mag_tractor', - baseDamage: 35, - fireRate: 0, - projectileSpeed: 0, - color: '#9370DB', - type: 'gravity_field', - stats: { - damage: 3.2, - projectileCount: 8, - area: 250, - duration: 1.0 - } - }, - - TEMPETE_IONIQUE: { - id: 'tempete_ionique', - tags: ['projectile', 'electric', 'aoe', 'range'], - name: 'Tempête Ionique', - description: 'Évolution de l\'arc électrique. Décharge foudroyante massive.', - requiredWeapon: 'arc_electrique', - requiredWeaponLevel: 8, - requiredPassive: 'bobines_tesla', - baseDamage: 45, - fireRate: 3.0, - projectileSpeed: 1500, - color: '#00FFFF', - type: 'storm', - stats: { - damage: 3.8, - chainCount: 20, - area: 350, - piercing: 0 - } - } -}; - -/** - * Get weapon data by ID - * @param {string} weaponId - Weapon identifier - * @returns {WeaponData|null} - */ -function getWeaponData(weaponId) { - return WEAPONS[weaponId.toUpperCase()] || null; -} - -/** - * Get evolution for weapon and passive combination - * @param {string} weaponId - Current weapon ID - * @param {number} weaponLevel - Current weapon level - * @param {string} passiveId - Passive ID - * @returns {Object|null} - */ -function getWeaponEvolution(weaponId, weaponLevel, passiveId) { - for (const evolution of Object.values(WEAPON_EVOLUTIONS)) { - if ( - evolution.requiredWeapon === weaponId && - evolution.requiredWeaponLevel <= weaponLevel && - evolution.requiredPassive === passiveId - ) { - return evolution; - } - } - return null; -} - -// Export to global namespace -const WeaponData = { - WEAPONS, - WEAPON_EVOLUTIONS, - getWeaponData, - getWeaponEvolution -}; - -if (typeof window !== 'undefined') { - window.WeaponData = WeaponData; -} diff --git a/js/dev/ContentAuditor.js b/js/dev/ContentAuditor.js deleted file mode 100644 index 03ea2ec..0000000 --- a/js/dev/ContentAuditor.js +++ /dev/null @@ -1,432 +0,0 @@ -/** - * @file ContentAuditor.js - * @description Systematic verification of all weapons and passives - * Parses content data and verifies each item works correctly - */ - -class ContentAuditor { - constructor(game) { - this.game = game; - this.weapons = []; - this.passives = []; - this.verificationResults = new Map(); - this.initialized = false; - } - - /** - * Initialize the auditor - parse all content - */ - initialize() { - if (this.initialized) return; - - console.log('%c[ContentAuditor] Initializing...', 'color: #00ff00; font-weight: bold'); - - this.parseWeapons(); - this.parsePassives(); - this.initialized = true; - - console.log(`%c[ContentAuditor] Parsed ${this.weapons.length} weapons and ${this.passives.length} passives`, - 'color: #00ff00; font-weight: bold'); - } - - /** - * Parse all weapons from WeaponData - */ - parseWeapons() { - this.weapons = []; - - if (typeof WeaponData === 'undefined' || !WeaponData.WEAPONS) { - console.error('[ContentAuditor] WeaponData not available'); - return; - } - - for (const [key, weapon] of Object.entries(WeaponData.WEAPONS)) { - const parsed = { - id: weapon.id, - key: key, - name: weapon.name, - description: weapon.description, - tags: weapon.tags || [], - baseDamage: weapon.baseDamage, - fireRate: weapon.fireRate, - projectileSpeed: weapon.projectileSpeed, - maxLevel: weapon.maxLevel, - rarity: weapon.rarity, - color: weapon.color, - type: weapon.type, - levels: weapon.levels || [], - hasVisuals: !!weapon.color, - hasSFX: true // Assume all weapons have sound - }; - - this.weapons.push(parsed); - this.verificationResults.set(`weapon_${weapon.id}`, { status: 'PENDING', tested: false }); - } - } - - /** - * Parse all passives from PassiveData - */ - parsePassives() { - this.passives = []; - - if (typeof PassiveData === 'undefined' || !PassiveData.PASSIVES) { - console.error('[ContentAuditor] PassiveData not available'); - return; - } - - for (const [key, passive] of Object.entries(PassiveData.PASSIVES)) { - const parsed = { - id: passive.id, - key: key, - name: passive.name, - description: passive.description, - tags: passive.tags || [], - effects: passive.effects || {}, - effectKeys: Object.keys(passive.effects || {}), - maxStacks: passive.maxStacks || 1, - rarity: passive.rarity, - color: passive.color, - icon: passive.icon, - isMalus: this.hasMalusEffect(passive.effects) - }; - - this.passives.push(parsed); - this.verificationResults.set(`passive_${passive.id}`, { status: 'PENDING', tested: false }); - } - } - - /** - * Check if a passive has any negative effects (malus) - */ - hasMalusEffect(effects) { - if (!effects) return false; - - for (const [key, value] of Object.entries(effects)) { - // Negative values for most stats are malus - if (typeof value === 'number' && value < 0) { - // Exception: some reductions are intended (like cooldown reduction) - if (!key.includes('Reduction') && !key.includes('Cost')) { - return true; - } - } - } - return false; - } - - /** - * Verify a weapon works correctly - * @param {string} weaponId - Weapon ID to verify - * @returns {Object} Verification result - */ - verifyWeapon(weaponId) { - const weapon = this.weapons.find(w => w.id === weaponId); - if (!weapon) { - return { status: 'FAIL', error: 'Weapon not found', tested: true }; - } - - const result = { - status: 'OK', - tested: true, - checks: [], - warnings: [] - }; - - // Check 1: Has basic properties - if (!weapon.name) { - result.checks.push('❌ Missing name'); - result.status = 'FAIL'; - } else { - result.checks.push('✓ Has name'); - } - - if (!weapon.baseDamage || weapon.baseDamage <= 0) { - result.checks.push('❌ Invalid base damage'); - result.status = 'FAIL'; - } else { - result.checks.push('✓ Has valid base damage'); - } - - if (!weapon.fireRate || weapon.fireRate <= 0) { - result.checks.push('❌ Invalid fire rate'); - result.status = 'FAIL'; - } else { - result.checks.push('✓ Has valid fire rate'); - } - - // Check 2: Has levels - if (!weapon.levels || weapon.levels.length === 0) { - result.checks.push('❌ No upgrade levels defined'); - result.status = 'FAIL'; - } else { - result.checks.push(`✓ Has ${weapon.levels.length} upgrade levels`); - } - - // Check 3: Verify levels are progressive - if (weapon.levels && weapon.levels.length > 1) { - let allProgressive = true; - for (let i = 1; i < weapon.levels.length; i++) { - if (weapon.levels[i].damage <= weapon.levels[i-1].damage) { - allProgressive = false; - break; - } - } - if (!allProgressive) { - result.warnings.push('⚠️ Some levels may not be progressive'); - } - } - - // Check 4: Has visual/audio feedback - if (!weapon.color) { - result.warnings.push('⚠️ No color defined'); - } - - this.verificationResults.set(`weapon_${weaponId}`, result); - return result; - } - - /** - * Verify a passive works correctly - * @param {string} passiveId - Passive ID to verify - * @returns {Object} Verification result - */ - verifyPassive(passiveId) { - const passive = this.passives.find(p => p.id === passiveId); - if (!passive) { - return { status: 'FAIL', error: 'Passive not found', tested: true }; - } - - const result = { - status: 'OK', - tested: true, - checks: [], - warnings: [] - }; - - // Check 1: Has basic properties - if (!passive.name) { - result.checks.push('❌ Missing name'); - result.status = 'FAIL'; - } else { - result.checks.push('✓ Has name'); - } - - // Check 2: Has effects - if (!passive.effects || Object.keys(passive.effects).length === 0) { - result.checks.push('❌ No effects defined'); - result.status = 'FAIL'; - } else { - result.checks.push(`✓ Has ${passive.effectKeys.length} effect(s)`); - } - - // Check 3: Verify effect keys are valid - const validEffectKeys = [ - 'damageMultiplier', 'fireRateMultiplier', 'critChance', 'critMultiplier', - 'lifesteal', 'maxHealthMultiplier', 'armor', 'speedMultiplier', - 'projectileSpeedMultiplier', 'rangeMultiplier', 'magnetRange', 'xpMultiplier', - 'luck', 'shield', 'shieldRegen', 'healthRegen', 'dashCooldownReduction', - 'overheatReduction', 'electricDamageBonus', 'stunChance', 'piercing', - 'projectileCount', 'chainCount', 'explosionRadius', 'slowAmount' - ]; - - for (const key of passive.effectKeys) { - if (!validEffectKeys.includes(key)) { - result.warnings.push(`⚠️ Unknown effect key: ${key}`); - } - } - - // Check 4: Verify effect values are reasonable - for (const [key, value] of Object.entries(passive.effects)) { - if (typeof value !== 'number') { - result.checks.push(`❌ Effect ${key} has non-numeric value`); - result.status = 'FAIL'; - } else if (Math.abs(value) > 100) { - result.warnings.push(`⚠️ Effect ${key} has very large value: ${value}`); - } - } - - // Check 5: Has valid rarity - const validRarities = ['common', 'uncommon', 'rare', 'epic', 'legendary']; - if (!validRarities.includes(passive.rarity)) { - result.warnings.push(`⚠️ Invalid rarity: ${passive.rarity}`); - } - - // Check 6: Has reasonable max stacks - if (passive.maxStacks > 10) { - result.warnings.push(`⚠️ Very high max stacks: ${passive.maxStacks}`); - } - - this.verificationResults.set(`passive_${passiveId}`, result); - return result; - } - - /** - * Test applying a passive to player and verify stats change - * @param {string} passiveId - Passive ID to test - * @returns {Object} Test result with before/after stats - */ - testPassiveApplication(passiveId) { - const passive = this.passives.find(p => p.id === passiveId); - if (!passive) { - return { success: false, error: 'Passive not found' }; - } - - // Get player - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) { - return { success: false, error: 'No player found' }; - } - - const playerComp = player.getComponent('player'); - if (!playerComp) { - return { success: false, error: 'Player component not found' }; - } - - // Capture stats before - const statsBefore = JSON.parse(JSON.stringify(playerComp.stats)); - - // Apply passive - try { - PassiveData.applyPassiveEffects(passive.id, playerComp.stats); - } catch (error) { - return { success: false, error: `Application failed: ${error.message}` }; - } - - // Capture stats after - const statsAfter = JSON.parse(JSON.stringify(playerComp.stats)); - - // Check if any stat changed - let hasChanges = false; - const changes = {}; - - for (const key of Object.keys(statsAfter)) { - if (statsBefore[key] !== statsAfter[key]) { - hasChanges = true; - changes[key] = { - before: statsBefore[key], - after: statsAfter[key], - delta: statsAfter[key] - statsBefore[key] - }; - } - } - - if (!hasChanges) { - return { - success: false, - error: 'NO EFFECT DETECTED - Stats unchanged', - statsBefore, - statsAfter - }; - } - - return { - success: true, - changes, - statsBefore, - statsAfter - }; - } - - /** - * Run full verification of all content - * @returns {Object} Complete report - */ - runFullAudit() { - console.log('%c[ContentAuditor] Running full audit...', 'color: #ffaa00; font-weight: bold'); - - const report = { - weapons: { total: 0, ok: 0, fail: 0, pending: 0 }, - passives: { total: 0, ok: 0, fail: 0, pending: 0 }, - details: [] - }; - - // Verify all weapons - for (const weapon of this.weapons) { - const result = this.verifyWeapon(weapon.id); - report.weapons.total++; - if (result.status === 'OK') report.weapons.ok++; - else if (result.status === 'FAIL') report.weapons.fail++; - else report.weapons.pending++; - - if (result.status === 'FAIL' || result.warnings.length > 0) { - report.details.push({ - type: 'weapon', - id: weapon.id, - name: weapon.name, - status: result.status, - checks: result.checks, - warnings: result.warnings - }); - } - } - - // Verify all passives - for (const passive of this.passives) { - const result = this.verifyPassive(passive.id); - report.passives.total++; - if (result.status === 'OK') report.passives.ok++; - else if (result.status === 'FAIL') report.passives.fail++; - else report.passives.pending++; - - if (result.status === 'FAIL' || result.warnings.length > 0) { - report.details.push({ - type: 'passive', - id: passive.id, - name: passive.name, - status: result.status, - checks: result.checks, - warnings: result.warnings - }); - } - } - - return report; - } - - /** - * Print audit report to console - */ - printReport() { - const report = this.runFullAudit(); - - console.log('%c═══════════════════════════════════════', 'color: #00ffff'); - console.log('%c CONTENT AUDIT REPORT', 'color: #00ffff; font-weight: bold; font-size: 16px'); - console.log('%c═══════════════════════════════════════', 'color: #00ffff'); - console.log(''); - - console.log('%cWeapons:', 'color: #ffaa00; font-weight: bold'); - console.log(` Total: ${report.weapons.total}`); - console.log(` %cOK: ${report.weapons.ok}`, 'color: #00ff00'); - console.log(` %cFAIL: ${report.weapons.fail}`, 'color: #ff0000'); - console.log(` PENDING: ${report.weapons.pending}`); - console.log(''); - - console.log('%cPassives:', 'color: #ffaa00; font-weight: bold'); - console.log(` Total: ${report.passives.total}`); - console.log(` %cOK: ${report.passives.ok}`, 'color: #00ff00'); - console.log(` %cFAIL: ${report.passives.fail}`, 'color: #ff0000'); - console.log(` PENDING: ${report.passives.pending}`); - console.log(''); - - if (report.details.length > 0) { - console.log('%c⚠️ Issues Found:', 'color: #ff8800; font-weight: bold'); - for (const detail of report.details) { - console.log(`\n [${detail.type.toUpperCase()}] ${detail.name} (${detail.id})`); - console.log(` Status: %c${detail.status}`, detail.status === 'FAIL' ? 'color: #ff0000' : 'color: #ffaa00'); - - for (const check of detail.checks) { - console.log(` ${check}`); - } - - for (const warning of detail.warnings) { - console.log(` ${warning}`); - } - } - } - - console.log(''); - console.log('%c═══════════════════════════════════════', 'color: #00ffff'); - - return report; - } -} diff --git a/js/dev/DevTools.js b/js/dev/DevTools.js deleted file mode 100644 index 12ff861..0000000 --- a/js/dev/DevTools.js +++ /dev/null @@ -1,728 +0,0 @@ -/** - * @file DevTools.js - * @description Developer tools overlay for testing weapons and passives - * Press F4 or L to toggle - */ - -class DevTools { - constructor(game) { - this.game = game; - this.auditor = new ContentAuditor(game); - this.visible = false; - this.currentTab = 'weapons'; - this.searchTerm = ''; - this.container = null; - this.godModeEnabled = false; // Track invincibility state - - this.setupKeyBindings(); - this.createUI(); - } - - /** - * Setup keyboard bindings - */ - setupKeyBindings() { - window.addEventListener('keydown', (e) => { - // Support both F4 and L key for toggling dev tools - if (e.key === 'F4' || e.key === 'l' || e.key === 'L') { - e.preventDefault(); - this.toggle(); - } - }); - } - - /** - * Toggle dev tools visibility - */ - toggle() { - this.visible = !this.visible; - if (this.visible) { - this.show(); - } else { - this.hide(); - } - } - - /** - * Show dev tools - */ - show() { - if (!this.container) { - this.createUI(); - } - - // Initialize auditor if needed - if (!this.auditor.initialized) { - this.auditor.initialize(); - } - - this.container.style.display = 'block'; - this.render(); - - console.log('%c[DevTools] Opened (F4 or L to close)', 'color: #00ff00; font-weight: bold'); - } - - /** - * Hide dev tools - */ - hide() { - if (this.container) { - this.container.style.display = 'none'; - } - console.log('%c[DevTools] Closed', 'color: #888'); - } - - /** - * Create UI container - */ - createUI() { - if (this.container) return; - - this.container = document.createElement('div'); - this.container.id = 'devtools-overlay'; - this.container.className = 'devtools-overlay'; - this.container.style.display = 'none'; - document.body.appendChild(this.container); - } - - /** - * Render dev tools UI - */ - render() { - if (!this.container) return; - - this.container.innerHTML = ` -
-

🛠️ DEV TOOLS (Press F4 or L to close)

-
- - - - -
-
-
- ${this.renderTabContent()} -
- `; - } - - /** - * Render current tab content - */ - renderTabContent() { - switch (this.currentTab) { - case 'weapons': - return this.renderWeaponsTab(); - case 'passives': - return this.renderPassivesTab(); - case 'utils': - return this.renderUtilitiesTab(); - case 'audit': - return this.renderAuditTab(); - default: - return '

Unknown tab

'; - } - } - - /** - * Render weapons tab - */ - renderWeaponsTab() { - const weapons = this.auditor.weapons.filter(w => - !this.searchTerm || - w.name.toLowerCase().includes(this.searchTerm.toLowerCase()) || - w.id.toLowerCase().includes(this.searchTerm.toLowerCase()) - ); - - return ` - -
- ${weapons.map(w => ` -
-
-
- ${w.name} -
-
- ${w.rarity} | Lv1-${w.maxLevel} | ${w.baseDamage} dmg | ${w.fireRate}/s -
-
${w.description}
-
${w.tags.join(', ')}
-
-
- - -
-
- `).join('')} -
- `; - } - - /** - * Render passives tab - */ - renderPassivesTab() { - const passives = this.auditor.passives.filter(p => - !this.searchTerm || - p.name.toLowerCase().includes(this.searchTerm.toLowerCase()) || - p.id.toLowerCase().includes(this.searchTerm.toLowerCase()) - ); - - return ` - -
- ${passives.map(p => { - const effectsStr = Object.entries(p.effects) - .map(([k, v]) => `${k}: ${v > 0 ? '+' : ''}${v}`) - .join(', '); - - return ` -
-
-
- ${p.icon} ${p.name} ${p.isMalus ? '⚠️' : ''} -
-
- ${p.rarity} | Max stacks: ${p.maxStacks} -
-
${p.description}
-
${effectsStr}
-
${p.tags.join(', ')}
-
-
- - -
-
- `; - }).join('')} -
- `; - } - - /** - * Render utilities tab - */ - renderUtilitiesTab() { - const player = this.game.world.getEntitiesByType('player')[0]; - const playerComp = player?.getComponent('player'); - const health = player?.getComponent('health'); - const waveNumber = this.game.systems.wave?.getWaveNumber() || 1; - - const statsHtml = playerComp ? ` -
- ${Object.entries(playerComp.stats).map(([key, value]) => ` -
- ${key}: - ${typeof value === 'number' ? value.toFixed(3) : value} -
- `).join('')} -
- ` : '

No player found

'; - - const godModeButtonStyle = this.godModeEnabled ? - 'background: rgba(0, 255, 0, 0.2); border-color: #00ff00;' : ''; - - return ` -
-
-

Player Control

- - - - - - -
- -
-

Wave Control

-

Current Wave: ${waveNumber}

-
- - -
- - -
- -
-

Weather Events

- - - - -
- -
-

Current Stats

- ${statsHtml} -
- -
-

Player Info

- ${health ? `

HP: ${health.current} / ${health.max}

` : ''} - ${health && this.godModeEnabled ? `

🛡️ INVINCIBLE

` : ''} - ${playerComp ? `

Level: ${playerComp.level}

` : ''} - ${playerComp ? `

XP: ${playerComp.xp} / ${playerComp.xpToLevel}

` : ''} - ${playerComp ? `

Weapons: ${playerComp.weapons.length}

` : ''} - ${playerComp ? `

Passives: ${playerComp.passives.length}

` : ''} -
-
- `; - } - - /** - * Render audit tab - */ - renderAuditTab() { - return ` -
-

Content Audit

-

Run automated verification of all weapons and passives.

- - - -
-

Click "Run Full Audit" to start verification.

-
-
- `; - } - - /** - * Switch to a different tab - */ - switchTab(tab) { - this.currentTab = tab; - this.render(); - } - - /** - * Set search term - */ - setSearch(term) { - this.searchTerm = term; - this.render(); - } - - /** - * Give weapon to player - */ - giveWeapon(weaponId) { - try { - this.game.addWeaponToPlayer(weaponId); - console.log(`%c[DevTools] Gave weapon: ${weaponId}`, 'color: #00ff00'); - this.render(); // Refresh to show updated stats - } catch (error) { - console.error(`[DevTools] Failed to give weapon ${weaponId}:`, error); - } - } - - /** - * Give passive to player - */ - givePassive(passiveId) { - try { - this.game.addPassiveToPlayer(passiveId); - console.log(`%c[DevTools] Gave passive: ${passiveId}`, 'color: #00ff00'); - this.render(); // Refresh to show updated stats - } catch (error) { - console.error(`[DevTools] Failed to give passive ${passiveId}:`, error); - } - } - - /** - * Verify an item (weapon or passive) - */ - verifyItem(type, id) { - if (type === 'weapon') { - const result = this.auditor.verifyWeapon(id); - console.log(`%cWeapon Verification: ${id}`, 'color: #ffaa00; font-weight: bold'); - console.log('Status:', result.status); - console.log('Checks:', result.checks); - console.log('Warnings:', result.warnings); - } else if (type === 'passive') { - const result = this.auditor.verifyPassive(id); - console.log(`%cPassive Verification: ${id}`, 'color: #ffaa00; font-weight: bold'); - console.log('Status:', result.status); - console.log('Checks:', result.checks); - console.log('Warnings:', result.warnings); - - // Also test application - const appResult = this.auditor.testPassiveApplication(id); - if (appResult.success) { - console.log('%cApplication Test: SUCCESS', 'color: #00ff00'); - console.log('Changes:', appResult.changes); - } else { - console.log('%cApplication Test: FAILED', 'color: #ff0000'); - console.log('Error:', appResult.error); - } - } - } - - /** - * Spawn a dummy enemy for testing - */ - spawnDummy() { - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) { - console.error('[DevTools] No player found'); - return; - } - - const playerPos = player.getComponent('position'); - const dummy = this.game.world.createEntity('enemy'); - - dummy.addComponent('position', Components.Position(playerPos.x + 150, playerPos.y)); - dummy.addComponent('velocity', Components.Velocity(0, 0)); // Immobile - dummy.addComponent('collision', Components.Collision(25)); - dummy.addComponent('health', Components.Health(10000, 10000)); // High HP - dummy.addComponent('renderable', Components.Renderable('#ff00ff', 25, 'circle')); - dummy.addComponent('enemy', { - type: 'dummy', - damage: 0, - xpValue: 0, - aiType: 'none', // No AI - speed: 0 - }); - - console.log('%c[DevTools] Spawned dummy enemy', 'color: #00ff00'); - } - - /** - * Reset the current run - */ - resetRun() { - if (confirm('Reset current run? (This will restart the game)')) { - this.game.gameOver(); - setTimeout(() => { - this.game.startGame(); - }, 100); - console.log('%c[DevTools] Run reset', 'color: #00ff00'); - } - } - - /** - * Set player health - */ - setHealth(amount) { - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) return; - - const health = player.getComponent('health'); - if (health) { - health.current = Math.min(amount, health.max); - console.log(`%c[DevTools] Set health to ${health.current}`, 'color: #00ff00'); - this.render(); - } - } - - /** - * Add XP to player - */ - addXP(amount) { - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerComp = player.getComponent('player'); - if (playerComp) { - playerComp.xp += amount; - console.log(`%c[DevTools] Added ${amount} XP`, 'color: #00ff00'); - this.render(); - } - } - - /** - * Clear all weapons and passives - */ - clearAll() { - if (!confirm('Clear all weapons and passives?')) return; - - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerComp = player.getComponent('player'); - if (playerComp) { - playerComp.weapons = []; - playerComp.passives = []; - this.game.recalculatePlayerStats(); - console.log('%c[DevTools] Cleared all weapons and passives', 'color: #00ff00'); - this.render(); - } - } - - /** - * Run full audit - */ - runAudit() { - const report = this.auditor.runFullAudit(); - - const resultsDiv = document.getElementById('audit-results'); - if (resultsDiv) { - const weaponStatus = report.weapons.fail > 0 ? '🔴' : '🟢'; - const passiveStatus = report.passives.fail > 0 ? '🔴' : '🟢'; - - resultsDiv.innerHTML = ` -
-

Audit Summary

-

${weaponStatus} Weapons: ${report.weapons.ok}/${report.weapons.total} OK, ${report.weapons.fail} FAIL

-

${passiveStatus} Passives: ${report.passives.ok}/${report.passives.total} OK, ${report.passives.fail} FAIL

- ${report.details.length > 0 ? `

⚠️ ${report.details.length} issues found (see console)

` : '

✅ No issues found!

'} -
- `; - } - - console.log('%c[DevTools] Full audit complete - see report above', 'color: #00ff00'); - } - - /** - * Print audit report to console - */ - printAuditReport() { - this.auditor.printReport(); - } - - /** - * Spawn a black hole (glass hole) event - */ - spawnBlackHole() { - const weatherSystem = this.game.systems.weather; - if (!weatherSystem) { - console.error('[DevTools] Weather system not found'); - return; - } - - // End current event if any - if (weatherSystem.activeEvent) { - weatherSystem.endEvent(); - } - - // Manually trigger black hole - weatherSystem.activeEvent = { type: 'black_hole', duration: 12 }; - weatherSystem.showingWarning = false; - weatherSystem.eventTimer = 12; - weatherSystem.spawnBlackHole(); - - if (weatherSystem.audioManager && weatherSystem.audioManager.initialized) { - weatherSystem.audioManager.playSFX('black_hole_spawn'); - } - - console.log('%c[DevTools] Spawned black hole (glass hole)', 'color: #9400D3; font-weight: bold'); - } - - /** - * Spawn a meteor storm event - */ - spawnMeteorStorm() { - const weatherSystem = this.game.systems.weather; - if (!weatherSystem) { - console.error('[DevTools] Weather system not found'); - return; - } - - // End current event if any - if (weatherSystem.activeEvent) { - weatherSystem.endEvent(); - } - - // Manually trigger meteor storm - weatherSystem.activeEvent = { type: 'meteor_storm', duration: 15 }; - weatherSystem.showingWarning = false; - weatherSystem.eventTimer = 15; - weatherSystem.meteorSpawnTimer = 0; - - if (weatherSystem.audioManager && weatherSystem.audioManager.initialized) { - weatherSystem.audioManager.playSFX('meteor_warning'); - } - - console.log('%c[DevTools] Spawned meteor storm', 'color: #ff6600; font-weight: bold'); - } - - /** - * Spawn a magnetic storm event - */ - spawnMagneticStorm() { - const weatherSystem = this.game.systems.weather; - if (!weatherSystem) { - console.error('[DevTools] Weather system not found'); - return; - } - - // End current event if any - if (weatherSystem.activeEvent) { - weatherSystem.endEvent(); - } - - // Manually trigger magnetic storm - weatherSystem.activeEvent = { type: 'magnetic_storm', duration: 5 }; - weatherSystem.showingWarning = false; - weatherSystem.eventTimer = 5; - weatherSystem.startMagneticStorm(); - - if (weatherSystem.audioManager && weatherSystem.audioManager.initialized) { - weatherSystem.audioManager.playSFX('electric'); - } - - console.log('%c[DevTools] Spawned magnetic storm', 'color: #9900ff; font-weight: bold'); - } - - /** - * End the current weather event - */ - endWeatherEvent() { - const weatherSystem = this.game.systems.weather; - if (!weatherSystem) { - console.error('[DevTools] Weather system not found'); - return; - } - - if (!weatherSystem.activeEvent) { - console.log('%c[DevTools] No active weather event to end', 'color: #ffaa00'); - return; - } - - const eventType = weatherSystem.activeEvent.type; - weatherSystem.endEvent(); - console.log(`%c[DevTools] Ended weather event: ${eventType}`, 'color: #00ff00'); - } - - /** - * Toggle god mode (invincibility) - */ - toggleGodMode() { - this.godModeEnabled = !this.godModeEnabled; - - const player = this.game.world.getEntitiesByType('player')[0]; - if (!player) { - console.error('[DevTools] No player found'); - return; - } - - const health = player.getComponent('health'); - if (health) { - if (this.godModeEnabled) { - // Enable god mode - make player permanently invulnerable - health.godMode = true; - console.log('%c[DevTools] God Mode ENABLED - Player is now invincible! 🛡️', 'color: #00ff00; font-weight: bold; font-size: 14px'); - } else { - // Disable god mode - health.godMode = false; - console.log('%c[DevTools] God Mode DISABLED - Player can take damage again', 'color: #ffaa00; font-weight: bold'); - } - this.render(); - } - } - - /** - * Jump to a specific wave - * @param {number} waveNumber - Target wave number - */ - jumpToWave(waveNumber) { - const targetWave = parseInt(waveNumber); - - // Validate wave number (1-999 to match UI constraints) - if (isNaN(targetWave) || targetWave < 1 || targetWave > 999) { - console.error('[DevTools] Invalid wave number:', waveNumber); - alert('Please enter a valid wave number (1-999)'); - return; - } - - const waveSystem = this.game.systems.wave; - if (!waveSystem) { - console.error('[DevTools] Wave system not found'); - return; - } - - // Update wave system state directly (WaveSystem doesn't provide setter methods) - // This is acceptable in dev tools context for testing purposes - waveSystem.waveNumber = targetWave; - waveSystem.waveTimer = 0; - waveSystem.isPaused = false; - waveSystem.pauseTimer = 0; - waveSystem.shouldSpawn = true; - - // Trigger wave announcement - if (waveSystem.onWaveStart) { - waveSystem.onWaveStart(targetWave); - } - - // Clear existing enemies for clean slate - const enemies = this.game.world.getEntitiesByType('enemy'); - enemies.forEach(enemy => { - this.game.world.removeEntity(enemy); - }); - - console.log(`%c[DevTools] Jumped to wave ${targetWave}! 🚀`, 'color: #00ff00; font-weight: bold; font-size: 14px'); - - // Refresh UI to show new wave number - this.render(); - } -} - -// Make DevTools globally accessible -window.DevTools = DevTools; diff --git a/js/main.js b/js/main.js deleted file mode 100644 index c0fcdfb..0000000 --- a/js/main.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file main.js - * @description Entry point for Space InZader game - */ - -// Initialize game when DOM is ready -window.addEventListener('DOMContentLoaded', () => { - console.log('Space InZader - Initializing...'); - - try { - const game = new Game(); - window.gameInstance = game; - - // Initialize dev tools (F4 or L to toggle) - if (typeof DevTools !== 'undefined') { - window.devTools = new DevTools(game); - console.log('%c[DevTools] Initialized - Press F4 or L to open', 'color: #00ff00; font-weight: bold'); - } - - console.log('Space InZader - Ready!'); - } catch (error) { - console.error('Failed to initialize game:', error); - alert('Failed to start game. Please refresh the page.'); - } -}); - -// Handle page visibility for pause/resume -document.addEventListener('visibilitychange', () => { - if (document.hidden && window.gameInstance) { - if (window.gameInstance.gameState.isState(GameStates.RUNNING)) { - window.gameInstance.pauseGame(); - } - } -}); - -// Prevent context menu on canvas -document.getElementById('gameCanvas')?.addEventListener('contextmenu', (e) => { - e.preventDefault(); -}); - -console.log('Space InZader - Scripts loaded'); diff --git a/js/managers/AudioManager.js b/js/managers/AudioManager.js deleted file mode 100644 index 46d81f4..0000000 --- a/js/managers/AudioManager.js +++ /dev/null @@ -1,823 +0,0 @@ -/** - * @file AudioManager.js - * @description Manages game audio and sound effects using Web Audio API - */ - -class AudioManager { - constructor() { - this.context = null; - this.masterGain = null; - this.musicGain = null; - this.sfxGain = null; - this.initialized = false; - this.sounds = {}; - this.musicVolume = 0.5; - this.sfxVolume = 0.7; - this.muted = false; - this.previousMasterVolume = 1.0; - - // MP3 Music system - Update this list when adding new music files - this.musicTracks = [ - 'music/1263681_8-Bit-Flight.mp3', - 'music/19583_newgrounds_robot_.mp3', - 'music/290077_spacecake.mp3', - 'music/575907_Space-Dumka-8bit.mp3', - 'music/770175_Outer-Space-Adventure-Agen.mp3', - 'music/888921_8-Bit-Flight-Loop.mp3' - ]; - this.currentAudio = null; - this.musicSource = null; - this.musicPlaying = false; - this.lastPlayedIndex = -1; - this.errorCount = 0; - this.onTrackEnded = null; - this.onTrackError = null; - - // Kept for backward compatibility - this.currentTheme = 'calm'; - this.currentMelodyIndex = 0; - this.crossfading = false; - - // Load settings from localStorage - this.loadSettings(); - } - - /** - * Initialize Web Audio API (must be called after user interaction) - */ - init() { - if (this.initialized) return; - - try { - this.context = new (window.AudioContext || window.webkitAudioContext)(); - this.masterGain = this.context.createGain(); - this.musicGain = this.context.createGain(); - this.sfxGain = this.context.createGain(); - - this.musicGain.connect(this.masterGain); - this.sfxGain.connect(this.masterGain); - this.masterGain.connect(this.context.destination); - - this.musicGain.gain.value = this.musicVolume; - this.sfxGain.gain.value = this.sfxVolume; - - this.initialized = true; - console.log('AudioManager initialized'); - } catch (e) { - console.warn('Web Audio API not supported:', e); - } - } - - /** - * Set music volume - * @param {number} volume - Volume level 0-1 - */ - setMusicVolume(volume) { - this.musicVolume = Math.max(0, Math.min(1, volume)); - if (this.musicGain && !this.muted) { - this.musicGain.gain.value = this.musicVolume; - } - // Also update HTML5 audio element if playing MP3 - if (this.currentAudio && !this.muted) { - this.currentAudio.volume = this.musicVolume; - } - this.saveSettings(); - } - - /** - * Set SFX volume - * @param {number} volume - Volume level 0-1 - */ - setSFXVolume(volume) { - this.sfxVolume = Math.max(0, Math.min(1, volume)); - if (this.sfxGain && !this.muted) { - this.sfxGain.gain.value = this.sfxVolume; - } - this.saveSettings(); - } - - /** - * Mute or unmute all audio - * @param {boolean} muted - Whether to mute - */ - setMute(muted) { - this.muted = muted; - if (this.masterGain) { - if (muted) { - this.previousMasterVolume = this.masterGain.gain.value; - this.masterGain.gain.value = 0; - } else { - this.masterGain.gain.value = this.previousMasterVolume; - // Restore individual volumes - if (this.musicGain) this.musicGain.gain.value = this.musicVolume; - if (this.sfxGain) this.sfxGain.gain.value = this.sfxVolume; - } - } - // Also mute/unmute HTML5 audio element - if (this.currentAudio) { - this.currentAudio.volume = muted ? 0 : this.musicVolume; - } - this.saveSettings(); - } - - /** - * Alias for setMute (backward compatibility) - * @param {boolean} muted - Whether to mute - */ - setMuted(muted) { - this.setMute(muted); - } - - /** - * Set SFX volume - * @param {number} volume - Volume level (0-1) - */ - setSfxVolume(volume) { - this.sfxVolume = Math.max(0, Math.min(1, volume)); - if (this.sfxGain && !this.muted) { - this.sfxGain.gain.value = this.sfxVolume; - } - this.saveSettings(); - } - - /** - * Save audio settings to localStorage - */ - saveSettings() { - try { - const settings = { - musicVolume: this.musicVolume, - sfxVolume: this.sfxVolume, - muted: this.muted - }; - localStorage.setItem('spaceInZader_audioSettings', JSON.stringify(settings)); - } catch (e) { - console.warn('Failed to save audio settings:', e); - } - } - - /** - * Load audio settings from localStorage - */ - loadSettings() { - try { - const saved = localStorage.getItem('spaceInZader_audioSettings'); - if (saved) { - const settings = JSON.parse(saved); - this.musicVolume = settings.musicVolume || 0.5; - this.sfxVolume = settings.sfxVolume || 0.7; - this.muted = settings.muted || false; - } - } catch (e) { - console.warn('Failed to load audio settings:', e); - } - } - - /** - * Play a synthesized sound effect - * @param {string} type - Sound type (laser, explosion, hit, etc.) - * @param {number} pitch - Pitch multiplier (default 1) - */ - playSFX(type, pitch = 1) { - if (!this.initialized || !this.context) return; - - const now = this.context.currentTime; - - switch (type) { - case 'laser': - this.playLaser(now, pitch); - break; - case 'missile': - this.playMissile(now, pitch); - break; - case 'explosion': - this.playExplosion(now, pitch); - break; - case 'hit': - this.playHit(now, pitch); - break; - case 'pickup': - this.playPickup(now, pitch); - break; - case 'levelup': - this.playLevelUp(now); - break; - case 'death': - this.playDeath(now); - break; - case 'boss': - this.playBossSpawn(now); - break; - case 'electric': - this.playElectric(now, pitch); - break; - default: - console.warn('Unknown sound type:', type); - } - } - - playLaser(time, pitch) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(400 * pitch, time); - osc.frequency.exponentialRampToValueAtTime(100 * pitch, time + 0.1); - - gain.gain.setValueAtTime(0.3, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.1); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.1); - } - - playMissile(time, pitch) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'square'; - osc.frequency.setValueAtTime(200 * pitch, time); - osc.frequency.linearRampToValueAtTime(150 * pitch, time + 0.15); - - gain.gain.setValueAtTime(0.2, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.15); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.15); - } - - playExplosion(time, pitch) { - const noise = this.context.createBufferSource(); - const buffer = this.context.createBuffer(1, this.context.sampleRate * 0.3, this.context.sampleRate); - const data = buffer.getChannelData(0); - - for (let i = 0; i < data.length; i++) { - data[i] = Math.random() * 2 - 1; - } - - noise.buffer = buffer; - - const filter = this.context.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(800 * pitch, time); - filter.frequency.exponentialRampToValueAtTime(50 * pitch, time + 0.3); - - const gain = this.context.createGain(); - gain.gain.setValueAtTime(0.5, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.3); - - noise.connect(filter); - filter.connect(gain); - gain.connect(this.sfxGain); - - noise.start(time); - noise.stop(time + 0.3); - } - - playHit(time, pitch) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sine'; - osc.frequency.setValueAtTime(150 * pitch, time); - osc.frequency.exponentialRampToValueAtTime(50 * pitch, time + 0.08); - - gain.gain.setValueAtTime(0.4, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.08); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.08); - } - - playPickup(time, pitch) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sine'; - osc.frequency.setValueAtTime(600 * pitch, time); - osc.frequency.exponentialRampToValueAtTime(1200 * pitch, time + 0.1); - - gain.gain.setValueAtTime(0.3, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.1); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.1); - } - - playLevelUp(time) { - for (let i = 0; i < 3; i++) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - const startTime = time + i * 0.1; - - osc.type = 'sine'; - osc.frequency.setValueAtTime(400 + i * 200, startTime); - - gain.gain.setValueAtTime(0.3, startTime); - gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.2); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(startTime); - osc.stop(startTime + 0.2); - } - } - - playDeath(time) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(400, time); - osc.frequency.exponentialRampToValueAtTime(50, time + 0.5); - - gain.gain.setValueAtTime(0.5, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.5); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.5); - } - - playBossSpawn(time) { - const osc1 = this.context.createOscillator(); - const osc2 = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc1.type = 'sawtooth'; - osc2.type = 'square'; - - osc1.frequency.setValueAtTime(100, time); - osc1.frequency.linearRampToValueAtTime(50, time + 1); - - osc2.frequency.setValueAtTime(150, time); - osc2.frequency.linearRampToValueAtTime(75, time + 1); - - gain.gain.setValueAtTime(0.4, time); - gain.gain.linearRampToValueAtTime(0.6, time + 0.5); - gain.gain.exponentialRampToValueAtTime(0.01, time + 1); - - osc1.connect(gain); - osc2.connect(gain); - gain.connect(this.sfxGain); - - osc1.start(time); - osc2.start(time); - osc1.stop(time + 1); - osc2.stop(time + 1); - } - - playElectric(time, pitch) { - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'square'; - osc.frequency.setValueAtTime(800 * pitch, time); - osc.frequency.setValueAtTime(700 * pitch, time + 0.02); - osc.frequency.setValueAtTime(850 * pitch, time + 0.04); - - gain.gain.setValueAtTime(0.3, time); - gain.gain.exponentialRampToValueAtTime(0.01, time + 0.12); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(time); - osc.stop(time + 0.12); - } - - /** - * Play critical hit sound - */ - playCrit() { - if (!this.initialized || !this.context) return; - - const now = this.context.currentTime; - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sine'; - osc.frequency.setValueAtTime(800, now); - osc.frequency.exponentialRampToValueAtTime(1200, now + 0.08); - - gain.gain.setValueAtTime(0.4, now); - gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(now); - osc.stop(now + 0.15); - } - - /** - * Play lifesteal sound - */ - playLifesteal() { - if (!this.initialized || !this.context) return; - - const now = this.context.currentTime; - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sine'; - osc.frequency.setValueAtTime(300, now); - osc.frequency.linearRampToValueAtTime(450, now + 0.12); - - gain.gain.setValueAtTime(0.2, now); - gain.gain.exponentialRampToValueAtTime(0.01, now + 0.12); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(now); - osc.stop(now + 0.12); - } - - /** - * Play boss hit sound - */ - playBossHit() { - if (!this.initialized || !this.context) return; - - const now = this.context.currentTime; - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'triangle'; - osc.frequency.setValueAtTime(120, now); - osc.frequency.exponentialRampToValueAtTime(80, now + 0.2); - - gain.gain.setValueAtTime(0.35, now); - gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(now); - osc.stop(now + 0.2); - } - - /** - * Play wave start sound - */ - playWaveStart() { - if (!this.initialized || !this.context) return; - - const now = this.context.currentTime; - const osc = this.context.createOscillator(); - const gain = this.context.createGain(); - - osc.type = 'sine'; - osc.frequency.setValueAtTime(400, now); - osc.frequency.exponentialRampToValueAtTime(600, now + 0.1); - osc.frequency.setValueAtTime(600, now + 0.1); - osc.frequency.exponentialRampToValueAtTime(800, now + 0.2); - - gain.gain.setValueAtTime(0.3, now); - gain.gain.setValueAtTime(0.3, now + 0.1); - gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3); - - osc.connect(gain); - gain.connect(this.sfxGain); - - osc.start(now); - osc.stop(now + 0.3); - } - - /** - * Start background music loop (MP3 playback) - */ - startBackgroundMusic() { - console.log('startBackgroundMusic called - initialized:', this.initialized, 'musicPlaying:', this.musicPlaying); - - if (!this.initialized || this.musicPlaying) return; - - this.musicPlaying = true; - this.playRandomMP3(); - } - - /** - * Stop background music - */ - stopBackgroundMusic() { - this.musicPlaying = false; - if (this.currentAudio) { - this.currentAudio.pause(); - this.currentAudio.currentTime = 0; - // Remove event listeners - if (this.onTrackEnded) { - this.currentAudio.removeEventListener('ended', this.onTrackEnded); - } - if (this.onTrackError) { - this.currentAudio.removeEventListener('error', this.onTrackError); - } - this.currentAudio = null; - } - if (this.musicSource) { - try { - this.musicSource.disconnect(); - } catch (e) { - // Already disconnected - } - this.musicSource = null; - } - // Reset error count - this.errorCount = 0; - } - - /** - * Play a random MP3 track - */ - playRandomMP3() { - if (!this.musicPlaying || !this.initialized) { - console.log('playRandomMP3 skipped - musicPlaying:', this.musicPlaying, 'initialized:', this.initialized); - return; - } - - console.log('Starting playRandomMP3...'); - - // Clean up previous audio element - if (this.currentAudio) { - this.currentAudio.pause(); - this.currentAudio.removeEventListener('ended', this.onTrackEnded); - this.currentAudio.removeEventListener('error', this.onTrackError); - this.currentAudio = null; - } - - // Disconnect previous source - if (this.musicSource) { - try { - this.musicSource.disconnect(); - } catch (e) { - // Already disconnected - } - this.musicSource = null; - } - - // Select a random track (avoid immediate repeat) - let randomIndex; - do { - randomIndex = Math.floor(Math.random() * this.musicTracks.length); - } while (randomIndex === this.lastPlayedIndex && this.musicTracks.length > 1); - - this.lastPlayedIndex = randomIndex; - const trackPath = this.musicTracks[randomIndex]; - - // Create new audio element - this.currentAudio = new Audio(trackPath); - this.currentAudio.volume = this.muted ? 0 : this.musicVolume; - - // Create bound event handlers for cleanup - this.onTrackEnded = () => { - if (this.musicPlaying) { - this.playRandomMP3(); - } - }; - - this.onTrackError = (e) => { - console.error('Error loading music track:', trackPath, e); - // Try next track on error (with limit to prevent infinite loop) - if (this.musicPlaying && (!this.errorCount || this.errorCount < this.musicTracks.length)) { - this.errorCount = (this.errorCount || 0) + 1; - setTimeout(() => this.playRandomMP3(), 1000); - } else { - console.error('All music tracks failed to load. Stopping music.'); - this.stopBackgroundMusic(); - this.errorCount = 0; - } - }; - - // Add event listeners - this.currentAudio.addEventListener('ended', this.onTrackEnded); - this.currentAudio.addEventListener('error', this.onTrackError); - - // Connect to Web Audio API for proper volume control (only once per element) - if (this.context && !this.currentAudio.connectedToWebAudio) { - try { - this.musicSource = this.context.createMediaElementSource(this.currentAudio); - this.musicSource.connect(this.musicGain); - this.currentAudio.connectedToWebAudio = true; - } catch (e) { - // If connection fails, just use HTML5 audio volume control - console.warn('Could not connect audio to Web Audio API:', e); - } - } - - // Start playback - this.currentAudio.play().then(() => { - // Reset error count on successful play - this.errorCount = 0; - console.log('Now playing:', trackPath); - }).catch(e => { - console.error('Error playing music:', e); - this.onTrackError(e); - }); - } - - /** - * Set music theme (kept for backward compatibility, no longer used with MP3s) - * @param {string} theme - Theme name ('calm', 'action', 'boss') - */ - setMusicTheme(theme) { - if (!['calm', 'action', 'boss'].includes(theme)) { - console.warn('Invalid music theme:', theme); - return; - } - - // Store theme for backward compatibility but don't change music - this.currentTheme = theme; - // MP3 music plays randomly regardless of theme - } - - /** - * Get melodies for the current theme (kept for backward compatibility, not used with MP3s) - * @returns {Array} Array of melody phrases - */ - getThemeMelodies() { - const noteDur = 0.2; - const longDur = noteDur * 2; - - const themes = { - calm: [ - // Calm 1: Gentle ascending - [ - { freq: 262, dur: longDur }, // C4 - { freq: 294, dur: noteDur }, // D4 - { freq: 330, dur: noteDur }, // E4 - { freq: 349, dur: longDur }, // F4 - { freq: 330, dur: noteDur }, // E4 - { freq: 294, dur: noteDur }, // D4 - { freq: 262, dur: longDur } // C4 - ], - // Calm 2: Ambient waves - [ - { freq: 392, dur: longDur }, // G4 - { freq: 349, dur: noteDur }, // F4 - { freq: 330, dur: noteDur }, // E4 - { freq: 294, dur: longDur }, // D4 - { freq: 330, dur: noteDur }, // E4 - { freq: 349, dur: noteDur }, // F4 - { freq: 392, dur: longDur } // G4 - ], - // Calm 3: Peaceful melody - [ - { freq: 294, dur: noteDur }, // D4 - { freq: 330, dur: noteDur }, // E4 - { freq: 294, dur: noteDur }, // D4 - { freq: 262, dur: longDur }, // C4 - { freq: 294, dur: noteDur }, // D4 - { freq: 330, dur: noteDur }, // E4 - { freq: 392, dur: longDur } // G4 - ], - // Calm 4: Serene flow - [ - { freq: 349, dur: longDur }, // F4 - { freq: 330, dur: noteDur }, // E4 - { freq: 294, dur: noteDur }, // D4 - { freq: 330, dur: longDur }, // E4 - { freq: 349, dur: noteDur }, // F4 - { freq: 392, dur: noteDur }, // G4 - { freq: 349, dur: longDur } // F4 - ] - ], - action: [ - // Action 1: Fast ascending arpeggio - [ - { freq: 262, dur: noteDur * 0.8 }, // C4 - { freq: 330, dur: noteDur * 0.8 }, // E4 - { freq: 392, dur: noteDur * 0.8 }, // G4 - { freq: 523, dur: noteDur * 0.8 }, // C5 - { freq: 392, dur: noteDur * 0.8 }, // G4 - { freq: 330, dur: noteDur * 0.8 }, // E4 - { freq: 294, dur: noteDur * 0.8 }, // D4 - { freq: 330, dur: noteDur * 0.8 } // E4 - ], - // Action 2: Intense rhythm - [ - { freq: 523, dur: noteDur * 0.6 }, // C5 - { freq: 494, dur: noteDur * 0.6 }, // B4 - { freq: 523, dur: noteDur * 0.6 }, // C5 - { freq: 587, dur: noteDur }, // D5 - { freq: 523, dur: noteDur * 0.6 }, // C5 - { freq: 494, dur: noteDur * 0.6 }, // B4 - { freq: 440, dur: noteDur }, // A4 - { freq: 392, dur: noteDur } // G4 - ], - // Action 3: Power chords - [ - { freq: 330, dur: noteDur * 0.7 }, // E4 - { freq: 330, dur: noteDur * 0.7 }, // E4 - { freq: 392, dur: noteDur * 0.7 }, // G4 - { freq: 440, dur: noteDur }, // A4 - { freq: 392, dur: noteDur * 0.7 }, // G4 - { freq: 392, dur: noteDur * 0.7 }, // G4 - { freq: 330, dur: noteDur * 0.7 }, // E4 - { freq: 294, dur: noteDur } // D4 - ], - // Action 4: Driving beat - [ - { freq: 440, dur: noteDur * 0.6 }, // A4 - { freq: 392, dur: noteDur * 0.6 }, // G4 - { freq: 440, dur: noteDur * 0.6 }, // A4 - { freq: 494, dur: noteDur }, // B4 - { freq: 440, dur: noteDur * 0.6 }, // A4 - { freq: 392, dur: noteDur * 0.6 }, // G4 - { freq: 330, dur: noteDur }, // E4 - { freq: 392, dur: noteDur } // G4 - ], - // Action 5: Frantic energy - [ - { freq: 587, dur: noteDur * 0.5 }, // D5 - { freq: 523, dur: noteDur * 0.5 }, // C5 - { freq: 494, dur: noteDur * 0.5 }, // B4 - { freq: 440, dur: noteDur * 0.5 }, // A4 - { freq: 494, dur: noteDur * 0.5 }, // B4 - { freq: 523, dur: noteDur * 0.5 }, // C5 - { freq: 587, dur: noteDur }, // D5 - { freq: 659, dur: noteDur } // E5 - ] - ], - boss: [ - // Boss 1: Ominous low tones - [ - { freq: 131, dur: longDur }, // C3 - { freq: 147, dur: noteDur }, // D3 - { freq: 131, dur: noteDur }, // C3 - { freq: 117, dur: longDur }, // A#2 - { freq: 131, dur: noteDur }, // C3 - { freq: 147, dur: noteDur }, // D3 - { freq: 165, dur: longDur } // E3 - ], - // Boss 2: Epic rising tension - [ - { freq: 196, dur: noteDur }, // G3 - { freq: 220, dur: noteDur }, // A3 - { freq: 247, dur: noteDur }, // B3 - { freq: 262, dur: longDur }, // C4 - { freq: 247, dur: noteDur }, // B3 - { freq: 220, dur: noteDur }, // A3 - { freq: 196, dur: longDur } // G3 - ], - // Boss 3: Dramatic - [ - { freq: 262, dur: noteDur * 0.7 }, // C4 - { freq: 233, dur: noteDur * 0.7 }, // A#3 - { freq: 196, dur: noteDur * 0.7 }, // G3 - { freq: 175, dur: longDur }, // F3 - { freq: 196, dur: noteDur * 0.7 }, // G3 - { freq: 233, dur: noteDur * 0.7 }, // A#3 - { freq: 262, dur: longDur } // C4 - ], - // Boss 4: Menacing march - [ - { freq: 147, dur: noteDur }, // D3 - { freq: 147, dur: noteDur }, // D3 - { freq: 175, dur: noteDur }, // F3 - { freq: 196, dur: longDur }, // G3 - { freq: 175, dur: noteDur }, // F3 - { freq: 147, dur: noteDur }, // D3 - { freq: 131, dur: longDur } // C3 - ], - // Boss 5: Dark power - [ - { freq: 110, dur: longDur }, // A2 - { freq: 123, dur: noteDur }, // B2 - { freq: 147, dur: noteDur }, // D3 - { freq: 165, dur: longDur }, // E3 - { freq: 147, dur: noteDur }, // D3 - { freq: 131, dur: noteDur }, // C3 - { freq: 110, dur: longDur } // A2 - ] - ] - }; - - return themes[this.currentTheme] || themes.calm; - } - - /** - * Stop all sounds - */ - stopAll() { - this.stopBackgroundMusic(); - if (this.context) { - // Web Audio nodes are automatically garbage collected when done - // No need to manually stop them - } - } -} diff --git a/js/managers/SaveManager.js b/js/managers/SaveManager.js deleted file mode 100644 index 1810068..0000000 --- a/js/managers/SaveManager.js +++ /dev/null @@ -1,317 +0,0 @@ -/** - * @file SaveManager.js - * @description Manages game save data using LocalStorage - */ - -class SaveManager { - constructor() { - this.saveKey = 'spaceInZader_save'; - this.scoreboardKey = 'spaceInZader_scoreboard'; - this.defaultSave = this.createDefaultSave(); - } - - createDefaultSave() { - return { - version: '1.0.0', - meta: { - noyaux: 0, - totalKills: 0, - totalPlaytime: 0, - runsCompleted: 0, - highestLevel: 1, - bossesDefeated: 0, - maxWave: 0, - bloodCritCount: 0 - }, - upgrades: { - maxHealth: 0, // +10 HP per level - baseDamage: 0, // +5% damage per level - xpBonus: 0, // +10% XP per level - startingWeapons: 0, // Unlock additional starting slots - rerollUnlock: false, // Unlock reroll in level-up - banishUnlock: false // Unlock banish in level-up - }, - ships: { - vampire: { unlocked: true }, - mitrailleur: { unlocked: true }, - tank: { unlocked: true }, - sniper: { unlocked: true }, - engineer: { unlocked: false }, - berserker: { unlocked: false } - }, - weapons: { - laser_frontal: { unlocked: true }, - mitraille: { unlocked: true }, - missiles_guides: { unlocked: true }, - orbes_orbitaux: { unlocked: true }, - rayon_vampirique: { unlocked: true }, - mines: { unlocked: false }, - arc_electrique: { unlocked: false }, - tourelle_drone: { unlocked: false }, - lame_tournoyante: { unlocked: true } - }, - passives: { - surchauffe: { unlocked: true }, - radiateur: { unlocked: true }, - sang_froid: { unlocked: true }, - coeur_noir: { unlocked: false }, - bobines_tesla: { unlocked: false }, - focaliseur: { unlocked: false }, - mag_tractor: { unlocked: false }, - plating: { unlocked: true }, - reacteur: { unlocked: true }, - chance: { unlocked: false } - }, - settings: { - musicVolume: 0.5, - sfxVolume: 0.7, - showFPS: false - }, - achievements: [] - }; - } - - load() { - try { - const saved = localStorage.getItem(this.saveKey); - if (saved) { - const data = JSON.parse(saved); - // Merge with defaults for new fields - const merged = this.mergeSaveData(this.defaultSave, data); - // Auto-add missing weapons and passives from data files - this.ensureAllContentExists(merged); - return merged; - } - } catch (e) { - console.error('Error loading save:', e); - } - const defaultSave = this.createDefaultSave(); - this.ensureAllContentExists(defaultSave); - return defaultSave; - } - - /** - * Ensure all weapons and passives from data files exist in save - * Auto-unlocks based on rarity to make all content accessible - */ - ensureAllContentExists(saveData) { - // Ensure weapons object exists - if (!saveData.weapons) { - saveData.weapons = {}; - } - - // Add any missing weapons from WeaponData - if (typeof WeaponData !== 'undefined' && WeaponData.WEAPONS) { - for (const weaponKey in WeaponData.WEAPONS) { - const weapon = WeaponData.WEAPONS[weaponKey]; - if (!saveData.weapons[weapon.id]) { - // Auto-unlock all content for dev-friendly experience - // Common/Uncommon always unlocked, Rare/Epic/Legendary also unlocked for testing - saveData.weapons[weapon.id] = { unlocked: true }; - console.log(`SaveManager: Auto-added weapon ${weapon.id}`); - } - } - } - - // Ensure passives object exists - if (!saveData.passives) { - saveData.passives = {}; - } - - // Add any missing passives from PassiveData - if (typeof PassiveData !== 'undefined' && PassiveData.PASSIVES) { - for (const passiveKey in PassiveData.PASSIVES) { - const passive = PassiveData.PASSIVES[passiveKey]; - if (!saveData.passives[passive.id]) { - // Auto-unlock all content for dev-friendly experience - saveData.passives[passive.id] = { unlocked: true }; - console.log(`SaveManager: Auto-added passive ${passive.id}`); - } - } - } - } - - save(data) { - try { - localStorage.setItem(this.saveKey, JSON.stringify(data)); - return true; - } catch (e) { - console.error('Error saving:', e); - return false; - } - } - - mergeSaveData(defaults, saved) { - const merged = { ...defaults }; - - for (const key in saved) { - if (typeof saved[key] === 'object' && !Array.isArray(saved[key])) { - merged[key] = { ...defaults[key], ...saved[key] }; - } else { - merged[key] = saved[key]; - } - } - - return merged; - } - - addNoyaux(amount, saveData) { - saveData.meta.noyaux += amount; - this.save(saveData); - } - - purchaseUpgrade(upgradeType, upgradeName, cost, saveData) { - if (saveData.meta.noyaux < cost) { - return false; - } - - saveData.meta.noyaux -= cost; - - if (upgradeType === 'upgrades') { - if (typeof saveData.upgrades[upgradeName] === 'number') { - saveData.upgrades[upgradeName]++; - } else { - saveData.upgrades[upgradeName] = true; - } - } else if (upgradeType === 'ships' || upgradeType === 'weapons' || upgradeType === 'passives') { - saveData[upgradeType][upgradeName].unlocked = true; - } - - this.save(saveData); - return true; - } - - updateStats(gameStats, saveData) { - saveData.meta.totalKills += gameStats.kills; - saveData.meta.totalPlaytime += gameStats.time; - saveData.meta.runsCompleted++; - saveData.meta.highestLevel = Math.max(saveData.meta.highestLevel, gameStats.highestLevel); - saveData.meta.maxWave = Math.max(saveData.meta.maxWave || 0, gameStats.wave || 0); - saveData.meta.bossesDefeated += gameStats.bossKills || 0; - saveData.meta.bloodCritCount = Math.max(saveData.meta.bloodCritCount || 0, gameStats.bloodCritCount || 0); - - this.save(saveData); - } - - reset() { - const confirmed = confirm('Are you sure you want to reset all progress? This cannot be undone.'); - if (confirmed) { - localStorage.removeItem(this.saveKey); - return this.createDefaultSave(); - } - return null; - } - - export() { - const data = this.load(); - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `spaceInZader_save_${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - } - - import(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - try { - const data = JSON.parse(e.target.result); - this.save(data); - resolve(data); - } catch (error) { - reject(error); - } - }; - reader.readAsText(file); - }); - } - - /** - * Add a score entry to the scoreboard - * @param {Object} entry - Score entry - * @param {number} entry.kills - Number of kills - * @param {number} entry.time - Survival time in seconds - * @param {number} entry.wave - Wave reached - * @param {string} entry.class - Ship class used - * @param {number} entry.bossKills - Number of bosses killed - * @param {Array} entry.weapons - Weapons used - * @param {Array} entry.passives - Passives acquired - */ - addScoreEntry(entry) { - try { - const score = this.calculateScore(entry); - const scoreEntry = { - score, - kills: entry.kills, - time: entry.time, - wave: entry.wave, - class: entry.class, - date: new Date().toISOString(), - bossKills: entry.bossKills || 0, - weapons: entry.weapons || [], - passives: entry.passives || [] - }; - - const scoreboard = this.getTopScores(100); // Keep top 100 - scoreboard.push(scoreEntry); - scoreboard.sort((a, b) => b.score - a.score); - - // Keep only top 100 - const top100 = scoreboard.slice(0, 100); - localStorage.setItem(this.scoreboardKey, JSON.stringify(top100)); - - return scoreboard.indexOf(scoreEntry) + 1; // Return rank (1-based) - } catch (e) { - console.error('Error adding score entry:', e); - return -1; - } - } - - /** - * Calculate score from run stats - * @param {Object} stats - Run statistics - * @returns {number} Calculated score - */ - calculateScore(stats) { - return Math.floor( - stats.kills * 10 + - stats.wave * 500 + - Math.floor(stats.time) + - (stats.bossKills || 0) * 2000 - ); - } - - /** - * Get top scores - * @param {number} limit - Number of scores to return - * @returns {Array} Array of score entries - */ - getTopScores(limit = 10) { - try { - const saved = localStorage.getItem(this.scoreboardKey); - if (saved) { - const scores = JSON.parse(saved); - return scores.slice(0, limit); - } - } catch (e) { - console.error('Error loading scoreboard:', e); - } - return []; - } - - /** - * Clear all scores from scoreboard - */ - clearScoreboard() { - try { - localStorage.removeItem(this.scoreboardKey); - return true; - } catch (e) { - console.error('Error clearing scoreboard:', e); - return false; - } - } -} diff --git a/js/managers/ScoreManager.js b/js/managers/ScoreManager.js deleted file mode 100644 index 11d2c52..0000000 --- a/js/managers/ScoreManager.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @file ScoreManager.js - * @description Manages scoreboard and high score persistence - */ - -class ScoreManager { - constructor() { - this.scores = []; - this.maxScores = 10; // Top 10 scores - this.storageKey = 'space_invader_scores'; - this.load(); - } - - /** - * Load scores from localStorage - */ - load() { - try { - const data = localStorage.getItem(this.storageKey); - if (data) { - this.scores = JSON.parse(data); - console.log('ScoreManager: Loaded', this.scores.length, 'scores'); - } else { - this.scores = []; - } - } catch (error) { - console.error('ScoreManager: Error loading scores:', error); - this.scores = []; - } - } - - /** - * Save scores to localStorage - */ - save() { - try { - localStorage.setItem(this.storageKey, JSON.stringify(this.scores)); - console.log('ScoreManager: Saved', this.scores.length, 'scores'); - } catch (error) { - console.error('ScoreManager: Error saving scores:', error); - } - } - - /** - * Add a new score entry - * @param {Object} scoreData - Score data - * @param {string} scoreData.playerName - Player name - * @param {number} scoreData.score - Final score - * @param {number} scoreData.time - Survival time in seconds - * @param {number} scoreData.kills - Total kills - * @param {number} scoreData.level - Highest level reached - * @param {number} scoreData.wave - Highest wave reached - * @returns {number} Rank (1-10, or 0 if not in top 10) - */ - addScore(scoreData) { - const entry = { - playerName: scoreData.playerName || 'Anonyme', - score: scoreData.score || 0, - time: scoreData.time || 0, - kills: scoreData.kills || 0, - level: scoreData.level || 1, - wave: scoreData.wave || 1, - date: new Date().toISOString(), - timestamp: Date.now() - }; - - this.scores.push(entry); - - // Sort by score (descending) - this.scores.sort((a, b) => b.score - a.score); - - // Keep only top 10 - this.scores = this.scores.slice(0, this.maxScores); - - this.save(); - - // Find rank of this entry - const rank = this.scores.findIndex(s => s.timestamp === entry.timestamp); - return rank >= 0 ? rank + 1 : 0; - } - - /** - * Get top scores - * @param {number} count - Number of scores to retrieve (default: 10) - * @returns {Array} Array of score entries - */ - getTopScores(count = 10) { - return this.scores.slice(0, count); - } - - /** - * Check if a score qualifies for the leaderboard - * @param {number} score - Score to check - * @returns {boolean} True if score makes top 10 - */ - qualifiesForLeaderboard(score) { - if (this.scores.length < this.maxScores) { - return true; - } - const lowestScore = this.scores[this.scores.length - 1].score; - return score > lowestScore; - } - - /** - * Clear all scores (for reset/debug) - */ - clearScores() { - this.scores = []; - this.save(); - console.log('ScoreManager: All scores cleared'); - } - - /** - * Format time for display - * @param {number} seconds - Time in seconds - * @returns {string} Formatted time string - */ - static formatTime(seconds) { - const minutes = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${minutes}:${secs.toString().padStart(2, '0')}`; - } - - /** - * Format date for display - * @param {string} isoString - ISO date string - * @returns {string} Formatted date - */ - static formatDate(isoString) { - const date = new Date(isoString); - return date.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }); - } -} diff --git a/js/systems/AISystem.js b/js/systems/AISystem.js deleted file mode 100644 index e3470ee..0000000 --- a/js/systems/AISystem.js +++ /dev/null @@ -1,728 +0,0 @@ -/** - * @file AISystem.js - * @description Enemy AI behaviors, movement patterns, and attack logic - */ - -class AISystem { - constructor(world, gameState) { - this.world = world; - this.gameState = gameState; - } - - /** - * Update all enemy AI behaviors - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - const enemies = this.world.getEntitiesByType('enemy'); - const player = this.world.getEntitiesByType('player')[0]; - - if (!player) return; - - for (const enemy of enemies) { - this.updateEnemyAI(enemy, player, deltaTime); - } - } - - /** - * Update individual enemy AI - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - updateEnemyAI(enemy, player, deltaTime) { - const enemyComp = enemy.getComponent('enemy'); - if (!enemyComp) return; - - const aiType = enemyComp.aiType; - - // Update attack cooldown - if (enemyComp.attackCooldown > 0) { - enemyComp.attackCooldown -= deltaTime; - } - - // Execute AI behavior - switch (aiType) { - case 'chase': - this.chaseAI(enemy, player, deltaTime); - break; - case 'weave': - this.weaveAI(enemy, player, deltaTime); - break; - case 'kite': - this.kiteAI(enemy, player, deltaTime); - break; - case 'aggressive': - this.aggressiveAI(enemy, player, deltaTime); - break; - case 'boss': - this.bossAI(enemy, player, deltaTime); - break; - case 'kamikaze': - this.kamikazeAI(enemy, player, deltaTime); - break; - case 'stationary': - this.stationaryAI(enemy, player, deltaTime); - break; - default: - this.chaseAI(enemy, player, deltaTime); - } - - // Handle attacks - this.handleEnemyAttack(enemy, player, deltaTime); - } - - /** - * Chase AI - Direct pursuit of player - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - chaseAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - // Calculate direction to player - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - const normalized = MathUtils.normalize(dx, dy); - - // Move towards player - enemyPos.x += normalized.x * enemyComp.speed * deltaTime; - enemyPos.y += normalized.y * enemyComp.speed * deltaTime; - - // Update rotation - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.rotation = Math.atan2(dy, dx); - } - } - - /** - * Weave AI - Zigzag movement towards player - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - weaveAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - // Initialize weave time if not exists - if (enemyComp.weaveTime === undefined) { - enemyComp.weaveTime = 0; - } - enemyComp.weaveTime += deltaTime; - - // Calculate direction to player - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - const angle = Math.atan2(dy, dx); - - // Add sine wave perpendicular to direction - const weaveAmplitude = 50; - const weaveFrequency = 3; - const weaveOffset = Math.sin(enemyComp.weaveTime * weaveFrequency) * weaveAmplitude; - - // Calculate perpendicular direction - const perpAngle = angle + Math.PI / 2; - const weaveX = Math.cos(perpAngle) * weaveOffset * deltaTime; - const weaveY = Math.sin(perpAngle) * weaveOffset * deltaTime; - - // Move towards player with weave - const normalized = MathUtils.normalize(dx, dy); - enemyPos.x += normalized.x * enemyComp.speed * deltaTime + weaveX; - enemyPos.y += normalized.y * enemyComp.speed * deltaTime + weaveY; - - // Update rotation - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.rotation = angle; - } - } - - /** - * Kite AI - Maintain distance and shoot - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - kiteAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - const minDistance = 200; - const maxDistance = 350; - - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - let moveX = 0; - let moveY = 0; - - if (distance < minDistance) { - // Too close - move away - const normalized = MathUtils.normalize(-dx, -dy); - moveX = normalized.x * enemyComp.speed * deltaTime; - moveY = normalized.y * enemyComp.speed * deltaTime; - } else if (distance > maxDistance) { - // Too far - move closer - const normalized = MathUtils.normalize(dx, dy); - moveX = normalized.x * enemyComp.speed * deltaTime; - moveY = normalized.y * enemyComp.speed * deltaTime; - } else { - // In range - strafe - if (enemyComp.strafeTime === undefined) { - enemyComp.strafeTime = 0; - enemyComp.strafeDirection = Math.random() < 0.5 ? -1 : 1; - } - - enemyComp.strafeTime += deltaTime; - if (enemyComp.strafeTime > 2.0) { - enemyComp.strafeTime = 0; - enemyComp.strafeDirection *= -1; - } - - const angle = Math.atan2(dy, dx); - const perpAngle = angle + Math.PI / 2; - moveX = Math.cos(perpAngle) * enemyComp.strafeDirection * enemyComp.speed * deltaTime; - moveY = Math.sin(perpAngle) * enemyComp.strafeDirection * enemyComp.speed * deltaTime; - } - - enemyPos.x += moveX; - enemyPos.y += moveY; - - // Always face player - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.rotation = Math.atan2(dy, dx); - } - } - - /** - * Aggressive AI - Fast pursuit with prediction - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - aggressiveAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - // Get player velocity for prediction - const playerVel = player.getComponent('velocity') || { vx: 0, vy: 0 }; - - // Predict player position - const predictionTime = 0.5; - const predictedX = playerPos.x + playerVel.vx * predictionTime; - const predictedY = playerPos.y + playerVel.vy * predictionTime; - - // Calculate direction to predicted position - const dx = predictedX - enemyPos.x; - const dy = predictedY - enemyPos.y; - const normalized = MathUtils.normalize(dx, dy); - - // Move aggressively - const speedMultiplier = 1.2; - enemyPos.x += normalized.x * enemyComp.speed * speedMultiplier * deltaTime; - enemyPos.y += normalized.y * enemyComp.speed * speedMultiplier * deltaTime; - - // Update rotation - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.rotation = Math.atan2(dy, dx); - } - } - - /** - * Boss AI - Multi-phase attack patterns with lasers and bullets - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - bossAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - const health = enemy.getComponent('health'); - const boss = enemy.getComponent('boss'); - - if (!enemyPos || !playerPos || !enemyComp || !health || !boss) return; - - // Initialize boss-specific timers if needed - if (!boss.burstCooldown) boss.burstCooldown = 0; - if (!boss.laserCooldown) boss.laserCooldown = 0; - if (!boss.telegraphTimer) boss.telegraphTimer = 0; - if (!boss.minionCooldown) boss.minionCooldown = 0; - if (!boss.isEnraged) boss.isEnraged = false; - - // Update cooldowns - boss.burstCooldown -= deltaTime; - boss.laserCooldown -= deltaTime; - boss.telegraphTimer -= deltaTime; - boss.minionCooldown -= deltaTime; - - // Determine current phase based on health (60% threshold) - const healthPercent = health.current / health.max; - const wasEnraged = boss.isEnraged; - boss.isEnraged = healthPercent <= 0.6; - - // Flash effect when entering enraged phase - if (boss.isEnraged && !wasEnraged) { - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.flash = 1.0; - } - // Play warning sound - if (window.game && window.game.audioManager) { - window.game.audioManager.playSFX('electric', 0.5, 0.8); - } - } - - // Phase-based cooldowns - const burstInterval = boss.isEnraged ? 1.5 : 2.5; - const laserInterval = boss.isEnraged ? 2.5 : 4.0; - const minionInterval = 5.0; - - // Movement behavior - if (healthPercent > 0.6) { - // Phase 1 - Moderate kiting - this.kiteAI(enemy, player, deltaTime); - } else { - // Phase 2 - Aggressive movement - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > 250) { - // Charge at player - const normalized = MathUtils.normalize(dx, dy); - const speedMult = 1.3; - enemyPos.x += normalized.x * enemyComp.speed * speedMult * deltaTime; - enemyPos.y += normalized.y * enemyComp.speed * speedMult * deltaTime; - } else { - // Retreat and attack - const normalized = MathUtils.normalize(-dx, -dy); - enemyPos.x += normalized.x * enemyComp.speed * 0.8 * deltaTime; - enemyPos.y += normalized.y * enemyComp.speed * 0.8 * deltaTime; - } - } - - // Pattern A: Burst Bullets (12-projectile fan spread) - if (boss.burstCooldown <= 0) { - // Telegraph attack - boss.telegraphTimer = 0.5; - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.flash = 0.8; - } - // Play telegraph sound - if (window.game && window.game.audioManager) { - window.game.audioManager.playSFX('electric', 0.3, 1.2); - } - - // Schedule attack after telegraph - setTimeout(() => { - this.bossBurstBullets(enemy, player); - }, 500); - - boss.burstCooldown = burstInterval; - } - - // Pattern B: Laser Sweep - if (boss.laserCooldown <= 0) { - // Telegraph attack - boss.telegraphTimer = 0.5; - const renderable = enemy.getComponent('renderable'); - if (renderable) { - renderable.flash = 1.0; - } - // Play telegraph sound - if (window.game && window.game.audioManager) { - window.game.audioManager.playSFX('laser', 0.4, 0.7); - } - - // Schedule attack after telegraph - setTimeout(() => { - this.bossLaserSweep(enemy); - }, 500); - - boss.laserCooldown = laserInterval; - } - - // Phase 2 only: Spawn minions - if (boss.isEnraged && boss.minionCooldown <= 0) { - this.bossSpawnMinions(enemy); - boss.minionCooldown = minionInterval; - } - - // Update rotation to face player - const renderable = enemy.getComponent('renderable'); - if (renderable) { - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - renderable.rotation = Math.atan2(dy, dx); - } - } - - /** - * Boss burst bullet pattern - 12 projectiles in fan - * @param {Entity} enemy - Boss entity - * @param {Entity} player - Player entity - */ - bossBurstBullets(enemy, player) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern || {}; - const baseAngle = MathUtils.angle(enemyPos.x, enemyPos.y, playerPos.x, playerPos.y); - const spreadAngle = Math.PI / 4; // 45 degrees - const count = 12; - - for (let i = 0; i < count; i++) { - const offset = (i - (count - 1) / 2) * (spreadAngle / (count - 1)); - const angle = baseAngle + offset; - - this.createEnemyProjectile( - enemyPos.x, - enemyPos.y, - angle, - attackPattern.damage || 30, - attackPattern.projectileSpeed || 350, - 5.0, - enemy.id, - '#FF00FF' // Magenta - ); - } - } - - /** - * Boss laser sweep pattern - rotating beam - * @param {Entity} enemy - Boss entity - */ - bossLaserSweep(enemy) { - const enemyPos = enemy.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern || {}; - const startAngle = Math.random() * Math.PI * 2; // Random starting angle - - // Create laser as a special projectile - const laser = this.createEnemyProjectile( - enemyPos.x, - enemyPos.y, - startAngle, - 10, // Damage per tick - 0, // No forward movement - 1.5, // Duration 1.5 seconds - enemy.id, - '#FF0000', // Red - 'laser' // Special type - ); - - // Mark as laser and set properties - if (laser) { - const projComp = laser.getComponent('projectile'); - if (projComp) { - projComp.isLaser = true; - projComp.laserLength = 600; - projComp.laserRotationSpeed = 2.0; // radians per second - projComp.damageTick = 0.1; // Damage every 0.1s - projComp.lastDamageTick = 0; - projComp.ownerPos = { x: enemyPos.x, y: enemyPos.y }; // Stay at boss position - } - - // Make it look like a laser - const renderable = laser.getComponent('renderable'); - if (renderable) { - renderable.shape = 'line'; - renderable.size = 600; // Length - renderable.shadowBlur = 20; - } - } - } - - /** - * Boss spawn minion enemies - * @param {Entity} enemy - Boss entity - */ - bossSpawnMinions(enemy) { - const enemyPos = enemy.getComponent('position'); - if (!enemyPos) return; - - // Spawn 2 elite enemies - const spawnOffsets = [ - { x: 60, y: 0 }, - { x: -60, y: 0 } - ]; - - for (const offset of spawnOffsets) { - const spawnX = enemyPos.x + offset.x; - const spawnY = enemyPos.y + offset.y; - - if (window.game && window.game.spawner) { - window.game.spawner.spawnEnemyAtPosition('elite', spawnX, spawnY); - } - } - } - - /** - * Boss shooting patterns - * @param {Entity} enemy - Boss enemy entity - * @param {Entity} player - Player entity - * @param {string} pattern - Pattern type - */ - bossShootPattern(enemy, player, pattern) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern || {}; - - switch (pattern) { - case 'single': - this.shootAtPlayer(enemy, player); - break; - case 'spiral': - this.shootSpiral(enemy, 8); - break; - case 'spread': - this.shootSpread(enemy, player, 5); - break; - } - } - - /** - * Handle enemy attacks - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - handleEnemyAttack(enemy, player, deltaTime) { - const enemyComp = enemy.getComponent('enemy'); - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - - if (!enemyComp || !enemyPos || !playerPos) return; - - const attackPattern = enemyComp.attackPattern; - if (!attackPattern || attackPattern.type === 'none') return; - - // Check if can attack - if (enemyComp.attackCooldown > 0) return; - - const distance = MathUtils.distance(enemyPos.x, enemyPos.y, playerPos.x, playerPos.y); - const range = attackPattern.range || 300; - - if (distance <= range) { - if (attackPattern.type === 'shoot') { - this.shootAtPlayer(enemy, player); - } else if (attackPattern.type === 'special') { - this.shootSpread(enemy, player, 3); - } - - enemyComp.attackCooldown = attackPattern.cooldown || 2.0; - } - } - - /** - * Shoot projectile at player - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - */ - shootAtPlayer(enemy, player) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern; - const angle = MathUtils.angle(enemyPos.x, enemyPos.y, playerPos.x, playerPos.y); - - this.createEnemyProjectile( - enemyPos.x, - enemyPos.y, - angle, - attackPattern.damage || 10, - attackPattern.projectileSpeed || 250, - 5.0, - enemy.id, - attackPattern.projectileColor || '#FF0000' - ); - } - - /** - * Shoot spread of projectiles at player - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} count - Number of projectiles - */ - shootSpread(enemy, player, count) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern; - const baseAngle = MathUtils.angle(enemyPos.x, enemyPos.y, playerPos.x, playerPos.y); - const spreadAngle = Math.PI / 4; - - for (let i = 0; i < count; i++) { - const offset = (i - (count - 1) / 2) * (spreadAngle / count); - const angle = baseAngle + offset; - - this.createEnemyProjectile( - enemyPos.x, - enemyPos.y, - angle, - attackPattern.damage || 10, - attackPattern.projectileSpeed || 250, - 5.0, - enemy.id, - attackPattern.projectileColor || '#FF0000' - ); - } - } - - /** - * Kamikaze AI - Rush directly at player for suicide attack - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - kamikazeAI(enemy, player, deltaTime) { - const enemyPos = enemy.getComponent('position'); - const playerPos = player.getComponent('position'); - const enemyVel = enemy.getComponent('velocity'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !playerPos || !enemyVel || !enemyComp) return; - - // Kamikaze behavior constants - const KAMIKAZE_BOOST_DISTANCE = 300; - const KAMIKAZE_BOOST_MULTIPLIER = 0.5; - - // Calculate direction to player - const dx = playerPos.x - enemyPos.x; - const dy = playerPos.y - enemyPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > 0) { - // Speed boost as it gets closer (more aggressive) - const speedBoost = 1 + (1 - Math.min(distance / KAMIKAZE_BOOST_DISTANCE, 1)) * KAMIKAZE_BOOST_MULTIPLIER; - const speed = enemyComp.speed * speedBoost; - - // Direct charge at player - enemyVel.vx = (dx / distance) * speed; - enemyVel.vy = (dy / distance) * speed; - } - } - - /** - * Stationary AI - Stays in place and shoots - * @param {Entity} enemy - Enemy entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - stationaryAI(enemy, player, deltaTime) { - const enemyVel = enemy.getComponent('velocity'); - - if (!enemyVel) return; - - // Don't move - enemyVel.vx = 0; - enemyVel.vy = 0; - - // Face towards player (rotation handled by attack system) - } - - /** - * Shoot spiral pattern - * @param {Entity} enemy - Enemy entity - * @param {number} count - Number of projectiles - */ - shootSpiral(enemy, count) { - const enemyPos = enemy.getComponent('position'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !enemyComp) return; - - const attackPattern = enemyComp.attackPattern; - - // Initialize spiral rotation if not exists - if (enemyComp.spiralRotation === undefined) { - enemyComp.spiralRotation = 0; - } - enemyComp.spiralRotation += 0.5; - - for (let i = 0; i < count; i++) { - const angle = (i / count) * Math.PI * 2 + enemyComp.spiralRotation; - - this.createEnemyProjectile( - enemyPos.x, - enemyPos.y, - angle, - attackPattern.damage || 10, - attackPattern.projectileSpeed || 250, - 5.0, - enemy.id, - attackPattern.projectileColor || '#FF00FF' - ); - } - } - - /** - * Create enemy projectile - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} angle - Direction angle - * @param {number} damage - Damage value - * @param {number} speed - Projectile speed - * @param {number} lifetime - Lifetime in seconds - * @param {number} owner - Owner entity ID - * @param {string} color - Projectile color - * @returns {Entity} Created projectile - */ - createEnemyProjectile(x, y, angle, damage, speed, lifetime, owner, color, type = 'normal') { - const projectile = this.world.createEntity('projectile'); - - projectile.addComponent('position', Components.Position(x, y)); - projectile.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - projectile.addComponent('collision', Components.Collision(6)); - projectile.addComponent('renderable', Components.Renderable(color, 6, 'circle')); - projectile.addComponent('projectile', Components.Projectile( - damage, - speed, - lifetime, - owner, - 'enemy_shot' - )); - - return projectile; - } -} diff --git a/js/systems/CollisionSystem.js b/js/systems/CollisionSystem.js deleted file mode 100644 index d0f8907..0000000 --- a/js/systems/CollisionSystem.js +++ /dev/null @@ -1,833 +0,0 @@ -/** - * @file CollisionSystem.js - * @description Handles collision detection between entities - */ - -class CollisionSystem { - constructor(world, gameState, audioManager, particleSystem = null) { - this.world = world; - this.gameState = gameState; - this.audioManager = audioManager; - this.particleSystem = particleSystem; - - // Black hole instant kill zone constants - this.BLACK_HOLE_CENTER_KILL_RADIUS = 30; // pixels - instant death zone - this.BLACK_HOLE_DEATH_SHAKE_INTENSITY = 15; - this.BLACK_HOLE_DEATH_SHAKE_DURATION = 0.5; - this.BLACK_HOLE_DEATH_FLASH_COLOR = '#9400D3'; // Purple - this.BLACK_HOLE_DEATH_FLASH_INTENSITY = 0.5; - this.BLACK_HOLE_DEATH_FLASH_DURATION = 0.5; - } - - update(deltaTime) { - // Update orbital projectile hit cooldowns - const projectiles = this.world.getEntitiesByType('projectile'); - for (const projectile of projectiles) { - const projComp = projectile.getComponent('projectile'); - if (projComp && projComp.orbital && projComp.hitCooldown) { - for (const enemyId in projComp.hitCooldown) { - projComp.hitCooldown[enemyId] -= deltaTime; - if (projComp.hitCooldown[enemyId] <= 0) { - delete projComp.hitCooldown[enemyId]; - } - } - } - } - - // Check projectile-enemy collisions - this.checkProjectileEnemyCollisions(); - - // Check player-enemy collisions - this.checkPlayerEnemyCollisions(); - - // Check player-pickup collisions - this.checkPlayerPickupCollisions(); - - // Check player-enemy projectile collisions - this.checkPlayerProjectileCollisions(); - - // Check weather hazard collisions - this.checkWeatherHazardCollisions(deltaTime); - } - - checkProjectileEnemyCollisions() { - const projectiles = this.world.getEntitiesByType('projectile'); - const enemies = this.world.getEntitiesByType('enemy'); - - for (const projectile of projectiles) { - const projPos = projectile.getComponent('position'); - const projCol = projectile.getComponent('collision'); - const projComp = projectile.getComponent('projectile'); - - if (!projPos || !projCol || !projComp) continue; - - // Check if projectile is from player (owner is a player entity) - const ownerEntity = this.world.getEntity(projComp.owner); - if (!ownerEntity || ownerEntity.type !== 'player') continue; - - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - const enemyCol = enemy.getComponent('collision'); - const enemyHealth = enemy.getComponent('health'); - - if (!enemyPos || !enemyCol || !enemyHealth) continue; - - // Skip if orbital projectile is on cooldown for this enemy - if (projComp.orbital && projComp.hitCooldown && projComp.hitCooldown[enemy.id] > 0) { - continue; - } - - if (MathUtils.circleCollision( - projPos.x, projPos.y, projCol.radius, - enemyPos.x, enemyPos.y, enemyCol.radius - )) { - // Deal damage to enemy (pass owner entity for lifesteal) - this.damageEnemy(enemy, projComp.damage, ownerEntity); - - // Don't remove orbital projectiles - they persist and keep damaging - if (projComp.orbital) { - // Orbital projectiles keep rotating but add a brief hit cooldown - if (!projComp.hitCooldown) projComp.hitCooldown = {}; - projComp.hitCooldown[enemy.id] = 0.15; // 150ms cooldown per enemy - continue; // Check other enemies - } - - // Remove projectile if not piercing - if (projComp.piercing <= 0) { - this.world.removeEntity(projectile.id); - } else { - projComp.piercing--; - } - - break; - } - } - } - } - - checkPlayerEnemyCollisions() { - const players = this.world.getEntitiesByType('player'); - const enemies = this.world.getEntitiesByType('enemy'); - - for (const player of players) { - const playerPos = player.getComponent('position'); - const playerCol = player.getComponent('collision'); - const playerHealth = player.getComponent('health'); - - if (!playerPos || !playerCol || !playerHealth) continue; - if (playerHealth.invulnerable || playerHealth.godMode) continue; - - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - const enemyCol = enemy.getComponent('collision'); - const enemyComp = enemy.getComponent('enemy'); - - if (!enemyPos || !enemyCol || !enemyComp) continue; - - if (MathUtils.circleCollision( - playerPos.x, playerPos.y, playerCol.radius, - enemyPos.x, enemyPos.y, enemyCol.radius - )) { - // Deal damage to player - this.damagePlayer(player, enemyComp.damage); - - // Add invulnerability frames - playerHealth.invulnerable = true; - playerHealth.invulnerableTime = 0.5; // 0.5 seconds - } - } - } - } - - checkPlayerPickupCollisions() { - const players = this.world.getEntitiesByType('player'); - const pickups = this.world.getEntitiesByType('pickup'); - - for (const player of players) { - const playerPos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - - if (!playerPos || !playerComp) continue; - - for (const pickup of pickups) { - const pickupPos = pickup.getComponent('position'); - const pickupComp = pickup.getComponent('pickup'); - - if (!pickupPos || !pickupComp || pickupComp.collected) continue; - - // Check if in magnet range - const dist = MathUtils.distance( - playerPos.x, playerPos.y, - pickupPos.x, pickupPos.y - ); - - if (dist < pickupComp.magnetRange) { - // Move pickup towards player - const angle = MathUtils.angle(pickupPos.x, pickupPos.y, playerPos.x, playerPos.y); - const speed = 400; - const vel = pickup.getComponent('velocity'); - if (vel) { - vel.vx = Math.cos(angle) * speed; - vel.vy = Math.sin(angle) * speed; - } - } - - // Check if pickup is collected - if (dist < 30) { - this.collectPickup(player, pickup); - } - } - } - } - - checkPlayerProjectileCollisions() { - const players = this.world.getEntitiesByType('player'); - const projectiles = this.world.getEntitiesByType('projectile'); - - for (const player of players) { - const playerPos = player.getComponent('position'); - const playerCol = player.getComponent('collision'); - const playerHealth = player.getComponent('health'); - - if (!playerPos || !playerCol || !playerHealth) continue; - if (playerHealth.invulnerable || playerHealth.godMode) continue; - - for (const projectile of projectiles) { - const projPos = projectile.getComponent('position'); - const projCol = projectile.getComponent('collision'); - const projComp = projectile.getComponent('projectile'); - - if (!projPos || !projCol || !projComp) continue; - - // Check if projectile is from enemy (owner is an enemy entity) - const ownerEntity = this.world.getEntity(projComp.owner); - if (!ownerEntity || ownerEntity.type !== 'enemy') continue; - - if (MathUtils.circleCollision( - playerPos.x, playerPos.y, playerCol.radius, - projPos.x, projPos.y, projCol.radius - )) { - // Deal damage to player - this.damagePlayer(player, projComp.damage); - - // Remove projectile - this.world.removeEntity(projectile.id); - - // Add invulnerability frames - playerHealth.invulnerable = true; - playerHealth.invulnerableTime = 0.3; - } - } - } - } - - damageEnemy(enemy, damage, attacker = null) { - const health = enemy.getComponent('health'); - const renderable = enemy.getComponent('renderable'); - if (!health) return; - - health.current -= damage; - this.gameState.stats.damageDealt += damage; - - // Check if this is a boss (large size) - const isBoss = renderable && renderable.size >= BOSS_SIZE_THRESHOLD; - - // Apply lifesteal if attacker is player - if (attacker && attacker.type === 'player') { - const playerComp = attacker.getComponent('player'); - const playerHealth = attacker.getComponent('health'); - - if (playerComp && playerHealth && playerComp.stats.lifesteal > 0) { - const healAmount = damage * playerComp.stats.lifesteal; - const newHealth = Math.min(playerHealth.max, playerHealth.current + healAmount); - if (newHealth > playerHealth.current) { - playerHealth.current = newHealth; - logger.debug('Combat', `Lifesteal healed ${healAmount.toFixed(1)} HP`); - - // Lifesteal sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playLifesteal(); - } - } - } - } - - // Play hit sound - if (this.audioManager && this.audioManager.initialized) { - if (isBoss) { - this.audioManager.playBossHit(); - - // Boss hit screen shake - if (this.screenEffects) { - this.screenEffects.shake(3, 0.1); - this.screenEffects.flash('#FFFFFF', 0.15, 0.08); - } - } else { - this.audioManager.playSFX('hit', MathUtils.randomFloat(0.9, 1.1)); - } - } - - if (health.current <= 0) { - this.killEnemy(enemy); - } - } - - damagePlayer(player, damage) { - const health = player.getComponent('health'); - const shield = player.getComponent('shield'); - const playerComp = player.getComponent('player'); - - if (!health || !playerComp) return; - - // God mode check - no damage taken - if (health.godMode) return; - - let remainingDamage = damage; - - // Shield absorbs damage first - if (shield && shield.current > 0) { - const shieldDamage = Math.min(shield.current, remainingDamage); - shield.current -= shieldDamage; - remainingDamage -= shieldDamage; - - // Reset shield regen delay when damaged - shield.regenDelay = shield.regenDelayMax; - - // Visual feedback for shield hit - if (this.screenEffects && shieldDamage > 0) { - this.screenEffects.flash('#00FFFF', 0.2, 0.1); - } - } - - // Remaining damage goes to health (with armor reduction) - if (remainingDamage > 0) { - const actualDamage = Math.max(1, remainingDamage - playerComp.stats.armor); - health.current -= actualDamage; - this.gameState.stats.damageTaken += actualDamage; - - // Play hit sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('hit', 1.2); - } - - // Screen shake and flash on health hit - if (this.screenEffects) { - this.screenEffects.shake(5, 0.2); - this.screenEffects.flash('#FF0000', 0.3, 0.15); - } - } - - if (health.current <= 0) { - health.current = 0; - // Game over handled by game loop - } - } - - killEnemy(enemy) { - const enemyComp = enemy.getComponent('enemy'); - const pos = enemy.getComponent('position'); - const renderable = enemy.getComponent('renderable'); - - // Explosion system constants - const EXPLOSION_FRIENDLY_FIRE_MULTIPLIER = 0.5; - const EXPLOSION_VISUAL_SCALE = 0.6; - const EXPLOSION_PARTICLE_COUNT = 40; - const EXPLOSION_SHAKE_INTENSITY = 8; - const EXPLOSION_SHAKE_DURATION = 0.3; - - if (enemyComp && pos) { - // Handle explosive enemies - deal area damage - if (enemyComp.isExplosive && enemyComp.attackPattern?.type === 'explode') { - const explosionRadius = enemyComp.attackPattern.explosionRadius || 80; - const explosionDamage = enemyComp.attackPattern.damage || 40; - - // Find all entities near the explosion - const player = this.world.getEntitiesByType('player')[0]; - const allEnemies = this.world.getEntitiesByType('enemy'); - - // Damage player if in range - if (player) { - const playerPos = player.getComponent('position'); - if (playerPos) { - const dx = playerPos.x - pos.x; - const dy = playerPos.y - pos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < explosionRadius) { - const damageFalloff = Math.max(0, Math.min(1, 1 - (distance / explosionRadius))); - this.damagePlayer(player, explosionDamage * damageFalloff); - } - } - } - - // Damage nearby enemies - for (const nearbyEnemy of allEnemies) { - if (nearbyEnemy.id === enemy.id) continue; - - const nearbyPos = nearbyEnemy.getComponent('position'); - if (nearbyPos) { - const dx = nearbyPos.x - pos.x; - const dy = nearbyPos.y - pos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < explosionRadius) { - const damageFalloff = Math.max(0, Math.min(1, 1 - (distance / explosionRadius))); - // Reduced friendly fire - this.damageEnemy(nearbyEnemy, explosionDamage * damageFalloff * EXPLOSION_FRIENDLY_FIRE_MULTIPLIER); - } - } - } - - // Enhanced visual effect for explosion - if (this.particleSystem) { - const explosionColor = enemyComp.attackPattern.explosionColor || '#FF4500'; - this.particleSystem.createExplosion( - pos.x, - pos.y, - explosionRadius * EXPLOSION_VISUAL_SCALE, - explosionColor, - EXPLOSION_PARTICLE_COUNT - ); - } - - // Screen shake for explosion - if (this.screenEffects) { - this.screenEffects.shake(EXPLOSION_SHAKE_INTENSITY, EXPLOSION_SHAKE_DURATION); - this.screenEffects.flash(enemyComp.attackPattern.explosionColor || '#FF4500', 0.4, 0.2); - } - } - - // Play explosion sound - if (this.audioManager && this.audioManager.initialized) { - const pitch = enemyComp.isExplosive ? 0.6 : MathUtils.randomFloat(0.8, 1.2); - this.audioManager.playSFX('explosion', pitch); - } - - // Create explosion particle effect - if (this.particleSystem && !enemyComp.isExplosive) { - const color = renderable ? renderable.color : '#ff6600'; - const size = renderable ? renderable.size : 20; - const particleCount = Math.min(30, 15 + size); - this.particleSystem.createExplosion(pos.x, pos.y, size, color, particleCount); - } - - // Drop XP - this.spawnPickup(pos.x, pos.y, 'xp', enemyComp.xpValue); - - // Random chance to drop health pack (5-15 HP) - const isBoss = renderable && renderable.size >= BOSS_SIZE_THRESHOLD; - // Reduce health drop rate by 75% (multiply by 0.25) - const healthDropChance = isBoss ? 0.125 : 0.0625; // Was 50% for bosses, 25% for regular - now 12.5% and 6.25% - - if (Math.random() < healthDropChance) { - const healAmount = 5 + Math.floor(Math.random() * 11); // 5-15 HP - this.spawnPickup(pos.x + (Math.random() - 0.5) * 30, pos.y + (Math.random() - 0.5) * 30, 'health', healAmount); - } - - // Chain Lightning/Chain Reaction - // Get player to check for chain lightning stat - const player = this.world.getEntitiesByType('player')[0]; - if (player) { - const playerComp = player.getComponent('player'); - if (playerComp && playerComp.stats.chainLightning > 0) { - // Find nearby enemies for chain reaction - const chainRange = 150; // Range for chain lightning - const chainDamage = enemyComp.maxHealth * 0.3; // 30% of killed enemy's max HP - const maxChains = Math.floor(playerComp.stats.chainLightning); // Number of chains - - const allEnemies = this.world.getEntitiesByType('enemy'); - const nearbyEnemies = []; - - // Find nearby enemies - for (const nearbyEnemy of allEnemies) { - if (nearbyEnemy.id === enemy.id) continue; - - const nearbyPos = nearbyEnemy.getComponent('position'); - if (nearbyPos) { - const dx = nearbyPos.x - pos.x; - const dy = nearbyPos.y - pos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < chainRange) { - nearbyEnemies.push({ enemy: nearbyEnemy, distance: distance, pos: nearbyPos }); - } - } - } - - // Sort by distance and chain to closest enemies - nearbyEnemies.sort((a, b) => a.distance - b.distance); - const chainsToApply = Math.min(maxChains, nearbyEnemies.length); - - for (let i = 0; i < chainsToApply; i++) { - const target = nearbyEnemies[i]; - - // Create lightning visual effect - if (this.particleSystem) { - this.particleSystem.createLightning(pos.x, pos.y, target.pos.x, target.pos.y, '#00ffff'); - } - - // Deal chain damage - this.damageEnemy(target.enemy, chainDamage, player); - } - - // Play chain lightning sound - if (chainsToApply > 0 && this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('electric', 1.0); - } - } - - // Chain Explosions (Réaction en Chaîne) - // Check if player has explosionOnKill stat AND enemy wasn't killed by explosion (prevent infinite cascade) - if (playerComp && playerComp.stats.explosionOnKill && !enemyComp.killedByExplosion) { - // Get explosion parameters from player stats - const explosionRadius = playerComp.stats.explosionRadius || 80; - const explosionDamage = (playerComp.stats.explosionDamage || 30) * playerComp.stats.damageMultiplier; - - // Create explosion visual effect - if (this.particleSystem) { - const color = renderable ? renderable.color : '#ff6600'; - const particleCount = 20; - this.particleSystem.createExplosion(pos.x, pos.y, explosionRadius * 0.5, color, particleCount); - } - - // Play explosion sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('explosion', 0.8); - } - - // Deal AOE damage to nearby enemies - const allEnemies = this.world.getEntitiesByType('enemy'); - for (const nearbyEnemy of allEnemies) { - if (nearbyEnemy.id === enemy.id) continue; // Skip the killed enemy - - const nearbyPos = nearbyEnemy.getComponent('position'); - if (nearbyPos) { - const dx = nearbyPos.x - pos.x; - const dy = nearbyPos.y - pos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < explosionRadius) { - // Damage falls off with distance - const falloff = 1 - (distance / explosionRadius) * 0.5; // 50-100% damage based on distance - const finalDamage = explosionDamage * falloff; - - // Mark enemy as killed by explosion to prevent infinite chain - const nearbyEnemyComp = nearbyEnemy.getComponent('enemy'); - if (nearbyEnemyComp) { - nearbyEnemyComp.killedByExplosion = true; - } - - this.damageEnemy(nearbyEnemy, finalDamage, player); - } - } - } - } - } - - // Update stats - this.gameState.addKill(enemyComp.xpValue); - } - - this.world.removeEntity(enemy.id); - } - - spawnPickup(x, y, type, value) { - const pickup = this.world.createEntity('pickup'); - pickup.addComponent('position', Components.Position(x, y)); - pickup.addComponent('velocity', Components.Velocity(0, 0)); - pickup.addComponent('pickup', Components.Pickup(type, value)); - - const colors = { - xp: '#00ff00', - health: '#ff0000', - noyaux: '#ffaa00' - }; - - pickup.addComponent('renderable', Components.Renderable( - colors[type] || '#ffffff', - type === 'xp' ? 8 : 12, - 'circle' - )); - pickup.addComponent('collision', Components.Collision(8)); - } - - collectPickup(player, pickup) { - const pickupComp = pickup.getComponent('pickup'); - const playerComp = player.getComponent('player'); - const health = player.getComponent('health'); - - if (!pickupComp || pickupComp.collected) return; - - pickupComp.collected = true; - - // Play pickup sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('pickup', MathUtils.randomFloat(0.9, 1.1)); - } - - switch (pickupComp.type) { - case 'xp': - if (playerComp) { - const xpGained = pickupComp.value * playerComp.stats.xpBonus; - playerComp.xp += xpGained; - - // Check for level up - if (playerComp.xp >= playerComp.xpRequired) { - this.levelUp(player); - } - } - break; - - case 'health': - if (health) { - health.current = Math.min(health.max, health.current + pickupComp.value); - } - break; - - case 'noyaux': - this.gameState.stats.noyauxEarned += pickupComp.value; - break; - } - - this.world.removeEntity(pickup.id); - } - - levelUp(player) { - const playerComp = player.getComponent('player'); - if (!playerComp) return; - - playerComp.level++; - playerComp.xp -= playerComp.xpRequired; - playerComp.xpRequired = Math.floor(playerComp.xpRequired * 1.2); - - this.gameState.stats.highestLevel = Math.max( - this.gameState.stats.highestLevel, - playerComp.level - ); - - // Trigger level up screen - if (window.game) { - window.game.triggerLevelUp(); - } - } - - /** - * Check collisions between player and weather hazards (meteors, black holes) - * @param {number} deltaTime - Time elapsed since last frame - */ - checkWeatherHazardCollisions(deltaTime) { - const player = this.world.getEntitiesByType('player')[0]; - const enemies = this.world.getEntitiesByType('enemy'); - - // Check meteor collisions - const meteors = this.world.getEntitiesByType('meteor'); - for (const meteor of meteors) { - const meteorPos = meteor.getComponent('position'); - const meteorCol = meteor.getComponent('collision'); - const meteorComp = meteor.getComponent('meteor'); - - if (!meteorPos || !meteorCol || !meteorComp) continue; - - // Check if meteor hit bottom of screen - if (meteorPos.y > this.world.canvas?.height + 50) { - this.world.removeEntity(meteor.id); - continue; - } - - let meteorHit = false; - - // Check collision with player - if (player) { - const playerPos = player.getComponent('position'); - const playerCol = player.getComponent('collision'); - - if (playerPos && playerCol && MathUtils.circleCollision( - playerPos.x, playerPos.y, playerCol.radius, - meteorPos.x, meteorPos.y, meteorCol.radius - )) { - // Damage player - this.damagePlayer(player, meteorComp.damage, 'meteor'); - meteorHit = true; - } - } - - // Check collision with enemies - if (!meteorHit) { - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - const enemyCol = enemy.getComponent('collision'); - - if (!enemyPos || !enemyCol) continue; - - if (MathUtils.circleCollision( - enemyPos.x, enemyPos.y, enemyCol.radius, - meteorPos.x, meteorPos.y, meteorCol.radius - )) { - // Damage enemy (reduced damage to balance) - this.damageEnemy(enemy, meteorComp.damage * 0.7); - meteorHit = true; - break; - } - } - } - - // If meteor hit something, create explosion and remove it - if (meteorHit) { - // Create explosion effect - if (this.particleSystem) { - this.particleSystem.createExplosion( - meteorPos.x, - meteorPos.y, - meteorComp.size, - '#8B4513', - 25 - ); - } - - // Play impact sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('explosion', 1.2); - } - - // Screen shake (less intense for enemy hits) - if (this.screenEffects && player) { - const playerPos = player.getComponent('position'); - if (playerPos) { - const dx = meteorPos.x - playerPos.x; - const dy = meteorPos.y - playerPos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - // Shake intensity decreases with distance - const intensity = Math.max(2, 6 - dist / 100); - this.screenEffects.shake(intensity, 0.2); - } - } - - // Remove meteor - this.world.removeEntity(meteor.id); - } - } - - // Check black hole collisions - const blackHoles = this.world.getEntitiesByType('black_hole'); - for (const blackHole of blackHoles) { - const blackHolePos = blackHole.getComponent('position'); - const blackHoleCol = blackHole.getComponent('collision'); - const blackHoleComp = blackHole.getComponent('black_hole'); - - if (!blackHolePos || !blackHoleCol || !blackHoleComp) continue; - - // Check if grace period has passed - const isActive = blackHoleComp.age > blackHoleComp.gracePeriod; - if (!isActive) continue; // Don't damage during grace period - - // Damage player if within damage radius (throttled to 0.5s intervals) - if (player) { - const playerPos = player.getComponent('position'); - if (playerPos) { - const distance = MathUtils.distance( - playerPos.x, playerPos.y, - blackHolePos.x, blackHolePos.y - ); - - if (distance < this.BLACK_HOLE_CENTER_KILL_RADIUS) { - // INSTANT KILL - Player is in the center of the black hole - const health = player.getComponent('health'); - if (health && !health.godMode) { - health.current = 0; // Instant death - - // Intense visual feedback for instant death - if (this.screenEffects) { - this.screenEffects.shake( - this.BLACK_HOLE_DEATH_SHAKE_INTENSITY, - this.BLACK_HOLE_DEATH_SHAKE_DURATION - ); - this.screenEffects.flash( - this.BLACK_HOLE_DEATH_FLASH_COLOR, - this.BLACK_HOLE_DEATH_FLASH_DURATION, - this.BLACK_HOLE_DEATH_FLASH_INTENSITY - ); - } - - // Play death sound - if (this.audioManager && this.audioManager.initialized) { - this.audioManager.playSFX('death'); - } - - console.log('%c[Black Hole] Player sucked into center - INSTANT DEATH!', 'color: #9400D3; font-weight: bold'); - } - } else if (distance < blackHoleComp.damageRadius) { - // Normal damage zone - outside the instant kill center - // Update damage timer only when in damage radius - blackHoleComp.lastPlayerDamageTime += deltaTime; - - // Apply damage every 0.5 seconds - if (blackHoleComp.lastPlayerDamageTime >= 0.5) { - // Scale damage based on distance - closer to center = more damage - // At center (distance=0): 3x damage, at edge (distance=damageRadius): 1x damage - const distanceFactor = Math.max(0, Math.min(1, 1 - (distance / blackHoleComp.damageRadius))); - const damageMultiplier = 1 + (distanceFactor * 2); // 1x to 3x multiplier - const scaledDamage = blackHoleComp.damage * damageMultiplier; - - this.damagePlayer(player, scaledDamage, 'black_hole'); - blackHoleComp.lastPlayerDamageTime = 0; - - // Visual feedback - more intense closer to center - if (this.screenEffects) { - const flashIntensity = 0.1 + (distanceFactor * 0.2); // 0.1 to 0.3 - this.screenEffects.flash('#9400D3', 0.2, flashIntensity); - } - } - } else { - // Reset timer when player exits damage radius - blackHoleComp.lastPlayerDamageTime = 0; - } - } - } - - // Damage enemies within damage radius (throttled to 0.5s intervals per enemy) - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - if (!enemyPos) continue; - - const distance = MathUtils.distance( - enemyPos.x, enemyPos.y, - blackHolePos.x, blackHolePos.y - ); - - if (distance < this.BLACK_HOLE_CENTER_KILL_RADIUS) { - // INSTANT KILL - Enemy is in the center of the black hole - const enemyHealth = enemy.getComponent('health'); - if (enemyHealth) { - enemyHealth.current = 0; // Instant death - console.log('%c[Black Hole] Enemy sucked into center - INSTANT DEATH!', 'color: #9400D3; font-weight: bold'); - } - } else if (distance < blackHoleComp.damageRadius) { - // Normal damage zone - outside the instant kill center - // Initialize timer for this enemy if not exists - if (!blackHoleComp.lastEnemyDamageTime[enemy.id]) { - blackHoleComp.lastEnemyDamageTime[enemy.id] = 0; // Start at 0 for consistent timing - } - - // Update timer - blackHoleComp.lastEnemyDamageTime[enemy.id] += deltaTime; - - // Apply damage every 0.5 seconds - if (blackHoleComp.lastEnemyDamageTime[enemy.id] >= 0.5) { - // Scale damage based on distance - closer to center = more damage - const distanceFactor = Math.max(0, Math.min(1, 1 - (distance / blackHoleComp.damageRadius))); - const damageMultiplier = 1 + (distanceFactor * 2); // 1x to 3x multiplier - const scaledDamage = blackHoleComp.damage * 0.5 * damageMultiplier; - - this.damageEnemy(enemy, scaledDamage); - blackHoleComp.lastEnemyDamageTime[enemy.id] = 0; - } - } else { - // Reset timer when enemy exits damage radius to prevent memory accumulation - if (blackHoleComp.lastEnemyDamageTime[enemy.id]) { - delete blackHoleComp.lastEnemyDamageTime[enemy.id]; - } - } - } - } - } -} diff --git a/js/systems/CombatSystem.js b/js/systems/CombatSystem.js deleted file mode 100644 index 5fd6a11..0000000 --- a/js/systems/CombatSystem.js +++ /dev/null @@ -1,853 +0,0 @@ -/** - * @file CombatSystem.js - * @description Handles weapon firing, combat mechanics, and projectile creation - */ - -// Sound probability constants for weapons -const BEAM_SOUND_PROBABILITY = 0.15; // Beam weapons fire rapidly, reduce sound frequency - -class CombatSystem { - constructor(world, gameState, audioManager) { - this.world = world; - this.gameState = gameState; - this.audioManager = audioManager; - } - - /** - * Update all weapon cooldowns and fire when ready - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - const players = this.world.getEntitiesByType('player'); - - for (const player of players) { - const playerComp = player.getComponent('player'); - if (!playerComp) continue; - - // Update and fire each weapon - for (const weaponData of playerComp.weapons) { - this.updateWeapon(player, weaponData, deltaTime); - } - - // Update blade halo if active - this.updateBladeHalo(player, playerComp, deltaTime); - } - } - - /** - * Update weapon cooldown and fire if ready - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon object from playerComp.weapons - * @param {number} deltaTime - Time elapsed - */ - updateWeapon(player, weapon, deltaTime) { - if (!weapon || !weapon.data) return; - - // Update cooldown - weapon.cooldown -= deltaTime; - - // Check if weapons are disabled by weather event (magnetic storm) - if (this.gameState && this.gameState.weaponDisabled) { - return; // Don't fire weapons during magnetic storm - } - - // Fire when cooldown is ready - if (weapon.cooldown <= 0) { - const playerStats = player.getComponent('player').stats; - const baseFireRate = weapon.data.fireRate || 1; - const fireRate = baseFireRate * playerStats.fireRate; - weapon.cooldown = fireRate > 0 ? 1 / fireRate : 999; - - this.fireWeapon(player, weapon); - } - } - - /** - * Fire weapon based on its type - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireWeapon(player, weapon) { - const type = weapon.data.type; - - // Play appropriate sound effect with pitch variation - if (this.audioManager && this.audioManager.initialized) { - switch (type) { - case 'direct': - case 'continuous_beam': - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.9, 1.1)); - break; - case 'spread': - case 'flamethrower': - // Minigun/flamethrower needs more variety - vary between 0.6 and 1.2 - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.6, 1.2)); - break; - case 'homing': - case 'mega_homing': - this.audioManager.playSFX('missile', MathUtils.randomFloat(0.85, 1.15)); - break; - case 'chain': - case 'storm': - this.audioManager.playSFX('electric', MathUtils.randomFloat(0.9, 1.1)); - break; - case 'beam': - // Beam weapons fire rapidly (20 times/sec) - play sound only occasionally to avoid repetitive noise - // Vampire ray weapon especially benefits from reduced sound frequency - if (Math.random() < BEAM_SOUND_PROBABILITY) { - // Use a softer, lower pitch for a subtle energy drain effect - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.3, 0.5)); - } - break; - case 'railgun': - // Railgun fires slowly (0.4 times/sec) - always play sound but keep it soft - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.4, 0.6)); - break; - case 'mine': - case 'turret': - this.audioManager.playSFX('laser', MathUtils.randomFloat(1.0, 1.2)); - break; - case 'orbital': - // Orbitals are passive, only play sound occasionally - if (Math.random() < 0.1) { - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.8, 1.0)); - } - break; - case 'gravity_field': - // Gravitational hum - low pitch - if (Math.random() < 0.2) { - this.audioManager.playSFX('laser', MathUtils.randomFloat(0.5, 0.7)); - } - break; - default: - // Default fallback sound - this.audioManager.playSFX('laser', 1.0); - break; - } - } - - switch (type) { - case 'direct': - this.fireDirect(player, weapon); - break; - case 'spread': - this.fireSpread(player, weapon); - break; - case 'homing': - this.fireHoming(player, weapon); - break; - case 'orbital': - this.updateOrbitals(player, weapon); - break; - case 'beam': - this.fireBeam(player, weapon); - break; - case 'mine': - this.fireMine(player, weapon); - break; - case 'chain': - this.fireChain(player, weapon); - break; - case 'turret': - this.fireTurret(player, weapon); - break; - case 'continuous_beam': - this.fireContinuousBeam(player, weapon); - break; - case 'mega_homing': - this.fireMegaHoming(player, weapon); - break; - case 'gravity_field': - this.updateGravityField(player, weapon); - break; - case 'storm': - this.fireStorm(player, weapon); - break; - case 'railgun': - this.fireRailgun(player, weapon); - break; - case 'flamethrower': - this.fireFlamethrower(player, weapon); - break; - } - } - - /** - * Fire direct projectiles towards nearest enemy - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireDirect(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y); - if (!target) return; - - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const projectileCount = levelData.projectileCount || 1; - const spread = projectileCount > 1 ? 0.2 : 0; - - for (let i = 0; i < projectileCount; i++) { - const offset = (i - (projectileCount - 1) / 2) * spread; - const finalAngle = angle + offset; - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - this.createProjectile( - pos.x, pos.y, - finalAngle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 3.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - } - } - - /** - * Fire spread projectiles in a cone - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireSpread(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y); - if (!target) return; - - const targetPos = target.getComponent('position'); - const baseAngle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const projectileCount = levelData.projectileCount || 3; - const spreadAngle = (levelData.area || 20) * (Math.PI / 180); - - for (let i = 0; i < projectileCount; i++) { - const offset = (i - (projectileCount - 1) / 2) * (spreadAngle / projectileCount); - const angle = baseAngle + offset; - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 2.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - } - } - - /** - * Fire homing missiles - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireHoming(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const enemies = this.world.getEntitiesByType('enemy'); - if (enemies.length === 0) return; - - const projectileCount = levelData.projectileCount || 1; - const targets = this.findNearestEnemies(pos.x, pos.y, projectileCount); - - for (const target of targets) { - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const projectile = this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 5.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - - // Make it homing - const projComp = projectile.getComponent('projectile'); - projComp.homing = true; - projComp.homingStrength = 0.1; - projComp.explosionRadius = levelData.area || 60; - } - } - - /** - * Update orbital projectiles - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - updateOrbitals(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - // Remove old orbitals for this weapon - const orbitals = this.world.getEntitiesByType('projectile').filter(p => { - const proj = p.getComponent('projectile'); - return proj && proj.weaponType === weapon.type && proj.owner === player.id; - }); - - const count = levelData.projectileCount || 2; - if (orbitals.length < count) { - // Create missing orbitals - for (let i = orbitals.length; i < count; i++) { - const angle = (i / count) * Math.PI * 2; - const radius = levelData.area || 100; - - const orbital = this.world.createEntity('projectile'); - orbital.addComponent('position', Components.Position( - pos.x + Math.cos(angle) * radius, - pos.y + Math.sin(angle) * radius - )); - orbital.addComponent('collision', Components.Collision(15)); - orbital.addComponent('renderable', Components.Renderable(weapon.data.color, 15, 'circle')); - orbital.addComponent('projectile', Components.Projectile( - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - 0, - 999, - player.id, - weapon.type - )); - - const projComp = orbital.getComponent('projectile'); - projComp.orbital = true; - projComp.orbitalIndex = i; - projComp.orbitalCount = count; - projComp.orbitalRadius = radius; - projComp.orbitalSpeed = 2.0; - } - } - } - - /** - * Fire beam weapon - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireBeam(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y, levelData.area || 200); - if (!target) return; - - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - // Create beam projectile - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - 5000, - 0.05, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - } - - /** - * Fire mines - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireMine(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const projectileCount = levelData.projectileCount || 1; - - for (let i = 0; i < projectileCount; i++) { - const angle = Math.random() * Math.PI * 2; - const speed = weapon.data.projectileSpeed * 0.5; - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const mine = this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - speed, - levelData.duration || 10, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - - const mineComp = mine.getComponent('projectile'); - mineComp.mine = true; - mineComp.explosionRadius = levelData.area || 80; - } - } - - /** - * Fire chain lightning - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireChain(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y); - if (!target) return; - - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const projectile = this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 1.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - - const projComp = projectile.getComponent('projectile'); - projComp.chain = true; - projComp.chainCount = levelData.chainCount || 3; - projComp.chainRange = levelData.area || 150; - projComp.chained = [target.id]; - } - - /** - * Fire turret (not implemented - would spawn ally entity) - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireTurret(player, weapon) { - // Turret system would require separate entity type - // Placeholder for future implementation - } - - /** - * Fire continuous beam (evolved laser) - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireContinuousBeam(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const stats = weapon.data.stats; - - const target = this.findNearestEnemy(pos.x, pos.y, stats.area); - if (!target) return; - - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * stats.damage * playerComp.stats.damage, - 8000, - 0.1, - player.id, - weapon.type, - stats.piercing, - weapon.data.color - ); - } - - /** - * Fire mega homing missiles (evolved) - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireMegaHoming(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const stats = weapon.data.stats; - - const targets = this.findNearestEnemies(pos.x, pos.y, stats.projectileCount); - - for (const target of targets) { - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const projectile = this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * stats.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 8.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - - const projComp = projectile.getComponent('projectile'); - projComp.homing = true; - projComp.homingStrength = 0.15; - projComp.explosionRadius = stats.area; - } - } - - /** - * Update gravity field (evolved orbitals) - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - updateGravityField(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const stats = weapon.data.stats; - - const orbitals = this.world.getEntitiesByType('projectile').filter(p => { - const proj = p.getComponent('projectile'); - return proj && proj.weaponType === weapon.type && proj.owner === player.id; - }); - - const count = stats.projectileCount; - if (orbitals.length < count) { - for (let i = orbitals.length; i < count; i++) { - const angle = (i / count) * Math.PI * 2; - const radius = stats.area; - - const orbital = this.world.createEntity('projectile'); - orbital.addComponent('position', Components.Position( - pos.x + Math.cos(angle) * radius, - pos.y + Math.sin(angle) * radius - )); - orbital.addComponent('collision', Components.Collision(20)); - orbital.addComponent('renderable', Components.Renderable(weapon.data.color, 20, 'circle')); - orbital.addComponent('projectile', Components.Projectile( - weapon.data.baseDamage * stats.damage * playerComp.stats.damage, - 0, - 999, - player.id, - weapon.type - )); - - const projComp = orbital.getComponent('projectile'); - projComp.orbital = true; - projComp.orbitalIndex = i; - projComp.orbitalCount = count; - projComp.orbitalRadius = radius; - projComp.orbitalSpeed = 1.5; - projComp.gravity = true; - projComp.gravityStrength = 50; - } - } - } - - /** - * Fire storm lightning (evolved chain) - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireStorm(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const stats = weapon.data.stats; - - const targets = this.findNearestEnemies(pos.x, pos.y, 5); - - for (const target of targets) { - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const projectile = this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * stats.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 2.0 * playerComp.stats.range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - - const projComp = projectile.getComponent('projectile'); - projComp.chain = true; - projComp.chainCount = stats.chainCount; - projComp.chainRange = stats.area; - projComp.chained = [target.id]; - } - } - - /** - * Fire railgun - piercing beam weapon - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireRailgun(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y); - if (!target) return; - - const targetPos = target.getComponent('position'); - const angle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const projectileCount = levelData.projectileCount || 1; - - for (let i = 0; i < projectileCount; i++) { - const offset = (i - (projectileCount - 1) / 2) * 0.1; - const finalAngle = angle + offset; - - // Create long, fast piercing projectile (beam-like) - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - const projectile = this.createProjectile( - pos.x, pos.y, - finalAngle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - 1.5 * playerComp.stats.range, // Shorter lifetime but very fast - player.id, - weapon.type, - levelData.piercing || 999, - weapon.data.color - ); - - // Make it look like a beam - elongated projectile - const renderable = projectile.getComponent('renderable'); - if (renderable) { - renderable.shape = 'line'; - renderable.size = 30; // Length of the beam visual - } - } - } - - /** - * Fire flamethrower - cone of fire projectiles - * @param {Entity} player - Player entity - * @param {Object} weapon - Weapon component - */ - fireFlamethrower(player, weapon) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const levelData = weapon.data.levels[weapon.level - 1]; - - const target = this.findNearestEnemy(pos.x, pos.y); - if (!target) return; - - const targetPos = target.getComponent('position'); - const baseAngle = MathUtils.angle(pos.x, pos.y, targetPos.x, targetPos.y); - - const projectileCount = levelData.projectileCount || 5; - const spreadAngle = (levelData.area || 30) * (Math.PI / 180); - - for (let i = 0; i < projectileCount; i++) { - const offset = (i - (projectileCount - 1) / 2) * (spreadAngle / projectileCount); - const angle = baseAngle + offset; - - // Shorter range due to malus - const range = 2.0 * playerComp.stats.range * (weapon.data.malus?.rangeMultiplier || 1); - - const piercing = Math.max(levelData.piercing || 0, playerComp.stats.piercing || 0); - this.createProjectile( - pos.x, pos.y, - angle, - weapon.data.baseDamage * levelData.damage * playerComp.stats.damage, - weapon.data.projectileSpeed * playerComp.stats.projectileSpeed, - range, - player.id, - weapon.type, - piercing, - weapon.data.color - ); - } - } - - /** - * Create a projectile entity - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} angle - Direction angle - * @param {number} damage - Damage value - * @param {number} speed - Projectile speed - * @param {number} lifetime - Lifetime in seconds - * @param {number} owner - Owner entity ID - * @param {string} weaponType - Weapon type - * @param {number} piercing - Piercing count - * @param {string} color - Projectile color - * @returns {Entity} Created projectile - */ - createProjectile(x, y, angle, damage, speed, lifetime, owner, weaponType, piercing, color) { - const projectile = this.world.createEntity('projectile'); - - // Get player stats for size modifier - const ownerEntity = this.world.getEntity(owner); - const ownerComp = ownerEntity ? ownerEntity.getComponent('player') : null; - const sizeMultiplier = ownerComp && ownerComp.stats.projectileSizeMultiplier ? ownerComp.stats.projectileSizeMultiplier : 1; - const sizeAdd = ownerComp && ownerComp.stats.projectileSizeAdd ? ownerComp.stats.projectileSizeAdd : 0; - - // Calculate final projectile size - const baseSize = 5; - const finalSize = baseSize * sizeMultiplier + sizeAdd; - - projectile.addComponent('position', Components.Position(x, y)); - projectile.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - projectile.addComponent('collision', Components.Collision(finalSize)); - projectile.addComponent('renderable', Components.Renderable(color, finalSize, 'circle')); - projectile.addComponent('projectile', Components.Projectile( - damage, - speed, - lifetime, - owner, - weaponType - )); - - const projComp = projectile.getComponent('projectile'); - projComp.piercing = piercing; - - return projectile; - } - - /** - * Find nearest enemy to position - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} maxRange - Maximum search range (optional) - * @returns {Entity|null} Nearest enemy or null - */ - findNearestEnemy(x, y, maxRange = Infinity) { - const enemies = this.world.getEntitiesByType('enemy'); - let nearest = null; - let nearestDist = maxRange; - - for (const enemy of enemies) { - const pos = enemy.getComponent('position'); - if (!pos) continue; - - const dist = MathUtils.distance(x, y, pos.x, pos.y); - if (dist < nearestDist) { - nearestDist = dist; - nearest = enemy; - } - } - - return nearest; - } - - /** - * Find N nearest enemies to position - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} count - Number of enemies to find - * @returns {Array} Array of nearest enemies - */ - findNearestEnemies(x, y, count) { - const enemies = this.world.getEntitiesByType('enemy'); - - const sorted = enemies - .map(enemy => { - const pos = enemy.getComponent('position'); - if (!pos) return null; - return { - enemy, - dist: MathUtils.distance(x, y, pos.x, pos.y) - }; - }) - .filter(e => e !== null) - .sort((a, b) => a.dist - b.dist) - .slice(0, count) - .map(e => e.enemy); - - return sorted; - } - - /** - * Update spinning blade halo (Lame Tournoyante) - * @param {Entity} player - Player entity - * @param {Object} playerComp - Player component - * @param {number} deltaTime - Time elapsed - */ - updateBladeHalo(player, playerComp, deltaTime) { - // Check if player has blade halo passive (orbitDamage from stats) - const orbitDamage = playerComp.stats.orbitDamage || 0; - const orbitRadius = playerComp.stats.orbitRadius || 60; - - if (orbitDamage <= 0) { - // No blade halo active - if (playerComp.bladeHalo) { - delete playerComp.bladeHalo; - } - return; - } - - // Initialize blade halo state if needed - if (!playerComp.bladeHalo) { - playerComp.bladeHalo = { - angle: 0, - lastTickTime: 0, - tickRate: 0.25 // 4 ticks per second - }; - } - - const halo = playerComp.bladeHalo; - - // Update rotation - halo.angle += deltaTime * 3.0; // Rotation speed - if (halo.angle > Math.PI * 2) { - halo.angle -= Math.PI * 2; - } - - // Update damage tick - halo.lastTickTime += deltaTime; - if (halo.lastTickTime >= halo.tickRate) { - halo.lastTickTime = 0; - - // Apply damage to nearby enemies - const playerPos = player.getComponent('position'); - if (!playerPos) return; - - const enemies = this.world.getEntitiesByType('enemy'); - const damagePerTick = orbitDamage * halo.tickRate * playerComp.stats.damageMultiplier; - - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - if (!enemyPos) continue; - - const dist = MathUtils.distance(playerPos.x, playerPos.y, enemyPos.x, enemyPos.y); - if (dist <= orbitRadius) { - const enemyHealth = enemy.getComponent('health'); - if (enemyHealth) { - enemyHealth.current -= damagePerTick; - if (enemyHealth.current <= 0) { - this.world.removeEntity(enemy.id); - } - } - } - } - } - } -} diff --git a/js/systems/MovementSystem.js b/js/systems/MovementSystem.js deleted file mode 100644 index a779397..0000000 --- a/js/systems/MovementSystem.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * @file MovementSystem.js - * @description Handles entity movement and player input - */ - -class MovementSystem { - constructor(world, canvas) { - this.world = world; - this.canvas = canvas; - this.keys = {}; - this.setupInputHandlers(); - } - - setupInputHandlers() { - window.addEventListener('keydown', (e) => { - this.keys[e.key.toLowerCase()] = true; - }); - - window.addEventListener('keyup', (e) => { - this.keys[e.key.toLowerCase()] = false; - }); - } - - update(deltaTime) { - // Update player movement - const players = this.world.getEntitiesByType('player'); - for (const player of players) { - this.updatePlayerMovement(player, deltaTime); - } - - // Update orbital and laser projectiles - const projectiles = this.world.getEntitiesByType('projectile'); - for (const projectile of projectiles) { - const projComp = projectile.getComponent('projectile'); - if (projComp && projComp.orbital) { - this.updateOrbitalProjectile(projectile, deltaTime); - } else if (projComp && projComp.isLaser) { - this.updateLaserProjectile(projectile, deltaTime); - } - } - - // Update all entities with velocity (non-orbital/non-laser projectiles and others) - const movingEntities = this.world.getEntitiesWithComponent('velocity'); - for (const entity of movingEntities) { - const projComp = entity.getComponent('projectile'); - // Skip orbital and laser projectiles as they're updated above - if (!(projComp && (projComp.orbital || projComp.isLaser))) { - this.updateEntityPosition(entity, deltaTime); - } - } - } - - /** - * Update orbital projectile position to rotate around player - * @param {Entity} entity - Orbital projectile entity - * @param {number} deltaTime - Time since last frame - */ - updateOrbitalProjectile(entity, deltaTime) { - const projComp = entity.getComponent('projectile'); - const pos = entity.getComponent('position'); - - if (!projComp || !pos) return; - - // Find owner (player) - const owner = this.world.getEntity(projComp.owner); - if (!owner) { - this.world.removeEntity(entity.id); - return; - } - - const ownerPos = owner.getComponent('position'); - if (!ownerPos) return; - - // Initialize orbital angle if not set - if (projComp.orbitalAngle === undefined) { - projComp.orbitalAngle = (projComp.orbitalIndex / projComp.orbitalCount) * Math.PI * 2; - } - - // Update angle - projComp.orbitalAngle += (projComp.orbitalSpeed || 2.0) * deltaTime; - - // Calculate new position - const radius = projComp.orbitalRadius || 100; - pos.x = ownerPos.x + Math.cos(projComp.orbitalAngle) * radius; - pos.y = ownerPos.y + Math.sin(projComp.orbitalAngle) * radius; - } - - updatePlayerMovement(player, deltaTime) { - const pos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - const vel = player.getComponent('velocity'); - - if (!pos || !playerComp) return; - - let dx = 0; - let dy = 0; - - // WASD or ZQSD movement - if (this.keys['w'] || this.keys['z']) dy -= 1; - if (this.keys['s']) dy += 1; - if (this.keys['a'] || this.keys['q']) dx -= 1; - if (this.keys['d']) dx += 1; - - // Check if player is actively moving - const hasInput = (dx !== 0 || dy !== 0); - - // Normalize diagonal movement - if (hasInput) { - const normalized = MathUtils.normalize(dx, dy); - dx = normalized.x; - dy = normalized.y; - } - - // Apply speed with stats - const speed = playerComp.speed * playerComp.stats.speed; - pos.x += dx * speed * deltaTime; - pos.y += dy * speed * deltaTime; - - // Apply drag/friction to player velocity (if exists) - // This prevents drift from black hole or other forces - if (vel) { - // Use stronger drag when not actively moving - const dragCoeff = hasInput ? 3.0 : 8.0; - - // Apply drag: vel *= (1 - dragCoeff * deltaTime) - const dragFactor = Math.max(0, 1 - dragCoeff * deltaTime); - vel.vx *= dragFactor; - vel.vy *= dragFactor; - - // Clamp small velocities to zero to stop drift completely - const velocityThreshold = 0.5; - if (Math.abs(vel.vx) < velocityThreshold) vel.vx = 0; - if (Math.abs(vel.vy) < velocityThreshold) vel.vy = 0; - } - - // Keep player in bounds - const collision = player.getComponent('collision'); - const radius = collision ? collision.radius : 15; - - pos.x = MathUtils.clamp(pos.x, radius, this.canvas.width - radius); - pos.y = MathUtils.clamp(pos.y, radius, this.canvas.height - radius); - } - - updateEntityPosition(entity, deltaTime) { - const pos = entity.getComponent('position'); - const vel = entity.getComponent('velocity'); - - if (!pos || !vel) return; - - pos.x += vel.vx * deltaTime; - pos.y += vel.vy * deltaTime; - - // Remove entities that are off-screen (with buffer) - const buffer = 100; - if (pos.x < -buffer || pos.x > this.canvas.width + buffer || - pos.y < -buffer || pos.y > this.canvas.height + buffer) { - - // Don't remove player or pickups - if (entity.type !== 'player' && entity.type !== 'pickup') { - this.world.removeEntity(entity.id); - } - } - } - - isKeyPressed(key) { - return this.keys[key.toLowerCase()] === true; - } -} diff --git a/js/systems/ParticleSystem.js b/js/systems/ParticleSystem.js deleted file mode 100644 index 4b76ea0..0000000 --- a/js/systems/ParticleSystem.js +++ /dev/null @@ -1,438 +0,0 @@ -/** - * @file ParticleSystem.js - * @description Particle effects system for explosions, impacts, and visual feedback - */ - -class ParticleSystem { - constructor(world) { - this.world = world; - } - - /** - * Update all particles - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - const particles = this.world.getEntitiesByType('particle'); - - for (const particle of particles) { - this.updateParticle(particle, deltaTime); - } - } - - /** - * Update individual particle - * @param {Entity} particle - Particle entity - * @param {number} deltaTime - Time elapsed - */ - updateParticle(particle, deltaTime) { - const particleComp = particle.getComponent('particle'); - const pos = particle.getComponent('position'); - const vel = particle.getComponent('velocity'); - const renderable = particle.getComponent('renderable'); - - if (!particleComp || !pos || !renderable) return; - - // Update lifetime - particleComp.lifetime -= deltaTime; - - if (particleComp.lifetime <= 0) { - this.world.removeEntity(particle.id); - return; - } - - // Update position with velocity - if (vel) { - pos.x += vel.vx * deltaTime; - pos.y += vel.vy * deltaTime; - - // Apply decay to velocity - vel.vx *= particleComp.decay; - vel.vy *= particleComp.decay; - } - - // Update alpha based on lifetime - const lifetimePercent = particleComp.lifetime / particleComp.maxLifetime; - particleComp.alpha = lifetimePercent; - renderable.alpha = particleComp.alpha; - - // Update size based on lifetime (shrink over time) - if (renderable.baseSize) { - renderable.size = renderable.baseSize * lifetimePercent; - } - } - - /** - * Create explosion particle effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} size - Explosion size - * @param {string} color - Explosion color - * @param {number} particleCount - Number of particles - */ - createExplosion(x, y, size, color, particleCount = 20) { - const colors = this.getColorVariations(color, 3); - - for (let i = 0; i < particleCount; i++) { - const angle = (i / particleCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.5; - const speed = (50 + Math.random() * 150) * (size / 20); - const particleSize = 3 + Math.random() * 5 * (size / 20); - const particleColor = colors[Math.floor(Math.random() * colors.length)]; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - particle.addComponent('renderable', Components.Renderable( - particleColor, - particleSize, - 'circle' - )); - particle.addComponent('particle', Components.Particle( - 0.5 + Math.random() * 0.5, - Math.cos(angle) * speed, - Math.sin(angle) * speed, - 0.92 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = particleSize; - renderable.glow = true; - } - - // Add a shockwave ring - this.createShockwave(x, y, size, color); - } - - /** - * Create impact particle effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} angle - Impact direction - * @param {string} color - Impact color - */ - createImpact(x, y, angle, color) { - const particleCount = 8; - const spreadAngle = Math.PI / 3; - - for (let i = 0; i < particleCount; i++) { - const offsetAngle = angle + (i - particleCount / 2) * (spreadAngle / particleCount); - const speed = 100 + Math.random() * 100; - const particleSize = 2 + Math.random() * 3; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(offsetAngle) * speed, - Math.sin(offsetAngle) * speed - )); - particle.addComponent('renderable', Components.Renderable( - color, - particleSize, - 'circle' - )); - particle.addComponent('particle', Components.Particle( - 0.3 + Math.random() * 0.2, - Math.cos(offsetAngle) * speed, - Math.sin(offsetAngle) * speed, - 0.88 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = particleSize; - renderable.glow = true; - } - } - - /** - * Create spark particle effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} color - Spark color - * @param {number} count - Number of sparks - */ - createSparks(x, y, color, count = 5) { - for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2; - const speed = 50 + Math.random() * 150; - const size = 2 + Math.random() * 2; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - particle.addComponent('renderable', Components.Renderable( - color, - size, - 'circle' - )); - particle.addComponent('particle', Components.Particle( - 0.4 + Math.random() * 0.3, - Math.cos(angle) * speed, - Math.sin(angle) * speed, - 0.85 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = size; - renderable.glow = true; - renderable.glowIntensity = 0.8; - } - } - - /** - * Create trail particle effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} color - Trail color - * @param {number} size - Trail size - */ - createTrail(x, y, color, size = 3) { - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('renderable', Components.Renderable(color, size, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.2, - 0, - 0, - 1.0 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = size; - renderable.glow = true; - renderable.glowIntensity = 0.5; - } - - /** - * Create shockwave effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} size - Shockwave size - * @param {string} color - Shockwave color - */ - createShockwave(x, y, size, color) { - const ringCount = 2; - - for (let ring = 0; ring < ringCount; ring++) { - const delay = ring * 0.05; - const particleCount = 20; - - for (let i = 0; i < particleCount; i++) { - const angle = (i / particleCount) * Math.PI * 2; - const speed = (150 + ring * 50) * (size / 20); - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - particle.addComponent('renderable', Components.Renderable( - color, - 3, - 'circle' - )); - particle.addComponent('particle', Components.Particle( - 0.4 - delay, - Math.cos(angle) * speed, - Math.sin(angle) * speed, - 0.95 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = 3; - renderable.glow = true; - renderable.glowIntensity = 0.6; - } - } - } - - /** - * Create glow pulse effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} color - Glow color - * @param {number} size - Glow size - */ - createGlow(x, y, color, size) { - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('renderable', Components.Renderable(color, size, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.5, - 0, - 0, - 1.0 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = size; - renderable.glow = true; - renderable.glowIntensity = 1.0; - } - - /** - * Create lightning arc effect - * @param {number} x1 - Start X position - * @param {number} y1 - Start Y position - * @param {number} x2 - End X position - * @param {number} y2 - End Y position - * @param {string} color - Lightning color - */ - createLightning(x1, y1, x2, y2, color) { - const segments = 8; - const jitter = 15; - - for (let i = 0; i <= segments; i++) { - const t = i / segments; - const x = x1 + (x2 - x1) * t + (Math.random() - 0.5) * jitter; - const y = y1 + (y2 - y1) * t + (Math.random() - 0.5) * jitter; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('renderable', Components.Renderable(color, 4, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.15, - 0, - 0, - 1.0 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = 4; - renderable.glow = true; - renderable.glowIntensity = 1.0; - } - } - - /** - * Create damage numbers effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} damage - Damage amount - * @param {boolean} isCrit - Whether it's a critical hit - */ - createDamageNumber(x, y, damage, isCrit = false) { - // This would require text rendering support - // Placeholder for future implementation - const color = isCrit ? '#FF00FF' : '#FFFFFF'; - const size = isCrit ? 10 : 6; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity(0, -50)); - particle.addComponent('renderable', Components.Renderable(color, size, 'circle')); - particle.addComponent('particle', Components.Particle( - 1.0, - 0, - -50, - 0.98 - )); - - const particleComp = particle.getComponent('particle'); - particleComp.damageText = Math.floor(damage); - particleComp.isCrit = isCrit; - } - - /** - * Get color variations for particle effects - * @param {string} baseColor - Base color hex - * @param {number} count - Number of variations - * @returns {Array} Array of color variations - */ - getColorVariations(baseColor, count) { - // Neon sci-fi color palette - const neonColors = { - '#FF1493': ['#FF1493', '#FF69B4', '#FF00FF'], // Pink/Magenta - '#00FFFF': ['#00FFFF', '#00BFFF', '#40E0D0'], // Cyan/Aqua - '#FF4500': ['#FF4500', '#FF6347', '#FF8C00'], // Orange/Red - '#00FF00': ['#00FF00', '#32CD32', '#7FFF00'], // Green - '#FFD700': ['#FFD700', '#FFFF00', '#FFA500'], // Yellow/Gold - '#4169E1': ['#4169E1', '#6495ED', '#00BFFF'], // Blue - '#9370DB': ['#9370DB', '#BA55D3', '#DA70D6'], // Purple - '#DC143C': ['#DC143C', '#FF0000', '#8B0000'] // Red/Crimson - }; - - return neonColors[baseColor] || [baseColor, baseColor, baseColor]; - } - - /** - * Create chain lightning effect between points - * @param {Array<{x: number, y: number}>} points - Array of positions - * @param {string} color - Lightning color - */ - createChainLightning(points, color) { - for (let i = 0; i < points.length - 1; i++) { - this.createLightning( - points[i].x, - points[i].y, - points[i + 1].x, - points[i + 1].y, - color - ); - } - } - - /** - * Create beam effect - * @param {number} x1 - Start X position - * @param {number} y1 - Start Y position - * @param {number} x2 - End X position - * @param {number} y2 - End Y position - * @param {string} color - Beam color - * @param {number} width - Beam width - */ - createBeam(x1, y1, x2, y2, color, width = 5) { - const distance = MathUtils.distance(x1, y1, x2, y2); - const segments = Math.floor(distance / 10); - - for (let i = 0; i <= segments; i++) { - const t = i / segments; - const x = x1 + (x2 - x1) * t; - const y = y1 + (y2 - y1) * t; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('renderable', Components.Renderable(color, width, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.1, - 0, - 0, - 1.0 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = width; - renderable.glow = true; - renderable.glowIntensity = 0.8; - } - } - - /** - * Create orbital trail effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} color - Trail color - */ - createOrbitalTrail(x, y, color) { - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('renderable', Components.Renderable(color, 8, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.15, - 0, - 0, - 1.0 - )); - - const renderable = particle.getComponent('renderable'); - renderable.baseSize = 8; - renderable.glow = true; - renderable.glowIntensity = 0.4; - } -} diff --git a/js/systems/PickupSystem.js b/js/systems/PickupSystem.js deleted file mode 100644 index fb14ade..0000000 --- a/js/systems/PickupSystem.js +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @file PickupSystem.js - * @description Handle pickup behavior, magnet attraction, and collection - */ - -class PickupSystem { - constructor(world, gameState) { - this.world = world; - this.gameState = gameState; - this.bobTime = 0; - } - - /** - * Update all pickups - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - this.bobTime += deltaTime; - - const pickups = this.world.getEntitiesByType('pickup'); - const player = this.world.getEntitiesByType('player')[0]; - - if (!player) return; - - for (const pickup of pickups) { - this.updatePickup(pickup, player, deltaTime); - } - } - - /** - * Update individual pickup - * @param {Entity} pickup - Pickup entity - * @param {Entity} player - Player entity - * @param {number} deltaTime - Time elapsed - */ - updatePickup(pickup, player, deltaTime) { - const pickupComp = pickup.getComponent('pickup'); - const pickupPos = pickup.getComponent('position'); - const playerPos = player.getComponent('position'); - const playerComp = player.getComponent('player'); - - if (!pickupComp || !pickupPos || !playerPos || !playerComp) return; - - // Update lifetime - if (pickupComp.lifetime !== undefined) { - pickupComp.lifetime -= deltaTime; - - // Remove expired pickups - if (pickupComp.lifetime <= 0) { - this.world.removeEntity(pickup.id); - return; - } - - // Flash when about to expire - const renderable = pickup.getComponent('renderable'); - if (renderable && pickupComp.lifetime < 3) { - const flash = Math.sin(pickupComp.lifetime * 10) > 0; - renderable.alpha = flash ? 1.0 : 0.3; - } - } - - // Calculate distance to player - const dx = playerPos.x - pickupPos.x; - const dy = playerPos.y - pickupPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Magnet attraction - use player's magnetRange stat plus bonus from luck - const baseMagnetRange = pickupComp.magnetRange || 100; // Default base range - const playerMagnetBonus = playerComp.stats.magnetRange || 0; // Bonus from passives - const magnetRange = (baseMagnetRange + playerMagnetBonus) * (1 + playerComp.stats.luck * 0.5); - - if (distance < magnetRange && !pickupComp.collected) { - // Pull towards player - const pullStrength = 300 + (200 * playerComp.stats.luck); - const normalized = MathUtils.normalize(dx, dy); - - pickupPos.x += normalized.x * pullStrength * deltaTime; - pickupPos.y += normalized.y * pullStrength * deltaTime; - - // Mark as being collected to prevent multiple collections - if (distance < 20) { - pickupComp.collected = true; - this.collectPickup(pickup, player); - } - } - - // Visual bobbing effect - this.applyBobEffect(pickup, deltaTime); - } - - /** - * Apply visual bobbing effect to pickup - * @param {Entity} pickup - Pickup entity - * @param {number} deltaTime - Time elapsed - */ - applyBobEffect(pickup, deltaTime) { - const renderable = pickup.getComponent('renderable'); - if (!renderable) return; - - // Oscillate size slightly for visual effect - const baseSize = renderable.baseSize || renderable.size; - if (!renderable.baseSize) { - renderable.baseSize = baseSize; - } - - const bobAmount = Math.sin(this.bobTime * 3 + pickup.id) * 2; - renderable.size = baseSize + bobAmount; - - // Glow effect - renderable.glow = true; - renderable.glowIntensity = 0.5 + Math.sin(this.bobTime * 2 + pickup.id) * 0.3; - } - - /** - * Collect pickup and apply effects to player - * @param {Entity} pickup - Pickup entity - * @param {Entity} player - Player entity - */ - collectPickup(pickup, player) { - const pickupComp = pickup.getComponent('pickup'); - const playerComp = player.getComponent('player'); - const playerHealth = player.getComponent('health'); - const pickupPos = pickup.getComponent('position'); - - if (!pickupComp || !playerComp) return; - - switch (pickupComp.type) { - case 'xp': - this.collectXP(player, pickupComp.value); - break; - case 'health': - this.collectHealth(playerHealth, pickupComp.value); - break; - case 'noyaux': - this.collectNoyaux(pickupComp.value); - break; - } - - // Create collection particle effect - this.createCollectionEffect(pickupPos.x, pickupPos.y, pickupComp.type); - - // Remove pickup - this.world.removeEntity(pickup.id); - } - - /** - * Collect XP and check for level up - * @param {Entity} player - Player entity - * @param {number} xpValue - XP amount - */ - collectXP(player, xpValue) { - const playerComp = player.getComponent('player'); - - // Apply XP bonus - const finalXP = xpValue * playerComp.stats.xpBonus; - playerComp.xp += finalXP; - - // Check for level up - while (playerComp.xp >= playerComp.xpRequired) { - playerComp.xp -= playerComp.xpRequired; - playerComp.level++; - playerComp.xpRequired = Math.floor(playerComp.xpRequired * 1.2); - - // Update stats - this.gameState.stats.highestLevel = Math.max( - this.gameState.stats.highestLevel, - playerComp.level - ); - - // Trigger level up - this.onLevelUp(player); - } - } - - /** - * Collect health pickup - * @param {Object} health - Health component - * @param {number} healAmount - Amount to heal - */ - collectHealth(health, healAmount) { - if (!health) return; - - health.current = Math.min(health.current + healAmount, health.max); - } - - /** - * Collect Noyaux (meta currency) - * @param {number} amount - Noyaux amount - */ - collectNoyaux(amount) { - this.gameState.stats.noyauxEarned += amount; - } - - /** - * Handle level up - * @param {Entity} player - Player entity - */ - onLevelUp(player) { - const playerPos = player.getComponent('position'); - - // Create level up particle effect - this.createLevelUpEffect(playerPos.x, playerPos.y); - - // Heal player slightly on level up - const health = player.getComponent('health'); - if (health) { - health.current = Math.min(health.current + health.max * 0.2, health.max); - } - - // Pause game and show level up choices - // This would be handled by the main game loop - console.log('Level Up! Now level', player.getComponent('player').level); - } - - /** - * Create collection particle effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} type - Pickup type - */ - createCollectionEffect(x, y, type) { - const colors = { - xp: '#00FFFF', - health: '#00FF00', - noyaux: '#FFD700' - }; - - const color = colors[type] || '#FFFFFF'; - const particleCount = 8; - - for (let i = 0; i < particleCount; i++) { - const angle = (i / particleCount) * Math.PI * 2; - const speed = 100 + Math.random() * 100; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - particle.addComponent('renderable', Components.Renderable(color, 3, 'circle')); - particle.addComponent('particle', Components.Particle( - 0.5, - Math.cos(angle) * speed, - Math.sin(angle) * speed, - 0.95 - )); - } - } - - /** - * Create level up particle effect - * @param {number} x - X position - * @param {number} y - Y position - */ - createLevelUpEffect(x, y) { - const particleCount = 30; - const colors = ['#00FFFF', '#FF00FF', '#FFFF00', '#00FF00']; - - for (let i = 0; i < particleCount; i++) { - const angle = (i / particleCount) * Math.PI * 2; - const speed = 150 + Math.random() * 150; - const color = colors[Math.floor(Math.random() * colors.length)]; - - const particle = this.world.createEntity('particle'); - particle.addComponent('position', Components.Position(x, y)); - particle.addComponent('velocity', Components.Velocity( - Math.cos(angle) * speed, - Math.sin(angle) * speed - )); - particle.addComponent('renderable', Components.Renderable(color, 5, 'circle')); - particle.addComponent('particle', Components.Particle( - 1.5, - Math.cos(angle) * speed, - Math.sin(angle) * speed, - 0.92 - )); - } - } - - /** - * Create pickup entity - * @param {number} x - X position - * @param {number} y - Y position - * @param {string} type - Pickup type ('xp', 'health', 'noyaux') - * @param {number} value - Pickup value - * @returns {Entity} Created pickup - */ - createPickup(x, y, type, value) { - const pickup = this.world.createEntity('pickup'); - - const colors = { - xp: '#00FFFF', - health: '#00FF00', - noyaux: '#FFD700' - }; - - const sizes = { - xp: 6, - health: 8, - noyaux: 10 - }; - - pickup.addComponent('position', Components.Position(x, y)); - pickup.addComponent('collision', Components.Collision(sizes[type] || 6)); - pickup.addComponent('renderable', Components.Renderable( - colors[type] || '#FFFFFF', - sizes[type] || 6, - 'circle' - )); - pickup.addComponent('pickup', Components.Pickup(type, value)); - - // Set lifetime based on type - const pickupComp = pickup.getComponent('pickup'); - if (type === 'xp') { - pickupComp.lifetime = 30; // 30 seconds - } else { - pickupComp.lifetime = 20; // 20 seconds - } - - return pickup; - } -} diff --git a/js/systems/RenderSystem.js b/js/systems/RenderSystem.js deleted file mode 100644 index 48c01a2..0000000 --- a/js/systems/RenderSystem.js +++ /dev/null @@ -1,712 +0,0 @@ -/** - * @file RenderSystem.js - * @description Main rendering system for Space InZader - * Handles all visual rendering including starfield, entities, effects, and UI overlays - */ - -class RenderSystem { - constructor(canvas, world, gameState) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - this.world = world; - this.gameState = gameState; - - // Screen effects reference (set from Game.js) - this.screenEffects = null; - - // Starfield background - this.stars = []; - this.initStarfield(); - - // Performance tracking - this.lastFrameTime = 0; - this.fps = 60; - - // Boss health bar - this.bossHealthAlpha = 0; - this.bossHealthTarget = 0; - } - - /** - * Initialize starfield background with parallax layers - */ - initStarfield() { - this.stars = []; - const layers = [ - { count: 100, speed: 0.2, size: 1, alpha: 0.5 }, - { count: 75, speed: 0.5, size: 1.5, alpha: 0.7 }, - { count: 50, speed: 1.0, size: 2, alpha: 0.9 } - ]; - - layers.forEach(layer => { - for (let i = 0; i < layer.count; i++) { - this.stars.push({ - x: Math.random() * this.canvas.width, - y: Math.random() * this.canvas.height, - speed: layer.speed, - size: layer.size, - alpha: layer.alpha, - twinkle: Math.random() * Math.PI * 2, - twinkleSpeed: 0.02 + Math.random() * 0.03 - }); - } - }); - } - - /** - * Main render loop - * @param {number} deltaTime - Time since last frame in seconds - */ - render(deltaTime) { - this.lastFrameTime = deltaTime; - this.fps = deltaTime > 0 ? 1 / deltaTime : 60; - - // Clear canvas - this.ctx.fillStyle = '#000'; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - - // Only render game elements when playing - if (this.gameState.isState(GameStates.RUNNING) || - this.gameState.isState(GameStates.LEVEL_UP) || - this.gameState.isState(GameStates.PAUSED)) { - - // Save context for screen shake - this.ctx.save(); - - // Apply screen shake if available - if (this.screenEffects) { - this.screenEffects.applyShake(this.ctx); - } - - this.renderStarfield(deltaTime); - this.renderEntities(); - - // Restore context after shake - this.ctx.restore(); - - this.renderBossHealthBar(); - - // Render flash overlay on top - if (this.screenEffects) { - this.screenEffects.renderFlash(this.ctx); - } - } - } - - /** - * Render animated starfield with parallax scrolling - * @param {number} deltaTime - Time delta - */ - renderStarfield(deltaTime) { - this.ctx.save(); - - this.stars.forEach(star => { - // Parallax movement - star.y += star.speed * 60 * deltaTime; - if (star.y > this.canvas.height) { - star.y = 0; - star.x = Math.random() * this.canvas.width; - } - - // Twinkling effect - star.twinkle += star.twinkleSpeed; - const twinkleAlpha = star.alpha * (0.5 + 0.5 * Math.sin(star.twinkle)); - - // Draw star - this.ctx.fillStyle = `rgba(255, 255, 255, ${twinkleAlpha})`; - this.ctx.fillRect(star.x, star.y, star.size, star.size); - }); - - this.ctx.restore(); - } - - /** - * Render all entities in the game world - */ - renderEntities() { - // Render order: particles -> pickups -> projectiles -> enemies -> weather -> player - this.renderParticles(); - this.renderPickups(); - this.renderProjectiles(); - this.renderEnemies(); - this.renderWeatherHazards(); - this.renderPlayer(); - } - - /** - * Render particle effects - */ - renderParticles() { - const particles = this.world.getEntitiesByType('particle'); - - particles.forEach(particle => { - const pos = particle.getComponent('position'); - const render = particle.getComponent('renderable'); - const particleComp = particle.getComponent('particle'); - - if (!pos || !render || !particleComp) return; - - const alpha = particleComp.alpha * (particleComp.lifetime / particleComp.maxLifetime); - - this.ctx.save(); - this.ctx.globalAlpha = alpha; - this.ctx.fillStyle = render.color; - this.ctx.shadowBlur = 10; - this.ctx.shadowColor = render.color; - - this.ctx.beginPath(); - this.ctx.arc(pos.x, pos.y, render.size, 0, Math.PI * 2); - this.ctx.fill(); - - this.ctx.restore(); - }); - } - - /** - * Render pickups (XP, health, etc.) - */ - renderPickups() { - const pickups = this.world.getEntitiesByType('pickup'); - - pickups.forEach(pickup => { - const pos = pickup.getComponent('position'); - const render = pickup.getComponent('renderable'); - const pickupComp = pickup.getComponent('pickup'); - - if (!pos || !render || !pickupComp) return; - - // Floating animation - const time = Date.now() * 0.003; - const offsetY = Math.sin(time + pickup.id) * 3; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y + offsetY); - - // Glow effect - this.ctx.shadowBlur = 15; - this.ctx.shadowColor = render.color; - - // Draw pickup based on type - this.drawShape(render.shape, render.color, render.size, render.rotation); - - this.ctx.restore(); - }); - } - - /** - * Render projectiles - */ - renderProjectiles() { - const projectiles = this.world.getEntitiesByType('projectile'); - - projectiles.forEach(projectile => { - const pos = projectile.getComponent('position'); - const render = projectile.getComponent('renderable'); - const projComp = projectile.getComponent('projectile'); - - if (!pos || !render) return; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y); - - // Trail effect for projectiles - if (projComp && projComp.lifetime > 0) { - const trailLength = 3; - const vel = projectile.getComponent('velocity'); - - if (vel) { - this.ctx.globalAlpha = 0.3; - this.ctx.strokeStyle = render.color; - this.ctx.lineWidth = render.size * 2; - this.ctx.lineCap = 'round'; - - this.ctx.beginPath(); - this.ctx.moveTo(0, 0); - const angle = Math.atan2(vel.vy, vel.vx); - this.ctx.lineTo(-Math.cos(angle) * trailLength, -Math.sin(angle) * trailLength); - this.ctx.stroke(); - this.ctx.globalAlpha = 1; - } - } - - // Glow effect - this.ctx.shadowBlur = 10; - this.ctx.shadowColor = render.color; - - // Draw projectile - this.drawShape(render.shape, render.color, render.size, render.rotation); - - this.ctx.restore(); - }); - } - - /** - * Render enemies with health bars - */ - renderEnemies() { - const enemies = this.world.getEntitiesByType('enemy'); - - enemies.forEach(enemy => { - const pos = enemy.getComponent('position'); - const render = enemy.getComponent('renderable'); - const health = enemy.getComponent('health'); - const enemyComp = enemy.getComponent('enemy'); - - if (!pos || !render) return; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y); - - // Flash effect when damaged - if (health && health.invulnerable && health.invulnerableTime > 0) { - const flashAlpha = Math.sin(Date.now() * 0.05) * 0.5 + 0.5; - this.ctx.globalAlpha = flashAlpha; - } - - // Glow effect based on enemy type - const isBoss = enemy.hasComponent('boss'); - this.ctx.shadowBlur = isBoss ? 25 : 15; - this.ctx.shadowColor = render.color; - - // Draw enemy - this.ctx.rotate(render.rotation); - this.drawShape(render.shape, render.color, render.size, 0); - - this.ctx.restore(); - - // Health bar for enemies - if (health && (isBoss || enemyComp?.baseHealth > 50)) { - this.drawHealthBar(pos.x, pos.y - render.size - 10, health.current, health.max, isBoss); - } - }); - } - - /** - * Render player ship - */ - renderPlayer() { - const players = this.world.getEntitiesByType('player'); - - players.forEach(player => { - const pos = player.getComponent('position'); - const render = player.getComponent('renderable'); - const health = player.getComponent('health'); - const playerComp = player.getComponent('player'); - - if (!pos || !render) return; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y); - - // Render blade halo if active - if (playerComp && playerComp.bladeHalo) { - const orbitRadius = playerComp.stats.orbitRadius || 60; - const haloAngle = playerComp.bladeHalo.angle; - - this.ctx.save(); - this.ctx.globalAlpha = 0.25; - this.ctx.strokeStyle = '#00ffff'; // Cyan - this.ctx.lineWidth = 3; - this.ctx.shadowBlur = 15; - this.ctx.shadowColor = '#00ffff'; - - // Draw rotating halo circle - this.ctx.beginPath(); - this.ctx.arc(0, 0, orbitRadius, 0, Math.PI * 2); - this.ctx.stroke(); - - // Draw rotating blade markers (4 blades) - this.ctx.globalAlpha = 0.6; - for (let i = 0; i < 4; i++) { - const angle = haloAngle + (i * Math.PI / 2); - const x = Math.cos(angle) * orbitRadius; - const y = Math.sin(angle) * orbitRadius; - - this.ctx.fillStyle = '#00ffff'; - this.ctx.beginPath(); - this.ctx.arc(x, y, 4, 0, Math.PI * 2); - this.ctx.fill(); - } - - this.ctx.restore(); - } - - // Invulnerability flashing - if (health && health.invulnerable && health.invulnerableTime > 0) { - const flashAlpha = Math.sin(Date.now() * 0.02) * 0.4 + 0.6; - this.ctx.globalAlpha = flashAlpha; - } - - // Enhanced glow for player - this.ctx.shadowBlur = 20; - this.ctx.shadowColor = render.color; - - // Draw player ship - this.ctx.rotate(render.rotation); - this.drawShape(render.shape, render.color, render.size, 0); - - this.ctx.restore(); - }); - } - - /** - * Draw different shapes (circle, triangle, square) - * @param {string} shape - Shape type - * @param {string} color - Fill color - * @param {number} size - Size/radius - * @param {number} rotation - Additional rotation - */ - drawShape(shape, color, size, rotation = 0) { - this.ctx.save(); - this.ctx.rotate(rotation); - this.ctx.fillStyle = color; - this.ctx.strokeStyle = color; - this.ctx.lineWidth = 2; - - switch (shape) { - case 'circle': - this.ctx.beginPath(); - this.ctx.arc(0, 0, size, 0, Math.PI * 2); - this.ctx.fill(); - break; - - case 'triangle': - this.ctx.beginPath(); - this.ctx.moveTo(0, -size); - this.ctx.lineTo(-size * 0.866, size * 0.5); - this.ctx.lineTo(size * 0.866, size * 0.5); - this.ctx.closePath(); - this.ctx.fill(); - this.ctx.stroke(); - break; - - case 'square': - this.ctx.fillRect(-size, -size, size * 2, size * 2); - this.ctx.strokeRect(-size, -size, size * 2, size * 2); - break; - - case 'diamond': - this.ctx.beginPath(); - this.ctx.moveTo(0, -size); - this.ctx.lineTo(size, 0); - this.ctx.lineTo(0, size); - this.ctx.lineTo(-size, 0); - this.ctx.closePath(); - this.ctx.fill(); - this.ctx.stroke(); - break; - - case 'star': - this.drawStar(size); - break; - - case 'line': - // Draw a line for beam weapons (railgun) - this.ctx.lineWidth = 3; - this.ctx.shadowBlur = 10; - this.ctx.shadowColor = color; - this.ctx.beginPath(); - this.ctx.moveTo(-size / 2, 0); - this.ctx.lineTo(size / 2, 0); - this.ctx.stroke(); - break; - - default: - // Default to circle - this.ctx.beginPath(); - this.ctx.arc(0, 0, size, 0, Math.PI * 2); - this.ctx.fill(); - } - - this.ctx.restore(); - } - - /** - * Draw a star shape - * @param {number} size - Star radius - */ - drawStar(size) { - const spikes = 5; - const outerRadius = size; - const innerRadius = size * 0.5; - - this.ctx.beginPath(); - for (let i = 0; i < spikes * 2; i++) { - const radius = i % 2 === 0 ? outerRadius : innerRadius; - const angle = (i * Math.PI) / spikes - Math.PI / 2; - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - if (i === 0) { - this.ctx.moveTo(x, y); - } else { - this.ctx.lineTo(x, y); - } - } - this.ctx.closePath(); - this.ctx.fill(); - this.ctx.stroke(); - } - - /** - * Draw health bar above entity - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} current - Current health - * @param {number} max - Max health - * @param {boolean} isBoss - Is boss health bar - */ - drawHealthBar(x, y, current, max, isBoss = false) { - const width = isBoss ? 200 : 40; - const height = isBoss ? 8 : 4; - const healthPercent = Math.max(0, current / max); - - this.ctx.save(); - this.ctx.translate(x - width / 2, y); - - // Background - this.ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; - this.ctx.fillRect(0, 0, width, height); - - // Border - this.ctx.strokeStyle = isBoss ? '#ff0000' : '#00ffff'; - this.ctx.lineWidth = 1; - this.ctx.strokeRect(0, 0, width, height); - - // Health fill - const gradient = this.ctx.createLinearGradient(0, 0, width, 0); - if (healthPercent > 0.5) { - gradient.addColorStop(0, '#00ff00'); - gradient.addColorStop(1, '#88ff00'); - } else if (healthPercent > 0.25) { - gradient.addColorStop(0, '#ffff00'); - gradient.addColorStop(1, '#ff8800'); - } else { - gradient.addColorStop(0, '#ff0000'); - gradient.addColorStop(1, '#ff0000'); - } - - this.ctx.fillStyle = gradient; - this.ctx.fillRect(1, 1, (width - 2) * healthPercent, height - 2); - - this.ctx.restore(); - } - - /** - * Render boss health bar at top of screen - */ - renderBossHealthBar() { - const bosses = this.world.getEntitiesByType('enemy').filter(e => e.hasComponent('boss')); - - if (bosses.length > 0) { - const boss = bosses[0]; - const health = boss.getComponent('health'); - const bossComp = boss.getComponent('boss'); - const enemyComp = boss.getComponent('enemy'); - - if (health) { - this.bossHealthTarget = 1; - this.bossHealthAlpha = Math.min(1, this.bossHealthAlpha + 0.05); - - const barWidth = this.canvas.width * 0.6; - const barHeight = 30; - const x = (this.canvas.width - barWidth) / 2; - const y = 20; - const healthPercent = health.current / health.max; - - this.ctx.save(); - this.ctx.globalAlpha = this.bossHealthAlpha; - - // Background - this.ctx.fillStyle = 'rgba(10, 10, 26, 0.9)'; - this.ctx.fillRect(x - 10, y - 10, barWidth + 20, barHeight + 30); - - // Border with glow - this.ctx.strokeStyle = '#ff00ff'; - this.ctx.lineWidth = 3; - this.ctx.shadowBlur = 15; - this.ctx.shadowColor = '#ff00ff'; - this.ctx.strokeRect(x - 10, y - 10, barWidth + 20, barHeight + 30); - - // Boss name - this.ctx.shadowBlur = 0; - this.ctx.fillStyle = '#ff00ff'; - this.ctx.font = 'bold 16px "Courier New"'; - this.ctx.textAlign = 'center'; - this.ctx.fillText('BOSS', this.canvas.width / 2, y - 15); - - // Health bar background - this.ctx.fillStyle = 'rgba(255, 0, 0, 0.3)'; - this.ctx.fillRect(x, y, barWidth, barHeight); - - // Health bar fill - const gradient = this.ctx.createLinearGradient(x, y, x + barWidth, y); - gradient.addColorStop(0, '#ff0000'); - gradient.addColorStop(0.5, '#ff00ff'); - gradient.addColorStop(1, '#ff0000'); - - this.ctx.fillStyle = gradient; - this.ctx.fillRect(x, y, barWidth * healthPercent, barHeight); - - // Health text - this.ctx.fillStyle = '#fff'; - this.ctx.font = 'bold 14px "Courier New"'; - this.ctx.textAlign = 'center'; - this.ctx.fillText( - `${Math.ceil(health.current)} / ${health.max}`, - this.canvas.width / 2, - y + barHeight / 2 + 5 - ); - - this.ctx.restore(); - } - } else { - // Fade out boss health bar - this.bossHealthTarget = 0; - this.bossHealthAlpha = Math.max(0, this.bossHealthAlpha - 0.05); - } - } - - /** - * Clear canvas - */ - clear() { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - } - - /** - * Reset starfield - */ - reset() { - this.initStarfield(); - this.bossHealthAlpha = 0; - this.bossHealthTarget = 0; - } - - /** - * Render weather hazards (meteors, black holes) - */ - renderWeatherHazards() { - // Render meteors - const meteors = this.world.getEntitiesByType('meteor'); - meteors.forEach(meteor => { - const pos = meteor.getComponent('position'); - const meteorComp = meteor.getComponent('meteor'); - - if (!pos || !meteorComp) return; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y); - this.ctx.rotate(meteorComp.rotation); - - // Draw meteor as rocky brown circle with darker patches - const gradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, meteorComp.size); - gradient.addColorStop(0, '#A0522D'); - gradient.addColorStop(0.5, '#8B4513'); - gradient.addColorStop(1, '#654321'); - - this.ctx.fillStyle = gradient; - this.ctx.shadowBlur = 10; - this.ctx.shadowColor = '#FF4500'; - - this.ctx.beginPath(); - this.ctx.arc(0, 0, meteorComp.size, 0, Math.PI * 2); - this.ctx.fill(); - - // Add some crater-like details - this.ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; - for (let i = 0; i < 3; i++) { - const angle = (i / 3) * Math.PI * 2; - const dist = meteorComp.size * 0.4; - const craterSize = meteorComp.size * 0.2; - this.ctx.beginPath(); - this.ctx.arc( - Math.cos(angle) * dist, - Math.sin(angle) * dist, - craterSize, - 0, - Math.PI * 2 - ); - this.ctx.fill(); - } - - this.ctx.restore(); - }); - - // Render black holes - const blackHoles = this.world.getEntitiesByType('black_hole'); - blackHoles.forEach(blackHole => { - const pos = blackHole.getComponent('position'); - const blackHoleComp = blackHole.getComponent('black_hole'); - - if (!pos || !blackHoleComp) return; - - // Calculate scale based on age (grows during grace period) - const age = blackHoleComp.age || 0; - const gracePeriod = blackHoleComp.gracePeriod || 1.0; - const scale = age < gracePeriod ? (age / gracePeriod) : 1.0; - - this.ctx.save(); - this.ctx.translate(pos.x, pos.y); - this.ctx.scale(scale, scale); - - // Draw swirling vortex effect - const numRings = 6; - for (let i = 0; i < numRings; i++) { - const ringProgress = i / numRings; - const radius = blackHoleComp.pullRadius * (1 - ringProgress * 0.8); - const alpha = (1 - ringProgress) * 0.3 * scale; // Fade in during grace period - const rotation = blackHoleComp.rotation + ringProgress * Math.PI; - - this.ctx.save(); - this.ctx.rotate(rotation); - this.ctx.globalAlpha = alpha; - - // Create spiral gradient - const gradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, radius); - gradient.addColorStop(0, 'rgba(148, 0, 211, 0.8)'); - gradient.addColorStop(0.5, 'rgba(75, 0, 130, 0.5)'); - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); - - this.ctx.fillStyle = gradient; - this.ctx.beginPath(); - this.ctx.arc(0, 0, radius, 0, Math.PI * 2); - this.ctx.fill(); - - this.ctx.restore(); - } - - // Draw core (event horizon) - const coreGradient = this.ctx.createRadialGradient( - 0, 0, 0, - 0, 0, blackHoleComp.damageRadius - ); - coreGradient.addColorStop(0, '#000000'); - coreGradient.addColorStop(0.7, '#1a001a'); - coreGradient.addColorStop(1, '#4B0082'); - - this.ctx.fillStyle = coreGradient; - this.ctx.shadowBlur = 30 * scale; - this.ctx.shadowColor = '#9400D3'; - this.ctx.beginPath(); - this.ctx.arc(0, 0, blackHoleComp.damageRadius, 0, Math.PI * 2); - this.ctx.fill(); - - // Draw accretion disk particles - this.ctx.globalAlpha = 0.6 * scale; - for (let i = 0; i < 20; i++) { - const angle = (i / 20) * Math.PI * 2 + blackHoleComp.rotation * 2; - const dist = blackHoleComp.damageRadius * 1.5 + Math.sin(blackHoleComp.rotation + i) * 20; - const x = Math.cos(angle) * dist; - const y = Math.sin(angle) * dist; - - this.ctx.fillStyle = i % 3 === 0 ? '#9400D3' : '#4B0082'; - this.ctx.beginPath(); - this.ctx.arc(x, y, 2, 0, Math.PI * 2); - this.ctx.fill(); - } - - this.ctx.restore(); - }); - } -} diff --git a/js/systems/SpawnerSystem.js b/js/systems/SpawnerSystem.js deleted file mode 100644 index f9aa033..0000000 --- a/js/systems/SpawnerSystem.js +++ /dev/null @@ -1,647 +0,0 @@ -/** - * @file SpawnerSystem.js - * @description Director system for budget-based enemy spawning with difficulty scaling - */ - -class SpawnerSystem { - constructor(world, gameState, canvas) { - this.world = world; - this.gameState = gameState; - this.canvas = canvas; - - // Spawning state - this.spawnBudget = 0; - this.spawnTimer = 0; - this.maxEnemiesOnScreen = 250; - - // Wave tracking - this.waveNumber = 1; - this.bossSpawnedThisWave = false; - this.eliteSpawnedThisWave = false; - - // Boss tracking (legacy time-based) - this.bossSpawned = { - 15: false, - 20: false - }; - - // Difficulty scaling - this.difficultyMultiplier = 1.0; - } - - /** - * Update spawn system - * @param {number} deltaTime - Time elapsed since last frame - * @param {boolean} canSpawn - Whether spawning is allowed (from WaveSystem) - */ - update(deltaTime, canSpawn = true) { - const gameTime = this.gameState.stats.time; - - // Get current wave configuration - const wave = this.getCurrentWave(gameTime); - - // Accumulate spawn budget exponentially - const budgetGain = wave.budgetPerSecond * deltaTime * this.difficultyMultiplier; - this.spawnBudget += budgetGain; - - // Update spawn timer - this.spawnTimer += deltaTime; - - // Spawn enemies when timer expires and we have budget (and spawning is allowed) - if (canSpawn && this.spawnTimer >= wave.spawnInterval) { - this.spawnTimer = 0; - this.spawnEnemies(wave, gameTime); - } - } - - /** - * Check and spawn bosses at milestone times - * @param {number} gameTime - Current game time in seconds - */ - checkBossSpawns(gameTime) { - // 15 minute boss - if (gameTime >= 900 && !this.bossSpawned[15]) { - this.spawnBoss(gameTime); - this.bossSpawned[15] = true; - } - - // 20 minute boss - if (gameTime >= 1200 && !this.bossSpawned[20]) { - this.spawnBoss(gameTime); - this.bossSpawned[20] = true; - } - } - - /** - * Spawn boss enemy - * @param {number} gameTime - Current game time - * @param {string} bossType - Type of boss to spawn - */ - spawnBoss(gameTime, bossType = 'boss') { - const player = this.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerPos = player.getComponent('position'); - const spawnPos = this.getSpawnPosition(playerPos.x, playerPos.y); - - // Get boss data and scale it - const bossData = this.getEnemyData(bossType); - const scaledData = this.scaleEnemyStats(bossData, gameTime); - - this.createEnemy(spawnPos.x, spawnPos.y, scaledData, true); - - logger.info('SpawnerSystem', `Boss spawned: ${bossType} at wave ${this.waveNumber}`); - } - - /** - * Spawn elite enemy - * @param {number} gameTime - Current game time - */ - spawnElite(gameTime) { - const player = this.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerPos = player.getComponent('position'); - const spawnPos = this.getSpawnPosition(playerPos.x, playerPos.y); - - // Spawn elite with extra scaling - const eliteData = this.getEnemyData('elite'); - const scaledData = this.scaleEnemyStats(eliteData, gameTime); - - this.createEnemy(spawnPos.x, spawnPos.y, scaledData, false); - - logger.info('SpawnerSystem', `Elite spawned at wave ${this.waveNumber}`); - } - - /** - * Calculate difficulty multipliers based on game time and wave - * @param {number} gameTime - Current game time in seconds - * @param {number} waveNumber - Current wave number - * @returns {{enemyCountMult: number, enemyHealthMult: number, enemySpeedMult: number}} - */ - calculateDifficultyMultipliers(gameTime, waveNumber) { - const timeMinutes = gameTime / 60; - - // Early game easing curve (waves 1-6): smoother progression - let enemyCountMult, enemyHealthMult, enemySpeedMult; - - if (waveNumber <= 6) { - // Linear interpolation from easier values to normal - const t = (waveNumber - 1) / 5; // 0.0 at wave 1, 1.0 at wave 6 - - // Base count multiplier from time, but eased for early waves - const baseCountMult = Math.min(4.0, 1 + (timeMinutes * 0.20)); - const earlyCountEasing = 0.6 + (0.4 * t); // 0.6 -> 1.0 - enemyCountMult = baseCountMult * earlyCountEasing; - - // Health easing: 0.7 -> 1.0 over waves 1-6 - const normalHealthMult = Math.min(5.0, 1 + (waveNumber * 0.15)); - const earlyHealthEasing = 0.7 + (0.3 * t); - enemyHealthMult = normalHealthMult * earlyHealthEasing; - - // Speed easing: 0.85 -> 1.0 over waves 1-6 - const normalSpeedMult = Math.min(2.0, 1 + (waveNumber * 0.05)); - const earlySpeedEasing = 0.85 + (0.15 * t); - enemySpeedMult = normalSpeedMult * earlySpeedEasing; - - // Debug logging (optional - can be enabled via console: window.debugDifficulty = true) - if (window.debugDifficulty) { - console.log(`[Wave ${waveNumber}] Difficulty: Count=${enemyCountMult.toFixed(2)}, Health=${enemyHealthMult.toFixed(2)}, Speed=${enemySpeedMult.toFixed(2)}`); - } - } else { - // Normal scaling from wave 7+ - // WAVE 8+ DIFFICULTY RAMP: Increased speed, HP, count, and special enemies - const wave8Bonus = waveNumber >= 8 ? (waveNumber - 7) : 0; - - enemyCountMult = Math.min(4.0, 1 + (timeMinutes * 0.20) + (wave8Bonus * 0.06)); // +6% per wave after 8 - enemyHealthMult = Math.min(5.0, 1 + (waveNumber * 0.15) + (wave8Bonus * 0.05)); // +5% HP per wave after 8 - enemySpeedMult = Math.min(2.0, 1 + (waveNumber * 0.05) + (wave8Bonus * 0.03)); // +3% speed per wave after 8 - - // Debug logging - if (window.debugDifficulty) { - console.log(`[Wave ${waveNumber}] Difficulty: Count=${enemyCountMult.toFixed(2)}, Health=${enemyHealthMult.toFixed(2)}, Speed=${enemySpeedMult.toFixed(2)} ${wave8Bonus > 0 ? `(+WAVE8 RAMP)` : ''}`); - } - } - - return { enemyCountMult, enemyHealthMult, enemySpeedMult }; - } - - /** - * Set wave number from WaveSystem - * @param {number} waveNumber - Current wave number - */ - setWaveNumber(waveNumber) { - if (this.waveNumber !== waveNumber) { - this.waveNumber = waveNumber; - this.bossSpawnedThisWave = false; - this.eliteSpawnedThisWave = false; - } - } - - /** - * Trigger wave-based spawns (boss/elite) - * @param {number} gameTime - Current game time - */ - triggerWaveSpawns(gameTime) { - // Check for boss spawn (every 5 waves) - if (this.waveNumber % 5 === 0 && !this.bossSpawnedThisWave) { - const bossTypes = ['boss', 'tank_boss', 'swarm_boss', 'sniper_boss']; - const bossType = bossTypes[Math.floor(this.waveNumber / 5) % bossTypes.length]; - this.spawnBoss(gameTime, bossType); - this.bossSpawnedThisWave = true; - } - // Check for elite spawn (every 3 waves) - else if (this.waveNumber % 3 === 0 && !this.eliteSpawnedThisWave) { - this.spawnElite(gameTime); - this.eliteSpawnedThisWave = true; - } - } - - /** - * Spawn enemies based on budget and wave configuration - * @param {Object} wave - Wave configuration - * @param {number} gameTime - Current game time - */ - spawnEnemies(wave, gameTime) { - // Check enemy count limit (soft cap) - const currentEnemies = this.world.getEntitiesByType('enemy').length; - if (currentEnemies >= this.maxEnemiesOnScreen) { - return; - } - - // Calculate how much budget to spend this spawn - const spendBudget = Math.min(this.spawnBudget, wave.budgetPerSecond * 2); - if (spendBudget < 1) return; - - const player = this.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerPos = player.getComponent('position'); - - // Select enemies to spawn based on budget - const enemiesToSpawn = this.selectEnemySpawn(spendBudget, wave.enemyPool); - - // Spawn selected enemies - for (const enemyId of enemiesToSpawn) { - // Check if we're still under cap - if (this.world.getEntitiesByType('enemy').length >= this.maxEnemiesOnScreen) { - break; - } - - const enemyData = this.getEnemyData(enemyId); - const scaledData = this.scaleEnemyStats(enemyData, gameTime); - const spawnPos = this.getSpawnPosition(playerPos.x, playerPos.y); - - this.createEnemy(spawnPos.x, spawnPos.y, scaledData, false); - this.spawnBudget -= enemyData.spawnCost; - } - } - - /** - * Create enemy entity - * @param {number} x - X position - * @param {number} y - Y position - * @param {Object} enemyData - Enemy data - * @param {boolean} isBoss - Whether this is a boss - * @returns {Entity} Created enemy - */ - createEnemy(x, y, enemyData, isBoss) { - const enemy = this.world.createEntity('enemy'); - - enemy.addComponent('position', Components.Position(x, y)); - enemy.addComponent('velocity', Components.Velocity(0, 0)); - enemy.addComponent('health', Components.Health(enemyData.health, enemyData.health)); - enemy.addComponent('collision', Components.Collision(enemyData.size)); - enemy.addComponent('renderable', Components.Renderable( - enemyData.color, - enemyData.size, - 'circle' - )); - enemy.addComponent('enemy', Components.Enemy( - enemyData.aiType, - enemyData.health, - enemyData.damage, - enemyData.speed, - enemyData.xpValue - )); - - // Set enemy data properties - const enemyComp = enemy.getComponent('enemy'); - enemyComp.attackPattern = enemyData.attackPattern; - enemyComp.armor = enemyData.armor || 0; - enemyComp.splitCount = enemyData.splitCount || 0; - enemyComp.splitType = enemyData.splitType || null; - - // Add boss component if boss - if (isBoss) { - enemy.addComponent('boss', Components.Boss( - 1, - ['chase', 'spiral', 'enrage'] - )); - } - - return enemy; - } - - /** - * Get current wave configuration based on game time - * @param {number} gameTime - Current game time in seconds - * @returns {Object} Wave configuration - */ - getCurrentWave(gameTime) { - if (gameTime < 300) { - // 0-5 minutes - Early - return { - budgetPerSecond: 2 + (gameTime / 60) * 0.5, - enemyPool: ['drone_basique', 'chasseur_rapide'], - spawnInterval: 2.0 - }; - } else if (gameTime < 600) { - // 5-10 minutes - Mid - return { - budgetPerSecond: 4 + ((gameTime - 300) / 60) * 0.8, - enemyPool: ['drone_basique', 'chasseur_rapide', 'tireur', 'tank'], - spawnInterval: 1.5 - }; - } else if (gameTime < 1200) { - // 10-20 minutes - Late - return { - budgetPerSecond: 8 + ((gameTime - 600) / 60) * 1.2, - enemyPool: ['chasseur_rapide', 'tireur', 'tank', 'elite'], - spawnInterval: 1.0 - }; - } else { - // 20+ minutes - Endgame - return { - budgetPerSecond: 15 + ((gameTime - 1200) / 60) * 2.0, - enemyPool: ['tank', 'elite'], - spawnInterval: 0.8 - }; - } - } - - /** - * Select enemies to spawn based on available budget - * @param {number} budget - Available spawn budget - * @param {Array} enemyPool - Available enemy types - * @returns {Array} Array of enemy IDs to spawn - */ - selectEnemySpawn(budget, enemyPool) { - const enemies = []; - let remainingBudget = budget; - - // Sort pool by spawn cost - const sortedPool = enemyPool - .map(id => { - const data = this.getEnemyData(id); - return { id, cost: data.spawnCost }; - }) - .sort((a, b) => b.cost - a.cost); - - // Fill budget efficiently - while (remainingBudget >= 1) { - const affordable = sortedPool.filter(e => e.cost <= remainingBudget); - if (affordable.length === 0) break; - - // Weighted random selection (prefer variety) - const selected = affordable[Math.floor(Math.random() * affordable.length)]; - enemies.push(selected.id); - remainingBudget -= selected.cost; - } - - return enemies; - } - - /** - * Get enemy data by ID - * @param {string} enemyId - Enemy identifier - * @returns {Object} Enemy data - */ - getEnemyData(enemyId) { - const enemies = { - drone_basique: { - id: 'drone_basique', - name: 'Drone Basique', - health: 20, - damage: 10, - speed: 100, - xpValue: 5, - aiType: 'chase', - size: 12, - color: '#FF1493', - spawnCost: 1, - attackPattern: { type: 'none' }, - armor: 0 - }, - chasseur_rapide: { - id: 'chasseur_rapide', - name: 'Chasseur Rapide', - health: 12, - damage: 15, - speed: 180, - xpValue: 8, - aiType: 'weave', - size: 10, - color: '#00FF00', - spawnCost: 2, - attackPattern: { type: 'none' }, - armor: 0 - }, - tank: { - id: 'tank', - name: 'Tank', - health: 80, - damage: 20, - speed: 60, - xpValue: 15, - aiType: 'chase', - size: 20, - color: '#4169E1', - spawnCost: 5, - attackPattern: { type: 'none' }, - armor: 5 - }, - tireur: { - id: 'tireur', - name: 'Tireur', - health: 25, - damage: 8, - speed: 80, - xpValue: 12, - aiType: 'kite', - size: 11, - color: '#FFD700', - spawnCost: 3, - attackPattern: { - type: 'shoot', - damage: 12, - cooldown: 2.0, - range: 300, - projectileSpeed: 250, - projectileColor: '#FFFF00' - }, - armor: 0 - }, - elite: { - id: 'elite', - name: 'Élite', - health: 150, - damage: 25, - speed: 120, - xpValue: 40, - aiType: 'aggressive', - size: 18, - color: '#FF4500', - spawnCost: 12, - attackPattern: { - type: 'shoot', - damage: 20, - cooldown: 1.5, - range: 250, - projectileSpeed: 300, - projectileColor: '#FF0000' - }, - armor: 3, - splitCount: 2, - splitType: 'drone_basique' - }, - boss: { - id: 'boss', - name: 'Boss', - health: 1000, - damage: 40, - speed: 90, - xpValue: 200, - aiType: 'boss', - size: 40, - color: '#DC143C', - spawnCost: 100, - attackPattern: { - type: 'special', - damage: 30, - cooldown: 0.8, - range: 400, - projectileSpeed: 350, - projectileColor: '#FF00FF' - }, - armor: 10, - splitCount: 5, - splitType: 'elite' - }, - tank_boss: { - id: 'tank_boss', - name: 'Tank Boss', - health: 2500, - damage: 60, - speed: 50, - xpValue: 300, - aiType: 'chase', - size: 50, - color: '#4169E1', - spawnCost: 150, - attackPattern: { - type: 'melee', - damage: 80, - cooldown: 2.0, - range: 60 - }, - armor: 25, - splitCount: 8, - splitType: 'tank' - }, - swarm_boss: { - id: 'swarm_boss', - name: 'Swarm Boss', - health: 800, - damage: 25, - speed: 120, - xpValue: 250, - aiType: 'weave', - size: 35, - color: '#00FF00', - spawnCost: 120, - attackPattern: { - type: 'shoot', - damage: 20, - cooldown: 0.5, - range: 350, - projectileSpeed: 300, - projectileColor: '#00FF00' - }, - armor: 5, - splitCount: 15, - splitType: 'chasseur_rapide' - }, - sniper_boss: { - id: 'sniper_boss', - name: 'Sniper Boss', - health: 1200, - damage: 30, - speed: 80, - xpValue: 280, - aiType: 'kite', - size: 38, - color: '#FFD700', - spawnCost: 130, - attackPattern: { - type: 'shoot', - damage: 50, - cooldown: 1.5, - range: 600, - projectileSpeed: 500, - projectileColor: '#FFFF00' - }, - armor: 8, - splitCount: 6, - splitType: 'tireur' - } - }; - - return enemies[enemyId] || enemies.drone_basique; - } - - /** - * Scale enemy stats based on time/difficulty - * @param {Object} enemyData - Base enemy data - * @param {number} gameTime - Current game time in seconds - * @returns {Object} Scaled enemy data - */ - scaleEnemyStats(enemyData, gameTime) { - // Get multipliers from wave and time - const multipliers = this.calculateDifficultyMultipliers(gameTime, this.waveNumber); - - // Exponential scaling: +30% every 5 minutes (legacy) - const timeFactor = 1 + (gameTime / 300) * 0.3; - - // Combine all scalings - const healthScaling = multipliers.enemyHealthMult * this.difficultyMultiplier; - const damageScaling = timeFactor * this.difficultyMultiplier; - const speedScaling = multipliers.enemySpeedMult; - - const scaled = { ...enemyData }; - scaled.health = Math.floor(enemyData.health * healthScaling); - scaled.damage = Math.floor(enemyData.damage * damageScaling); - scaled.speed = Math.floor(enemyData.speed * speedScaling); - scaled.xpValue = Math.floor(enemyData.xpValue * timeFactor); - - if (scaled.attackPattern.damage) { - scaled.attackPattern = { - ...scaled.attackPattern, - damage: Math.floor(scaled.attackPattern.damage * damageScaling) - }; - } - - return scaled; - } - - /** - * Calculate spawn position on screen edge - * @param {number} playerX - Player X position - * @param {number} playerY - Player Y position - * @returns {{x: number, y: number}} Spawn position - */ - getSpawnPosition(playerX, playerY) { - const margin = 50; - const edge = Math.floor(Math.random() * 4); - - const screenWidth = this.canvas.width; - const screenHeight = this.canvas.height; - - switch (edge) { - case 0: // Top - return { - x: MathUtils.clamp( - playerX + (Math.random() - 0.5) * screenWidth, - margin, - screenWidth - margin - ), - y: -margin - }; - case 1: // Right - return { - x: screenWidth + margin, - y: MathUtils.clamp( - playerY + (Math.random() - 0.5) * screenHeight, - margin, - screenHeight - margin - ) - }; - case 2: // Bottom - return { - x: MathUtils.clamp( - playerX + (Math.random() - 0.5) * screenWidth, - margin, - screenWidth - margin - ), - y: screenHeight + margin - }; - case 3: // Left - return { - x: -margin, - y: MathUtils.clamp( - playerY + (Math.random() - 0.5) * screenHeight, - margin, - screenHeight - margin - ) - }; - default: - return { x: playerX, y: playerY }; - } - } - - /** - * Reset spawner state for new game - */ - reset() { - this.spawnBudget = 0; - this.spawnTimer = 0; - this.waveNumber = 1; - this.bossSpawnedThisWave = false; - this.eliteSpawnedThisWave = false; - this.bossSpawned = { - 15: false, - 20: false - }; - this.difficultyMultiplier = 1.0; - } -} diff --git a/js/systems/SynergySystem.js b/js/systems/SynergySystem.js deleted file mode 100644 index 3075a6c..0000000 --- a/js/systems/SynergySystem.js +++ /dev/null @@ -1,280 +0,0 @@ -/** - * @fileoverview Synergy system for Space InZader - * Tracks and applies synergy bonuses based on player's equipped items - */ - -class SynergySystem { - constructor(world, player) { - this.world = world; - this.player = player; - this.activeSynergies = new Map(); // synergyId -> {count, threshold} - this.tagCounts = new Map(); - - // Event tracking for synergy effects - this.critExplosionCooldown = 0; - this.chainExplosionTargets = new Set(); - } - - /** - * Update synergy system - * @param {number} deltaTime - Time since last update in ms - */ - update(deltaTime) { - // Update cooldowns - if (this.critExplosionCooldown > 0) { - this.critExplosionCooldown -= deltaTime; - } - - // Recalculate synergies (done less frequently or on item change) - this.recalculateSynergies(); - - // Apply active synergy effects - this.applyActiveSynergies(); - } - - /** - * Recalculate which synergies are active - */ - recalculateSynergies() { - if (!this.player) return; - - const playerComp = this.player.getComponent('player'); - if (!playerComp) return; - - // Clear tag counts - this.tagCounts.clear(); - - // Count tags from weapons - if (playerComp.weapons) { - playerComp.weapons.forEach(weapon => { - if (!weapon || !weapon.type) return; - const weaponData = WeaponData.getWeaponData(weapon.type); - if (weaponData && weaponData.tags) { - weaponData.tags.forEach(tag => { - this.tagCounts.set(tag, (this.tagCounts.get(tag) || 0) + 1); - }); - } - }); - } - - // Count tags from passives - if (playerComp.passives) { - playerComp.passives.forEach(passive => { - if (!passive || !passive.id) return; - const passiveData = PassiveData.getPassiveData(passive.id); - if (passiveData && passiveData.tags) { - passiveData.tags.forEach(tag => { - this.tagCounts.set(tag, (this.tagCounts.get(tag) || 0) + 1); - }); - } - }); - } - - // Check each synergy - this.activeSynergies.clear(); - SynergyData.getAllSynergies().forEach(synergy => { - let count = 0; - synergy.tagsCounted.forEach(tag => { - count += this.tagCounts.get(tag) || 0; - }); - - if (count >= synergy.thresholds[0]) { - // Determine threshold: 6, 4, or 2 - let threshold = 2; - if (synergy.thresholds.length >= 3 && count >= synergy.thresholds[2]) { - threshold = 6; - } else if (synergy.thresholds.length >= 2 && count >= synergy.thresholds[1]) { - threshold = 4; - } - this.activeSynergies.set(synergy.id, { count, threshold }); - } - }); - } - - /** - * Apply active synergy bonuses to player - */ - applyActiveSynergies() { - if (!this.player) return; - const playerComp = this.player.getComponent('player'); - if (!playerComp) return; - - // Initialize synergy bonuses object if not exists - if (!playerComp.synergyBonuses) { - playerComp.synergyBonuses = {}; - } - - // Reset synergy bonuses - playerComp.synergyBonuses = { - lifesteal: 0, - critDamage: 0, - explosionRadius: 0, - coolingRate: 0, - dashCooldown: 0, - summonCap: 0, - damageMultiplier: 0, - damageTakenMultiplier: 0, - rangeMultiplier: 0, - magnetRange: 0, - maxHealthMultiplier: 0, - speedMultiplier: 0, - heatGenerationMultiplier: 0, - selfExplosionDamage: 0, - critChance: 0, - // Mechanic flags - onEliteKillHeal: 0, - critExplosion: null, - chainExplosion: null, - damageRamp: null, - dashInvuln: null, - summonInherit: null - }; - - // Apply active synergies - this.activeSynergies.forEach((data, synergyId) => { - const synergy = SynergyData.getSynergy(synergyId); - if (!synergy) return; - - // Select appropriate bonus based on threshold - let bonus; - if (data.threshold === 6 && synergy.bonus6) { - bonus = synergy.bonus6; - } else if (data.threshold === 4 && synergy.bonus4) { - bonus = synergy.bonus4; - } else { - bonus = synergy.bonus2; - } - - if (bonus.type === 'stat_add') { - if (typeof playerComp.synergyBonuses[bonus.stat] === 'number') { - playerComp.synergyBonuses[bonus.stat] += bonus.value; - } else { - playerComp.synergyBonuses[bonus.stat] = bonus.value; - } - } else if (bonus.type === 'stat_add_multi') { - // Apply multiple stats at once - Object.entries(bonus.stats).forEach(([stat, value]) => { - if (typeof playerComp.synergyBonuses[stat] === 'number') { - playerComp.synergyBonuses[stat] += value; - } else { - playerComp.synergyBonuses[stat] = value; - } - }); - } else if (bonus.type === 'event') { - // Store event-based bonuses for combat system to use - if (bonus.event === 'on_elite_kill') { - playerComp.synergyBonuses.onEliteKillHeal = bonus.effect.value; - } - } else if (bonus.type === 'mechanic') { - // Store mechanic details for combat system - playerComp.synergyBonuses[bonus.mechanic] = bonus.effect; - } - }); - } - - /** - * Get list of currently active synergies - * @returns {Array<{synergy: Object, count: number, threshold: number}>} - */ - getActiveSynergies() { - return Array.from(this.activeSynergies.entries()).map(([id, data]) => ({ - synergy: SynergyData.getSynergy(id), - count: data.count, - threshold: data.threshold - })); - } - - /** - * Trigger synergy event (called by combat system) - * @param {string} event - Event type (e.g., 'on_elite_kill', 'on_crit') - * @param {Object} context - Event context data - */ - triggerEvent(event, context = {}) { - if (!this.player) return; - const playerComp = this.player.getComponent('player'); - if (!playerComp || !playerComp.synergyBonuses) return; - - switch (event) { - case 'on_elite_kill': - if (playerComp.synergyBonuses.onEliteKillHeal > 0) { - const healAmount = playerComp.maxHealth * playerComp.synergyBonuses.onEliteKillHeal; - playerComp.health = Math.min( - playerComp.maxHealth, - playerComp.health + healAmount - ); - } - break; - - case 'on_crit': - if (playerComp.synergyBonuses.critExplosion && this.critExplosionCooldown <= 0) { - this.critExplosionCooldown = playerComp.synergyBonuses.critExplosion.cooldown; - // Create explosion at target position - if (context.position && context.damage && this.world.particleSystem) { - const effect = playerComp.synergyBonuses.critExplosion; - this.createSynergyExplosion( - context.position.x, - context.position.y, - effect.radius, - context.damage * effect.damage - ); - } - } - break; - } - } - - /** - * Create synergy explosion effect - * @param {number} x - X position - * @param {number} y - Y position - * @param {number} radius - Explosion radius - * @param {number} damage - Explosion damage - */ - createSynergyExplosion(x, y, radius, damage) { - if (!this.world.particleSystem) return; - - // Create visual explosion - for (let i = 0; i < 12; i++) { - const angle = (Math.PI * 2 * i) / 12; - this.world.particleSystem.createParticle({ - x: x, - y: y, - vx: Math.cos(angle) * 150, - vy: Math.sin(angle) * 150, - lifetime: 300, - color: '#FFD700', - size: 3 - }); - } - - // Damage enemies in radius - const enemies = this.world.getEntitiesWithComponent('enemy'); - enemies.forEach(enemy => { - const enemyPos = enemy.getComponent('position'); - if (!enemyPos) return; - - const dx = enemyPos.x - x; - const dy = enemyPos.y - y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist <= radius) { - const enemyHealth = enemy.getComponent('health'); - if (enemyHealth) { - enemyHealth.current -= damage; - } - } - }); - } - - /** - * Force synergy recalculation (call when items change) - */ - forceRecalculate() { - this.recalculateSynergies(); - } -} - -// Export to global namespace -if (typeof window !== 'undefined') { - window.SynergySystem = SynergySystem; -} diff --git a/js/systems/UISystem.js b/js/systems/UISystem.js deleted file mode 100644 index b6fbea9..0000000 --- a/js/systems/UISystem.js +++ /dev/null @@ -1,1867 +0,0 @@ -/** - * @file UISystem.js - * @description UI management system for Space InZader - * Handles HUD updates, menus, level-up screen, and meta-progression - */ - -class UISystem { - constructor(world, gameState) { - this.world = world; - this.gameState = gameState; - this.waveSystem = null; // Will be set by Game.js - - // Cache DOM elements - this.cacheElements(); - - // Bind event handlers - this.bindEvents(); - - // Rarity colors - this.rarityColors = { - common: '#888', - rare: '#4488ff', - epic: '#aa44ff', - legendary: '#ffaa00' - }; - - // Selected ship - this.selectedShipId = null; - - // Double-click protection for boost selection - this._boostPicking = false; - - // Options return screen tracking - this.optionsReturnScreen = 'main'; - - // Menu starfield animation ID - this.menuStarfieldAnim = null; - - // Controls help tracking - this.controlsShownThisGame = false; - - // Stats overlay toggle state - this.statsOverlayVisible = true; - - // Track missing stats warnings to avoid spam - this.missingStatsWarned = new Set(); - } - - /** - * Cache all UI DOM elements - */ - cacheElements() { - // Screens - this.menuScreen = document.getElementById('menuScreen'); - this.levelUpScreen = document.getElementById('levelUpScreen'); - this.gameOverScreen = document.getElementById('gameOverScreen'); - this.metaScreen = document.getElementById('metaScreen'); - - // Main menu elements - this.mainMenu = document.getElementById('mainMenu'); - this.playBtn = document.getElementById('playBtn'); - this.scoreboardBtn = document.getElementById('scoreboardBtn'); - this.optionsBtn = document.getElementById('optionsBtn'); - this.creditsBtn = document.getElementById('creditsBtn'); - this.backToMainBtn = document.getElementById('backToMainBtn'); - - // Pause menu elements - this.pauseMenu = document.getElementById('pauseMenu'); - this.resumeBtn = document.getElementById('resumeBtn'); - this.commandsBtn = document.getElementById('commandsBtn'); - this.pauseOptionsBtn = document.getElementById('pauseOptionsBtn'); - this.quitBtn = document.getElementById('quitBtn'); - - // Commands screen elements - this.commandsScreen = document.getElementById('commandsScreen'); - this.commandsBackBtn = document.getElementById('commandsBackBtn'); - - // Options screen elements - this.optionsScreen = document.getElementById('optionsScreen'); - this.musicSlider = document.getElementById('musicSlider'); - this.musicDown = document.getElementById('musicDown'); - this.musicUp = document.getElementById('musicUp'); - this.musicValue = document.getElementById('musicValue'); - this.sfxSlider = document.getElementById('sfxSlider'); - this.sfxDown = document.getElementById('sfxDown'); - this.sfxUp = document.getElementById('sfxUp'); - this.sfxValue = document.getElementById('sfxValue'); - this.muteToggle = document.getElementById('muteToggle'); - this.optionsBackBtn = document.getElementById('optionsBackBtn'); - - // Scoreboard screen elements - this.scoreboardScreen = document.getElementById('scoreboardScreen'); - this.scoreList = document.getElementById('scoreList'); - this.clearScoresBtn = document.getElementById('clearScoresBtn'); - this.scoreboardBackBtn = document.getElementById('scoreboardBackBtn'); - - // Credits screen elements - this.creditsScreen = document.getElementById('creditsScreen'); - this.creditsBackBtn = document.getElementById('creditsBackBtn'); - - // Menu starfield canvas - this.menuStarfield = document.getElementById('menuStarfield'); - - // HUD elements - this.timeDisplay = document.getElementById('timeDisplay'); - this.waveDisplay = document.getElementById('waveDisplay'); - this.killsDisplay = document.getElementById('killsDisplay'); - this.scoreDisplay = document.getElementById('scoreDisplay'); - this.hpDisplay = document.getElementById('hpDisplay'); - this.healthFill = document.getElementById('healthFill'); - this.levelDisplay = document.getElementById('levelDisplay'); - this.xpFill = document.getElementById('xpFill'); - this.weaponSlots = document.getElementById('weaponSlots'); - this.controlsHelp = document.getElementById('controlsHelp'); - - // Shield elements - this.shieldBar = document.getElementById('shieldBar'); - this.shieldFill = document.getElementById('shieldFill'); - this.shieldDisplay = document.getElementById('shieldDisplay'); - this.shieldValue = document.getElementById('shieldValue'); - - // Heat/Overheat elements - this.heatBar = document.getElementById('heatBar'); - this.heatFill = document.getElementById('heatFill'); - this.heatDisplay = document.getElementById('heatDisplay'); - this.heatValue = document.getElementById('heatValue'); - - // Stats display elements - this.statDamage = document.getElementById('statDamage'); - this.statFireRate = document.getElementById('statFireRate'); - this.statSpeed = document.getElementById('statSpeed'); - this.statArmor = document.getElementById('statArmor'); - this.statLifesteal = document.getElementById('statLifesteal'); - this.statRegen = document.getElementById('statRegen'); - this.statCrit = document.getElementById('statCrit'); - - // Weapon and passive status elements - this.weaponList = document.getElementById('weaponList'); - this.passiveList = document.getElementById('passiveList'); - - // Stats overlay panel - this.statsOverlayPanel = document.getElementById('statsOverlayPanel'); - - // Menu elements (ship selection) - this.shipSelection = document.getElementById('shipSelection'); - this.startButton = document.getElementById('startButton'); - this.metaButton = document.getElementById('metaButton'); - - // Level up elements - this.boostOptions = document.getElementById('boostOptions'); - - // Game over elements - this.endStats = document.getElementById('endStats'); - this.returnMenuButton = document.getElementById('returnMenuButton'); - - // Meta screen elements - this.metaUpgrades = document.getElementById('metaUpgrades'); - this.backToMenuButton = document.getElementById('backToMenuButton'); - } - - /** - * Bind UI event handlers - */ - bindEvents() { - // Ship selection screen - if (this.startButton) { - this.startButton.addEventListener('click', () => this.onStartGame()); - } - if (this.metaButton) { - this.metaButton.addEventListener('click', () => this.onShowMeta()); - } - - // Game over screen - if (this.returnMenuButton) { - this.returnMenuButton.addEventListener('click', () => this.onReturnToMenu()); - } - - // Meta screen - if (this.backToMenuButton) { - this.backToMenuButton.addEventListener('click', () => this.onBackToMenu()); - } - - // Main menu buttons - if (this.playBtn) { - this.playBtn.addEventListener('click', () => this.showShipSelection()); - } - if (this.scoreboardBtn) { - this.scoreboardBtn.addEventListener('click', () => this.showScoreboard()); - } - if (this.optionsBtn) { - this.optionsBtn.addEventListener('click', () => this.showOptions('main')); - } - if (this.creditsBtn) { - this.creditsBtn.addEventListener('click', () => this.showCredits()); - } - - // Back button from ship selection - if (this.backToMainBtn) { - this.backToMainBtn.addEventListener('click', () => this.showMainMenu()); - } - - // Pause menu buttons - if (this.resumeBtn) { - this.resumeBtn.addEventListener('click', () => this.hidePauseMenu()); - } - if (this.commandsBtn) { - this.commandsBtn.addEventListener('click', () => this.showCommands()); - } - if (this.pauseOptionsBtn) { - this.pauseOptionsBtn.addEventListener('click', () => this.showOptions('pause')); - } - if (this.quitBtn) { - this.quitBtn.addEventListener('click', () => this.onQuitToMenu()); - } - - // Commands screen - if (this.commandsBackBtn) { - this.commandsBackBtn.addEventListener('click', () => this.showPauseMenu()); - } - - // Options screen - sliders - if (this.musicSlider) { - this.musicSlider.addEventListener('input', (e) => this.onMusicVolumeChange(e.target.value)); - } - if (this.sfxSlider) { - this.sfxSlider.addEventListener('input', (e) => this.onSfxVolumeChange(e.target.value)); - } - - // Options screen - volume buttons - if (this.musicDown) { - this.musicDown.addEventListener('click', () => this.adjustMusicVolume(-10)); - } - if (this.musicUp) { - this.musicUp.addEventListener('click', () => this.adjustMusicVolume(10)); - } - if (this.sfxDown) { - this.sfxDown.addEventListener('click', () => this.adjustSfxVolume(-10)); - } - if (this.sfxUp) { - this.sfxUp.addEventListener('click', () => this.adjustSfxVolume(10)); - } - - // Options screen - mute toggle - if (this.muteToggle) { - this.muteToggle.addEventListener('change', (e) => this.onMuteToggle(e.target.checked)); - } - - // Options screen - back button - if (this.optionsBackBtn) { - this.optionsBackBtn.addEventListener('click', () => this.onOptionsBack()); - } - - // Scoreboard screen - if (this.clearScoresBtn) { - this.clearScoresBtn.addEventListener('click', () => this.onClearScores()); - } - if (this.scoreboardBackBtn) { - this.scoreboardBackBtn.addEventListener('click', () => this.showMainMenu()); - } - - // Credits screen - if (this.creditsBackBtn) { - this.creditsBackBtn.addEventListener('click', () => this.showMainMenu()); - } - - // Stats overlay toggle with 'A' key - window.addEventListener('keydown', (e) => { - if (e.key === 'a' || e.key === 'A') { - // Only toggle if game is running - if (this.gameState && (this.gameState.currentState === GameStates.RUNNING || this.gameState.currentState === GameStates.LEVEL_UP)) { - this.toggleStatsOverlay(); - } - } - }); - } - - /** - * Update UI based on game state - * @param {number} deltaTime - Time since last frame - */ - update(deltaTime) { - const state = this.gameState.currentState; - - // Update HUD when game is running - if (state === GameStates.RUNNING || state === GameStates.LEVEL_UP) { - this.updateHUD(); - } - } - - /** - * Update HUD elements - */ - updateHUD() { - const player = this.world.getEntitiesByType('player')[0]; - if (!player) return; - - const playerComp = player.getComponent('player'); - const health = player.getComponent('health'); - - if (playerComp) { - // Update time - const minutes = Math.floor(this.gameState.stats.time / 60); - const seconds = Math.floor(this.gameState.stats.time % 60); - this.timeDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; - - // Update wave display - if (this.waveSystem && this.waveDisplay) { - this.waveDisplay.textContent = this.waveSystem.getWaveNumber(); - } - - // Update kills and score - this.killsDisplay.textContent = this.gameState.stats.kills; - this.scoreDisplay.textContent = this.gameState.stats.score; - - // Update level and XP - this.levelDisplay.textContent = playerComp.level; - const xpPercent = (playerComp.xp / playerComp.xpRequired) * 100; - this.xpFill.style.width = `${Math.min(100, xpPercent)}%`; - - // Update real-time stats display with safe access (nullish coalescing) - if (playerComp.stats) { - const stats = playerComp.stats; - const damageMultiplier = stats.damageMultiplier ?? 1; - const fireRateMultiplier = stats.fireRateMultiplier ?? 1; - const speed = stats.speed ?? 1; - const armor = stats.armor ?? 0; - const lifesteal = stats.lifesteal ?? 0; - const healthRegen = stats.healthRegen ?? 0; - const critChance = stats.critChance ?? 0; - - this.statDamage.textContent = `${Math.round(damageMultiplier * 100)}%`; - this.statFireRate.textContent = `${Math.round(fireRateMultiplier * 100)}%`; - this.statSpeed.textContent = `${Math.round(speed)}`; - this.statArmor.textContent = `${Math.round(armor)}`; - this.statLifesteal.textContent = `${Math.round(lifesteal * 100)}%`; - this.statRegen.textContent = `${healthRegen.toFixed(1)}/s`; - this.statCrit.textContent = `${Math.round(critChance * 100)}%`; - } - } - - if (health) { - // Update health - this.hpDisplay.textContent = `${Math.ceil(health.current)}/${health.max}`; - const healthPercent = (health.current / health.max) * 100; - this.healthFill.style.width = `${Math.max(0, healthPercent)}%`; - } - - // Update shield - const shield = player.getComponent('shield'); - if (shield && shield.max > 0) { - this.shieldBar.style.display = 'block'; - this.shieldDisplay.style.display = 'block'; - this.shieldValue.textContent = `${Math.ceil(shield.current)}/${shield.max}`; - const shieldPercent = (shield.current / shield.max) * 100; - this.shieldFill.style.width = `${Math.max(0, shieldPercent)}%`; - } else { - this.shieldBar.style.display = 'none'; - this.shieldDisplay.style.display = 'none'; - } - - // Update heat/overheat gauge - if (this.heatBar && this.heatFill && this.heatDisplay) { - const heat = playerComp?.heat ?? 0; - const heatMax = playerComp?.heatMax ?? 100; - - if (heatMax > 0 && heat > 0) { - this.heatBar.style.display = 'block'; - this.heatDisplay.style.display = 'block'; - this.heatValue.textContent = `${Math.ceil(heat)}/${heatMax}`; - const heatPercent = (heat / heatMax) * 100; - this.heatFill.style.width = `${Math.max(0, Math.min(100, heatPercent))}%`; - - // Change color based on heat level - if (heatPercent >= 80) { - this.heatFill.style.background = 'linear-gradient(to right, #ff4444, #ff0000)'; - } else if (heatPercent >= 50) { - this.heatFill.style.background = 'linear-gradient(to right, #ffaa00, #ff6600)'; - } else { - this.heatFill.style.background = 'linear-gradient(to right, #ffcc00, #ff9900)'; - } - } else { - this.heatBar.style.display = 'none'; - this.heatDisplay.style.display = 'none'; - } - } - - // Update weapon display - this.updateWeaponDisplay(playerComp); - - // Update passive display - this.updatePassiveDisplay(playerComp); - - // Update synergy display - this.updateSynergyDisplay(); - - // Update weather warning - this.updateWeatherWarning(); - - // Update magnetic storm status (during active event) - this.updateMagneticStormStatus(); - - // Update stats overlay (deltas) - this.updateStatsOverlay(playerComp, health); - } - - /** - * Update synergy HUD display - */ - updateSynergyDisplay() { - if (!window.game || !window.game.synergySystem) return; - - const activeSynergies = window.game.synergySystem.getActiveSynergies(); - - // Get or create synergy container - let synergyContainer = document.getElementById('synergyDisplay'); - if (!synergyContainer) { - synergyContainer = document.createElement('div'); - synergyContainer.id = 'synergyDisplay'; - synergyContainer.style.cssText = ` - position: absolute; - top: 60px; - left: 10px; - display: flex; - flex-direction: column; - gap: 5px; - pointer-events: none; - `; - document.getElementById('ui').appendChild(synergyContainer); - } - - // Clear and rebuild - synergyContainer.innerHTML = ''; - - if (activeSynergies.length === 0) return; - - activeSynergies.forEach(({ synergy, count, threshold }) => { - const badge = document.createElement('div'); - const color = threshold === 4 ? '#FFD700' : '#00FFFF'; - - badge.style.cssText = ` - background: rgba(0, 0, 0, 0.8); - border: 2px solid ${color}; - padding: 5px 10px; - border-radius: 5px; - font-size: 12px; - color: ${color}; - text-shadow: 0 0 5px ${color}; - box-shadow: 0 0 10px ${color}40; - `; - - badge.textContent = `${synergy.name} ${count}/${threshold}`; - synergyContainer.appendChild(badge); - }); - } - - /** - * Update weapon display in HUD - * @param {Object} playerComp - Player component - */ - updateWeaponDisplay(playerComp) { - if (!playerComp || !playerComp.weapons || !this.weaponList) return; - - this.weaponList.innerHTML = ''; - - playerComp.weapons.forEach((weapon, index) => { - const weaponDiv = document.createElement('div'); - weaponDiv.className = 'weapon-item'; - - const weaponData = weapon.data; - const weaponName = weaponData?.name || weapon.type || 'Unknown'; - const level = weapon.level || 1; - const maxLevel = weaponData?.maxLevel || 8; - const rarity = weaponData?.rarity || 'common'; - - // Get rarity color - const rarityColor = this.rarityColors[rarity] || '#888'; - - // Format weapon info - weaponDiv.innerHTML = ` - ${weaponName} - Lv${level}/${maxLevel} - `; - this.weaponList.appendChild(weaponDiv); - }); - } - - /** - * Update passive display - */ - updatePassiveDisplay(playerComp) { - if (!playerComp || !playerComp.passives || !this.passiveList) return; - - this.passiveList.innerHTML = ''; - - // Get unique passives with stacks - const passiveMap = new Map(); - playerComp.passives.forEach(passive => { - const key = passive.type || passive.id; - if (passiveMap.has(key)) { - passiveMap.get(key).stacks++; - } else { - passiveMap.set(key, { - ...passive, - stacks: 1 - }); - } - }); - - // Display passives - passiveMap.forEach((passive, key) => { - const passiveDiv = document.createElement('div'); - passiveDiv.className = 'passive-item'; - - const passiveData = passive.data; - const name = passiveData?.name || passive.type || key; - const rarity = passiveData?.rarity || 'common'; - const rarityColor = this.rarityColors[rarity] || '#888'; - - const stackText = passive.stacks > 1 ? ` x${passive.stacks}` : ''; - - passiveDiv.innerHTML = `${name}${stackText}`; - this.passiveList.appendChild(passiveDiv); - }); - } - - /** - * Show screen by name - * @param {string} screen - Screen name ('menu', 'game', 'levelup', 'gameover', 'meta') - */ - showScreen(screen) { - switch (screen) { - case 'menu': - this.showMainMenu(); - break; - case 'game': - this.hideAllScreens(); - this.showHUD(); - break; - case 'levelup': - // Handled by showLevelUp - break; - case 'gameover': - this.showGameOver(); - break; - case 'meta': - this.showMetaScreen(); - break; - } - } - - /** - * Show main menu and start starfield animation - */ - showMainMenu() { - this.hideAllScreens(); - this.hideHUD(); - if (this.mainMenu) { - this.mainMenu.classList.add('active'); - } - this.startMenuStarfield(); - if (window.game?.audioManager) { - window.game.audioManager.setMusicTheme('calm'); - } - } - - /** - * Hide main menu - */ - hideMainMenu() { - if (this.mainMenu) { - this.mainMenu.classList.remove('active'); - } - this.stopMenuStarfield(); - } - - /** - * Show ship selection screen - */ - showShipSelection() { - this.hideAllScreens(); - this.stopMenuStarfield(); - if (this.menuScreen) { - this.menuScreen.classList.add('active'); - } - this.renderShipSelection(); - } - - /** - * Show pause menu - */ - showPauseMenu() { - if (window.game?.gameState) { - window.game.gameState.setState(GameStates.PAUSED); - } - // Hide other screens first - this.hideAllScreens(); - if (this.pauseMenu) { - this.pauseMenu.classList.add('active'); - } - } - - /** - * Hide pause menu - */ - hidePauseMenu() { - if (this.pauseMenu) { - this.pauseMenu.classList.remove('active'); - } - // Resume the game properly - if (window.game && window.game.gameState.isState(GameStates.PAUSED)) { - window.game.resumeGame(); - } - } - - /** - * Show commands screen - */ - showCommands() { - this.hideAllScreens(); - if (this.commandsScreen) { - this.commandsScreen.classList.add('active'); - } - } - - /** - * Show options screen - * @param {string} returnScreen - Screen to return to ('main', 'pause', etc.) - */ - showOptions(returnScreen = 'main') { - this.optionsReturnScreen = returnScreen; - this.hideAllScreens(); - if (this.optionsScreen) { - this.optionsScreen.classList.add('active'); - } - this.loadOptionsValues(); - } - - /** - * Load current audio settings into options UI - */ - loadOptionsValues() { - const audio = window.game?.audioManager; - if (audio) { - const musicVol = Math.round(audio.musicVolume * 100); - const sfxVol = Math.round(audio.sfxVolume * 100); - - if (this.musicSlider) this.musicSlider.value = musicVol; - if (this.musicValue) this.musicValue.textContent = musicVol + '%'; - if (this.sfxSlider) this.sfxSlider.value = sfxVol; - if (this.sfxValue) this.sfxValue.textContent = sfxVol + '%'; - if (this.muteToggle) this.muteToggle.checked = audio.muted; - } - } - - /** - * Show scoreboard screen - */ - showScoreboard() { - this.hideAllScreens(); - if (this.scoreboardScreen) { - this.scoreboardScreen.classList.add('active'); - } - this.renderScoreboard(); - } - - /** - * Render scoreboard entries - */ - renderScoreboard() { - if (!this.scoreList) return; - - const scoreManager = window.game?.scoreManager; - if (!scoreManager) { - this.scoreList.innerHTML = '
Score Manager not available
'; - return; - } - - const scores = scoreManager.getTopScores(10); - - if (scores.length === 0) { - this.scoreList.innerHTML = '
Aucun score enregistré.

Jouez pour établir un record!
'; - return; - } - - // Create table structure - let tableHTML = ` - - - - - - - - - - - - - `; - - scores.forEach((entry, index) => { - const rank = index + 1; - const date = new Date(entry.date); - const timeStr = Math.floor(entry.time / 60) + ':' + String(Math.floor(entry.time % 60)).padStart(2, '0'); - - // Color based on rank - let rankColor = '#fff'; - if (rank === 1) rankColor = '#ffd700'; // Gold - else if (rank === 2) rankColor = '#c0c0c0'; // Silver - else if (rank === 3) rankColor = '#cd7f32'; // Bronze - else rankColor = '#0ff'; // Cyan for others - - const rowStyle = ` - border-bottom:1px solid #333; - color:${rankColor}; - text-shadow:0 0 5px ${rankColor}; - `; - - tableHTML += ` - - - - - - - - - `; - }); - - tableHTML += ` - -
RangNomScoreVagueTempsDate
#${rank}${entry.playerName}${entry.score.toLocaleString()}${entry.wave}${timeStr}${date.toLocaleDateString()}
- `; - - this.scoreList.innerHTML = tableHTML; - } - - /** - * Show credits screen - */ - showCredits() { - this.hideAllScreens(); - if (this.creditsScreen) { - this.creditsScreen.classList.add('active'); - } - } - - /** - * Start menu starfield animation - */ - startMenuStarfield() { - const canvas = this.menuStarfield; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - const stars = []; - const starCount = 150; - - // Initialize stars - for (let i = 0; i < starCount; i++) { - stars.push({ - x: Math.random() * canvas.width, - y: Math.random() * canvas.height, - speed: 0.2 + Math.random() * 0.8, - size: 1 + Math.random() * 2, - opacity: 0.3 + Math.random() * 0.7 - }); - } - - const animate = () => { - if (!this.mainMenu || !this.mainMenu.classList.contains('active')) { - cancelAnimationFrame(this.menuStarfieldAnim); - this.menuStarfieldAnim = null; - return; - } - - ctx.fillStyle = '#000'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - stars.forEach(star => { - star.y += star.speed; - if (star.y > canvas.height) { - star.y = 0; - star.x = Math.random() * canvas.width; - } - - ctx.fillStyle = `rgba(0, 255, 255, ${star.opacity})`; - ctx.fillRect(star.x, star.y, star.size, star.size); - }); - - this.menuStarfieldAnim = requestAnimationFrame(animate); - }; - - this.menuStarfieldAnim = requestAnimationFrame(animate); - } - - /** - * Stop menu starfield animation - */ - stopMenuStarfield() { - if (this.menuStarfieldAnim) { - cancelAnimationFrame(this.menuStarfieldAnim); - this.menuStarfieldAnim = null; - } - } - - /** - * Check if a screen is currently active - * @param {string} screenId - ID of the screen to check - * @returns {boolean} - */ - isScreenActive(screenId) { - const screen = document.getElementById(screenId); - return screen ? screen.classList.contains('active') : false; - } - - /** - * Handle music volume change - * @param {number} value - Volume value (0-100) - */ - onMusicVolumeChange(value) { - const audio = window.game?.audioManager; - if (audio) { - audio.setMusicVolume(value / 100); - if (this.musicValue) { - this.musicValue.textContent = value + '%'; - } - } - } - - /** - * Handle SFX volume change - * @param {number} value - Volume value (0-100) - */ - onSfxVolumeChange(value) { - const audio = window.game?.audioManager; - if (audio) { - audio.setSfxVolume(value / 100); - if (this.sfxValue) { - this.sfxValue.textContent = value + '%'; - } - } - } - - /** - * Adjust music volume by delta - * @param {number} delta - Volume change amount - */ - adjustMusicVolume(delta) { - const audio = window.game?.audioManager; - if (audio && this.musicSlider) { - const currentVol = parseInt(this.musicSlider.value); - const newVol = Math.max(0, Math.min(100, currentVol + delta)); - this.musicSlider.value = newVol; - this.onMusicVolumeChange(newVol); - } - } - - /** - * Adjust SFX volume by delta - * @param {number} delta - Volume change amount - */ - adjustSfxVolume(delta) { - const audio = window.game?.audioManager; - if (audio && this.sfxSlider) { - const currentVol = parseInt(this.sfxSlider.value); - const newVol = Math.max(0, Math.min(100, currentVol + delta)); - this.sfxSlider.value = newVol; - this.onSfxVolumeChange(newVol); - } - } - - /** - * Handle mute toggle - * @param {boolean} muted - Mute state - */ - onMuteToggle(muted) { - const audio = window.game?.audioManager; - if (audio) { - audio.setMuted(muted); - } - } - - /** - * Handle options back button - */ - onOptionsBack() { - if (this.optionsReturnScreen === 'pause') { - this.showPauseMenu(); - } else { - this.showMainMenu(); - } - } - - /** - * Handle clear scores button - */ - onClearScores() { - const saveManager = window.game?.saveManager; - if (saveManager && confirm('Are you sure you want to clear all scores?')) { - saveManager.clearScoreboard(); - this.renderScoreboard(); - } - } - - /** - * Handle quit to menu from pause - */ - onQuitToMenu() { - this.hidePauseMenu(); - this.hideHUD(); - this.gameState.setState(GameStates.MENU); - this.showMainMenu(); - - // Dispatch event for game to handle - const event = new CustomEvent('returnToMenu'); - document.dispatchEvent(event); - } - - /** - * Render ship selection UI - */ - renderShipSelection() { - this.shipSelection.innerHTML = ''; - - // Get ships from ShipData - const ships = ShipData && ShipData.getAllShips ? ShipData.getAllShips() : this.getDefaultShips(); - const saveData = window.game?.saveData || {}; - const progress = saveData.meta || { maxWave: 0, bloodCritCount: 0 }; - - ships.forEach(ship => { - const card = document.createElement('div'); - card.className = 'ship-card'; - card.dataset.shipId = ship.id; - - // Check if ship is locked - const isLocked = !ship.unlocked && ship.unlockCondition; - if (isLocked) { - card.classList.add('locked'); - } - - // Select first unlocked ship by default - if (!this.selectedShipId && !isLocked) { - this.selectedShipId = ship.id; - card.classList.add('selected'); - // Dispatch ship selected event for default selection - window.dispatchEvent(new CustomEvent('shipSelected', { - detail: { ship: ship.id } - })); - } else if (this.selectedShipId === ship.id && !isLocked) { - card.classList.add('selected'); - } - - let unlockText = ''; - if (isLocked) { - const cond = ship.unlockCondition; - if (cond.type === 'wave') { - unlockText = `
🔒 Reach Wave ${cond.value}
`; - } else if (cond.type === 'blood_crit_count') { - unlockText = `
🔒 Get ${cond.value} Blood Crits
`; - } - } - - card.innerHTML = ` -

${ship.name}

-
- ${ship.description} -
-
-
HP: ${ship.baseStats.maxHealth}
-
DMG: x${ship.baseStats.damageMultiplier.toFixed(2)}
-
SPD: ${ship.baseStats.speed}
-
Difficulty: ${ship.difficulty.toUpperCase()}
-
- ${unlockText} - `; - - card.addEventListener('click', () => { - if (isLocked) return; // Can't select locked ships - - document.querySelectorAll('.ship-card').forEach(c => c.classList.remove('selected')); - card.classList.add('selected'); - this.selectedShipId = ship.id; - - // Dispatch ship selected event - window.dispatchEvent(new CustomEvent('shipSelected', { - detail: { ship: ship.id } - })); - }); - - this.shipSelection.appendChild(card); - }); - } - - /** - * Get default ships if data not available - * @returns {Array} - */ - getDefaultShips() { - return [ - { - id: 'equilibre', - name: 'Équilibré', - description: 'Bon en tout, parfait pour débuter', - baseStats: { maxHealth: 100, damageMultiplier: 1.0, speed: 210 }, - color: '#9370DB', - difficulty: 'easy' - }, - { - id: 'defenseur', - name: 'Défenseur', - description: 'Plus de HP, meilleure survie', - baseStats: { maxHealth: 120, damageMultiplier: 1.0, speed: 200 }, - color: '#00BFFF', - difficulty: 'easy' - } - ]; - } - - /** - * Show level up screen with boost options - * @param {Array} boosts - Available boost options - */ - showLevelUp(boosts, rerollsRemaining = 0) { - this.hideAllScreens(); - this.levelUpScreen.classList.add('active'); - this.renderBoostOptions(boosts, rerollsRemaining); - } - - /** - * Render boost selection options - * @param {Array} boosts - Available boosts - * @param {number} rerollsRemaining - Number of rerolls left - */ - renderBoostOptions(boosts, rerollsRemaining = 0) { - this.boostOptions.innerHTML = ''; - - // Filter out null/undefined boosts to prevent empty cards - const validBoosts = boosts.filter(boost => boost != null); - - if (validBoosts.length === 0) { - console.error('UISystem: No valid boosts to display!'); - return; - } - - validBoosts.forEach((boost, index) => { - const card = document.createElement('div'); - card.className = `boost-card ${boost.rarity || 'common'}`; - - const rarityColor = this.rarityColors[boost.rarity || 'common']; - - // Special styling for keystones - const isKeystone = boost.isKeystone || boost.uniquePerRun; - - card.innerHTML = ` - ${isKeystone ? '
' : ''} -
- ${boost.name || boost.data?.name} -
-
- ${isKeystone ? 'KEYSTONE' : (boost.rarity || 'common')} -
-
- ${boost.description || boost.data?.description} -
- ${boost.currentLevel ? ` -
- Current Level: ${boost.currentLevel}/${boost.maxLevel || 5} -
- ` : ''} - `; - - card.addEventListener('click', () => { - this.onBoostSelected(boost, index); - }); - - this.boostOptions.appendChild(card); - }); - - // Add reroll button if rerolls available - if (rerollsRemaining > 0) { - const rerollBtn = document.createElement('button'); - rerollBtn.className = 'button'; - rerollBtn.style.marginTop = '20px'; - rerollBtn.textContent = `REROLL (${rerollsRemaining} LEFT)`; - rerollBtn.addEventListener('click', () => { - window.dispatchEvent(new CustomEvent('rerollBoosts')); - }); - this.boostOptions.appendChild(rerollBtn); - } - } - - /** - * Handle boost selection - * @param {Object} boost - Selected boost - * @param {number} index - Boost index - */ - onBoostSelected(boost, index) { - // Double-click protection - if (this._boostPicking) { - logger.warn('UISystem', 'Boost selection already in progress, ignoring'); - return; - } - - // Lock boost selection - this._boostPicking = true; - - // Disable pointer events on all boost cards immediately - const cards = document.querySelectorAll('.boost-card'); - cards.forEach(card => { - card.style.pointerEvents = 'none'; - card.style.opacity = '0.5'; - }); - - logger.info('UISystem', `Boost selected: ${boost?.name || 'unknown'}`); - - // Dispatch custom event for game to handle - const event = new CustomEvent('boostSelected', { detail: { boost, index } }); - window.dispatchEvent(event); - - // Hide level up screen - this.levelUpScreen.classList.remove('active'); - - // Unlock after 250ms (safety delay) - setTimeout(() => { - this._boostPicking = false; - logger.debug('UISystem', 'Boost selection unlocked'); - }, 250); - } - - /** - * Show game over screen with statistics - */ - showGameOver() { - this.hideAllScreens(); - this.gameOverScreen.classList.add('active'); - this.renderEndStats(); - } - - /** - * Render end-of-run statistics (in French) - */ - renderEndStats() { - const stats = this.gameState.stats; - const credits = this.gameState.calculateNoyaux(); - const waveNumber = window.game?.systems?.wave?.getWaveNumber() || 1; - - const minutes = Math.floor(stats.time / 60); - const seconds = Math.floor(stats.time % 60); - - this.endStats.innerHTML = ` -
- Temps de Survie: - ${minutes}:${seconds.toString().padStart(2, '0')} -
-
- Vague Atteinte: - ${waveNumber} -
-
- Ennemis Éliminés: - ${stats.kills} -
-
- Score Final: - ${stats.score} -
-
- Niveau Maximum: - ${stats.highestLevel} -
-
- Dégâts Infligés: - ${Math.floor(stats.damageDealt)} -
-
- Dégâts Subis: - ${Math.floor(stats.damageTaken)} -
-
-
- Crédits Gagnés: - ⬡ ${credits} -
-
- `; - } - - /** - * Show meta-progression screen - */ - showMetaScreen() { - this.hideAllScreens(); - this.metaScreen.classList.add('active'); - this.renderMetaUpgrades(); - } - - /** - * Render meta-progression upgrade tree - */ - renderMetaUpgrades() { - // Placeholder for meta upgrades - // In a full implementation, this would show upgradeable stats - this.metaUpgrades.innerHTML = ` -
-

⬡ Meta Progression ⬡

-

Permanent upgrades using Noyaux

-
-

Available Noyaux: 0

-

Coming Soon: Permanent stat upgrades, ship unlocks, and more!

-
-
- `; - } - - /** - * Hide all screens - */ - hideAllScreens() { - if (this.mainMenu) this.mainMenu.classList.remove('active'); - if (this.menuScreen) this.menuScreen.classList.remove('active'); - if (this.levelUpScreen) this.levelUpScreen.classList.remove('active'); - if (this.gameOverScreen) this.gameOverScreen.classList.remove('active'); - if (this.metaScreen) this.metaScreen.classList.remove('active'); - if (this.pauseMenu) this.pauseMenu.classList.remove('active'); - if (this.commandsScreen) this.commandsScreen.classList.remove('active'); - if (this.optionsScreen) this.optionsScreen.classList.remove('active'); - if (this.scoreboardScreen) this.scoreboardScreen.classList.remove('active'); - if (this.creditsScreen) this.creditsScreen.classList.remove('active'); - } - - /** - * Show HUD elements - */ - showHUD() { - const hudElements = [ - document.getElementById('hudTopLeft'), - document.getElementById('hudTopCenter'), - document.getElementById('hudBottomLeft'), - document.getElementById('hudBottomCenter'), - document.getElementById('hudBottomRight') - ]; - - hudElements.forEach(el => { - if (el) el.style.display = 'block'; - }); - - // Don't show controls automatically - only on wave 1 - } - - /** - * Hide HUD elements - */ - hideHUD() { - const hudElements = [ - document.getElementById('hudTopLeft'), - document.getElementById('hudTopCenter'), - document.getElementById('hudBottomLeft'), - document.getElementById('hudBottomCenter'), - document.getElementById('hudBottomRight') - ]; - - hudElements.forEach(el => { - if (el) el.style.display = 'none'; - }); - - // Hide controls help - if (this.controlsHelp) { - this.controlsHelp.classList.remove('active'); - } - } - - /** - * Event handler: Start game - */ - onStartGame() { - if (!this.selectedShipId) { - console.warn('No ship selected'); - return; - } - - this.gameState.selectedShip = this.selectedShipId; - - // Dispatch custom event for game to handle - const event = new CustomEvent('startGame', { detail: { shipId: this.selectedShipId } }); - document.dispatchEvent(event); - } - - /** - * Event handler: Show meta screen - */ - onShowMeta() { - this.showMetaScreen(); - } - - /** - * Event handler: Return to menu from game over - */ - onReturnToMenu() { - this.gameState.setState(GameStates.MENU); - this.showMainMenu(); - this.hideHUD(); - - // Dispatch event for game to handle - const event = new CustomEvent('returnToMenu'); - document.dispatchEvent(event); - } - - /** - * Event handler: Back to menu from meta screen - */ - onBackToMenu() { - this.showMainMenu(); - } - - /** - * Generate random boosts for level up - * @param {Object} playerComp - Player component - * @param {number} count - Number of boosts to generate - * @returns {Array} - */ - generateRandomBoosts(playerComp, count = 3) { - const availableBoosts = this.getAvailableBoosts(playerComp); - const selectedBoosts = []; - - // Shuffle and select random boosts - for (let i = 0; i < Math.min(count, availableBoosts.length); i++) { - const randomIndex = Math.floor(Math.random() * availableBoosts.length); - const boost = availableBoosts.splice(randomIndex, 1)[0]; - - // Determine rarity based on luck - boost.rarity = this.determineRarity(playerComp.stats.luck || 0); - - selectedBoosts.push(boost); - } - - return selectedBoosts; - } - - /** - * Get available boosts based on player state - * @param {Object} playerComp - Player component - * @returns {Array} - */ - getAvailableBoosts(playerComp) { - // Default boosts if no data available - const defaultBoosts = [ - { - name: 'Damage +10%', - description: 'Increase damage by 10%', - type: 'stat', - stat: 'damage', - value: 0.1 - }, - { - name: 'Fire Rate +10%', - description: 'Increase fire rate by 10%', - type: 'stat', - stat: 'fireRate', - value: 0.1 - }, - { - name: 'Max HP +10', - description: 'Increase maximum health by 10', - type: 'stat', - stat: 'maxHealth', - value: 10 - }, - { - name: 'Speed +10%', - description: 'Increase movement speed by 10%', - type: 'stat', - stat: 'speed', - value: 0.1 - }, - { - name: 'Critical Chance +5%', - description: 'Increase critical hit chance by 5%', - type: 'stat', - stat: 'critChance', - value: 0.05 - } - ]; - - return [...defaultBoosts]; - } - - /** - * Determine boost rarity based on luck - * @param {number} luck - Player luck value - * @returns {string} - */ - determineRarity(luck) { - const rand = Math.random() + (luck * 0.1); - - if (rand > 0.95) return 'legendary'; - if (rand > 0.80) return 'epic'; - if (rand > 0.60) return 'rare'; - return 'common'; - } - - /** - * Show notification message - * @param {string} message - Message text - * @param {string} color - Message color - */ - showNotification(message, color = '#00ffff') { - const notification = document.createElement('div'); - notification.style.position = 'absolute'; - notification.style.top = '50%'; - notification.style.left = '50%'; - notification.style.transform = 'translate(-50%, -50%)'; - notification.style.padding = '20px 40px'; - notification.style.background = 'rgba(10, 10, 26, 0.95)'; - notification.style.border = `2px solid ${color}`; - notification.style.color = color; - notification.style.fontSize = '24px'; - notification.style.fontWeight = 'bold'; - notification.style.textShadow = `0 0 10px ${color}`; - notification.style.zIndex = '1000'; - notification.style.pointerEvents = 'none'; - notification.textContent = message; - - document.getElementById('ui').appendChild(notification); - - // Fade out and remove - setTimeout(() => { - notification.style.transition = 'opacity 0.5s'; - notification.style.opacity = '0'; - setTimeout(() => notification.remove(), 500); - }, 2000); - } - - /** - * Show wave announcement - * @param {number} waveNumber - Current wave number - */ - showWaveAnnouncement(waveNumber) { - const announcement = document.createElement('div'); - announcement.style.position = 'fixed'; - announcement.style.top = '35%'; // Positioned slightly above center - announcement.style.left = '50%'; - announcement.style.transform = 'translate(-50%, -50%)'; - announcement.style.padding = '10px 30px'; // Compact padding - announcement.style.background = 'rgba(0, 0, 0, 0.3)'; // Very transparent - announcement.style.border = '2px solid rgba(0, 255, 255, 0.5)'; // Semi-transparent border - announcement.style.borderRadius = '8px'; - announcement.style.color = '#00FFFF'; - announcement.style.fontSize = '28px'; // ~40% of original 72px - announcement.style.fontWeight = 'bold'; - announcement.style.textShadow = '0 0 10px #00FFFF'; // Subtle glow - announcement.style.zIndex = '2000'; - announcement.style.pointerEvents = 'none'; - announcement.style.opacity = '0'; - announcement.style.transition = 'opacity 0.2s ease-in'; - announcement.textContent = `VAGUE ${waveNumber}`; // French: WAVE → VAGUE - - document.getElementById('ui').appendChild(announcement); - - // Fade in quickly - setTimeout(() => { - announcement.style.opacity = '0.9'; // Max 0.9 opacity for subtlety - }, 50); - - // Hold briefly and fade out rapidly - setTimeout(() => { - announcement.style.transition = 'opacity 0.3s ease-out'; - announcement.style.opacity = '0'; - setTimeout(() => announcement.remove(), 300); - }, 1000); // Disappears faster (1s instead of 1.5s) - - // Show controls on wave 1 only - if (waveNumber === 1 && !this.controlsShownThisGame) { - this.showControlsHelp(); - this.controlsShownThisGame = true; - } - } - - /** - * Show controls help overlay - */ - showControlsHelp() { - if (this.controlsHelp) { - this.controlsHelp.classList.add('active'); - - // Auto-hide controls help after 10 seconds - if (this.controlsHelpTimer) { - clearTimeout(this.controlsHelpTimer); - } - this.controlsHelpTimer = setTimeout(() => { - if (this.controlsHelp) { - this.controlsHelp.classList.remove('active'); - } - }, 10000); // 10 seconds - } - } - - /** - * Reset UI state - */ - reset() { - this.hideAllScreens(); - this.hideHUD(); - // Reset controls flag for new game - this.controlsShownThisGame = false; - } - - /** - * Update weather warning display - */ - updateWeatherWarning() { - if (!window.game || !window.game.systems || !window.game.systems.weather) return; - - const warningText = window.game.systems.weather.getWarningText(); - - // Get or create warning element - let warningEl = document.getElementById('weatherWarning'); - if (!warningEl) { - warningEl = document.createElement('div'); - warningEl.id = 'weatherWarning'; - warningEl.style.cssText = ` - position: absolute; - top: 100px; - left: 50%; - transform: translateX(-50%); - background: rgba(255, 0, 0, 0.8); - border: 2px solid #ff0000; - padding: 15px 30px; - border-radius: 5px; - font-size: 24px; - font-weight: bold; - color: #fff; - text-shadow: 0 0 10px #ff0000; - animation: pulse 0.5s infinite alternate; - z-index: 1000; - display: none; - `; - document.getElementById('gameCanvas').parentElement.appendChild(warningEl); - - // Add CSS animation if not exists - if (!document.getElementById('weatherWarningStyle')) { - const style = document.createElement('style'); - style.id = 'weatherWarningStyle'; - style.textContent = ` - @keyframes pulse { - from { opacity: 0.7; } - to { opacity: 1.0; } - } - `; - document.head.appendChild(style); - } - } - - if (warningText) { - warningEl.textContent = warningText; - warningEl.style.display = 'block'; - } else { - warningEl.style.display = 'none'; - } - } - - /** - * Update magnetic storm status display (during active event) - */ - updateMagneticStormStatus() { - if (!window.game || !window.game.systems || !window.game.systems.weather) return; - - const statusText = window.game.systems.weather.getMagneticStormStatus(); - - // Get or create status element - let statusEl = document.getElementById('magneticStormStatus'); - if (!statusEl) { - statusEl = document.createElement('div'); - statusEl.id = 'magneticStormStatus'; - statusEl.style.cssText = ` - position: absolute; - top: 150px; - left: 50%; - transform: translateX(-50%); - background: rgba(100, 0, 255, 0.8); - border: 2px solid #6600ff; - padding: 10px 20px; - border-radius: 5px; - font-size: 18px; - font-weight: bold; - color: #fff; - text-shadow: 0 0 10px #6600ff; - z-index: 999; - display: none; - `; - document.getElementById('gameCanvas').parentElement.appendChild(statusEl); - } - - if (statusText) { - statusEl.textContent = statusText; - statusEl.style.display = 'block'; - } else { - statusEl.style.display = 'none'; - } - } - - /** - * Show name entry dialog for scoreboard - */ - showNameEntryDialog() { - const dialog = document.getElementById('nameEntryDialog'); - if (dialog) { - dialog.classList.add('active'); - const input = document.getElementById('playerNameInput'); - if (input) { - input.value = ''; - input.focus(); - } - } - } - - /** - * Hide name entry dialog - */ - hideNameEntryDialog() { - const dialog = document.getElementById('nameEntryDialog'); - if (dialog) { - dialog.classList.remove('active'); - } - } - - /** - * Show scoreboard screen - */ - showScoreboard() { - this.hideAllScreens(); - const scoreboardScreen = document.getElementById('scoreboardScreen'); - if (scoreboardScreen) { - scoreboardScreen.classList.add('active'); - this.renderScoreboard(); - } - } - - /** - * Hide scoreboard screen - */ - hideScoreboard() { - const scoreboardScreen = document.getElementById('scoreboardScreen'); - if (scoreboardScreen) { - scoreboardScreen.classList.remove('active'); - } - } - - /** - * Render scoreboard table - */ - renderScoreboard() { - const scoreManager = window.game?.scoreManager; - const container = document.getElementById('scoreboardContainer'); - - if (!container || !scoreManager) return; - - const scores = scoreManager.getTopScores(10); - - if (scores.length === 0) { - container.innerHTML = '
Aucun score enregistré
'; - return; - } - - let html = ` - - - - - - - - - - - - `; - - scores.forEach((score, index) => { - html += ` - - - - - - - - `; - }); - - html += ` - -
#JoueurScoreVagueDate
${index + 1}${score.playerName}${score.score}V${score.wave}${ScoreManager.formatDate(score.date)}
- `; - - container.innerHTML = html; - } - - /** - * Toggle stats overlay visibility - */ - toggleStatsOverlay() { - this.statsOverlayVisible = !this.statsOverlayVisible; - if (this.statsOverlayPanel) { - this.statsOverlayPanel.style.display = this.statsOverlayVisible ? 'block' : 'none'; - } - } - - /** - * Update stats overlay with delta calculations - * @param {Object} playerComp - Player component with stats and baseStats - * @param {Object} health - Health component - */ - updateStatsOverlay(playerComp, health) { - if (!this.statsOverlayPanel || !this.statsOverlayVisible || !playerComp) return; - - const stats = playerComp.stats || {}; - const baseStats = playerComp.baseStats || {}; - - // Helper function to get stat with fallback - const getStat = (statName, defaultValue = 0) => { - const value = stats[statName]; - if (value === undefined || value === null) { - // Warn once per stat - if (!this.missingStatsWarned.has(statName)) { - console.warn(`[UISystem] Missing stat: ${statName}, using default ${defaultValue}`); - this.missingStatsWarned.add(statName); - } - return defaultValue; - } - return value; - }; - - const getBaseStat = (statName, defaultValue = 0) => { - return baseStats[statName] !== undefined ? baseStats[statName] : defaultValue; - }; - - // Calculate deltas and format display - const statsList = [ - { - label: 'Damage', - current: getStat('damageMultiplier', 1), - base: getBaseStat('damageMultiplier', 1), - format: 'percent', - multiplier: 100 - }, - { - label: 'Fire Rate', - current: getStat('fireRateMultiplier', 1), - base: getBaseStat('fireRateMultiplier', 1), - format: 'percent', - multiplier: 100 - }, - { - label: 'Speed', - current: getStat('speed', 1), - base: getBaseStat('speed', 1), - format: 'number', - multiplier: 1 - }, - { - label: 'Max HP', - current: health ? health.max : 100, - base: getBaseStat('maxHealth', 100), - format: 'number', - multiplier: 1 - }, - { - label: 'Armor', - current: getStat('armor', 0), - base: getBaseStat('armor', 0), - format: 'number', - multiplier: 1 - }, - { - label: 'Crit Chance', - current: getStat('critChance', 0), - base: getBaseStat('critChance', 0), - format: 'percent', - multiplier: 100 - }, - { - label: 'Crit Damage', - current: getStat('critDamage', 1.5), - base: getBaseStat('critDamage', 1.5), - format: 'percent', - multiplier: 100 - }, - { - label: 'Lifesteal', - current: getStat('lifesteal', 0), - base: getBaseStat('lifesteal', 0), - format: 'percent', - multiplier: 100 - }, - { - label: 'Health Regen', - current: getStat('healthRegen', 0), - base: getBaseStat('healthRegen', 0), - format: 'decimal', - suffix: '/s', - multiplier: 1 - }, - { - label: 'Range', - current: getStat('rangeMultiplier', 1), - base: getBaseStat('rangeMultiplier', 1), - format: 'percent', - multiplier: 100 - }, - { - label: 'Projectile Speed', - current: getStat('projectileSpeedMultiplier', 1), - base: getBaseStat('projectileSpeedMultiplier', 1), - format: 'percent', - multiplier: 100 - } - ]; - - // Build HTML - let html = '
PLAYER STATS
'; - - statsList.forEach(stat => { - const current = stat.current * stat.multiplier; - const base = stat.base * stat.multiplier; - const delta = current - base; - - // Format values - let currentStr; - let deltaStr; - - if (stat.format === 'percent') { - currentStr = `${Math.round(current)}%`; - deltaStr = delta === 0 ? '±0%' : `${delta > 0 ? '+' : ''}${Math.round(delta)}%`; - } else if (stat.format === 'decimal') { - currentStr = `${current.toFixed(1)}${stat.suffix || ''}`; - deltaStr = delta === 0 ? '±0' : `${delta > 0 ? '+' : ''}${delta.toFixed(1)}${stat.suffix || ''}`; - } else { - currentStr = `${Math.round(current)}`; - deltaStr = delta === 0 ? '±0' : `${delta > 0 ? '+' : ''}${Math.round(delta)}`; - } - - // Determine color - let deltaColor; - if (delta > 0) { - deltaColor = '#0f0'; // Green - } else if (delta < 0) { - deltaColor = '#f33'; // Red - } else { - deltaColor = '#888'; // Gray - } - - html += ` -
- ${stat.label}: - ${currentStr} - ${deltaStr} -
- `; - }); - - html += '
Press [A] to toggle
'; - - this.statsOverlayPanel.innerHTML = html; - } -} diff --git a/js/systems/WaveSystem.js b/js/systems/WaveSystem.js deleted file mode 100644 index 2e6bd7d..0000000 --- a/js/systems/WaveSystem.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * @file WaveSystem.js - * @description Wave management system for roguelite progression - * Manages wave counter, transitions, and triggers special spawns - */ - -class WaveSystem { - constructor(gameState) { - this.gameState = gameState; - - // Wave state - this.waveNumber = 1; - this.waveDuration = 35; // 35 seconds per wave - this.waveTimer = 0; - this.isPaused = false; - this.pauseTimer = 0; - this.pauseDuration = 1.5; // 1.5 second pause between waves - - // Spawn triggers - this.shouldSpawn = true; - - // UI callback - this.onWaveStart = null; - - logger.info('WaveSystem', 'Initialized'); - } - - /** - * Update wave system - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - if (this.isPaused) { - this.pauseTimer += deltaTime; - - if (this.pauseTimer >= this.pauseDuration) { - this.endPause(); - } - return; - } - - this.waveTimer += deltaTime; - - // Check if wave should end - if (this.waveTimer >= this.waveDuration) { - this.endWave(); - } - } - - /** - * End current wave and start pause - */ - endWave() { - this.waveNumber++; - this.waveTimer = 0; - this.isPaused = true; - this.pauseTimer = 0; - this.shouldSpawn = false; - - logger.info('WaveSystem', `Wave ${this.waveNumber - 1} completed. Starting wave ${this.waveNumber}`); - - // Trigger UI announcement - if (this.onWaveStart) { - this.onWaveStart(this.waveNumber); - } - } - - /** - * End pause and resume spawning - */ - endPause() { - this.isPaused = false; - this.pauseTimer = 0; - this.shouldSpawn = true; - - logger.info('WaveSystem', `Wave ${this.waveNumber} active`); - } - - /** - * Check if elite should spawn this wave - * @returns {boolean} True if elite should spawn - */ - shouldSpawnElite() { - return this.waveNumber % 5 === 0; - } - - /** - * Check if boss should spawn this wave - * @returns {boolean} True if boss should spawn - */ - shouldSpawnBoss() { - return this.waveNumber % 10 === 0; - } - - /** - * Get current wave number - * @returns {number} Current wave - */ - getWaveNumber() { - return this.waveNumber; - } - - /** - * Check if spawning is allowed - * @returns {boolean} True if enemies can spawn - */ - canSpawn() { - return this.shouldSpawn && !this.isPaused; - } - - /** - * Get wave progress (0-1) - * @returns {number} Wave progress - */ - getWaveProgress() { - return Math.min(1, this.waveTimer / this.waveDuration); - } - - /** - * Reset wave system - */ - reset() { - this.waveNumber = 1; - this.waveTimer = 0; - this.isPaused = false; - this.pauseTimer = 0; - this.shouldSpawn = true; - - logger.info('WaveSystem', 'Reset to wave 1'); - } -} diff --git a/js/systems/WeatherSystem.js b/js/systems/WeatherSystem.js deleted file mode 100644 index 088c56c..0000000 --- a/js/systems/WeatherSystem.js +++ /dev/null @@ -1,535 +0,0 @@ -/** - * @file WeatherSystem.js - * @description Space weather events system - meteors, black holes, and other hazards - */ - -class WeatherSystem { - constructor(world, canvas, audioManager, gameState) { - this.world = world; - this.canvas = canvas; - this.audioManager = audioManager; - this.gameState = gameState; - - // Event configuration - this.events = [ - { type: 'meteor_storm', weight: 0.5, duration: 15 }, // Changed: 15s average (12-18s random) - { type: 'black_hole', weight: 0.3, duration: 12 }, - { type: 'magnetic_storm', weight: 0.2, duration: 5 } // New: Magnetic storm disables weapons - ]; - - // Event state - this.activeEvent = null; - this.eventTimer = 0; - this.nextEventIn = this.getRandomEventDelay(); - this.warningTimer = 0; - this.showingWarning = false; - - // Meteor storm configuration - this.meteorSpawnTimer = 0; - this.meteorSpawnInterval = 0.3; // Spawn meteor every 0.3s during storm - - // Black hole configuration - DOUBLED for much stronger attraction - this.blackHoleEntity = null; - this.blackHolePullRadius = 800; // Doubled from 400 - this.blackHoleDamageRadius = 80; - - // Performance limits - this.maxMeteors = 20; - } - - /** - * Update weather system - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - // Check for new events - if (!this.activeEvent) { - this.nextEventIn -= deltaTime; - - if (this.nextEventIn <= 0) { - this.startRandomEvent(); - } - return; - } - - // Handle warning phase - if (this.showingWarning) { - this.warningTimer -= deltaTime; - if (this.warningTimer <= 0) { - this.showingWarning = false; - this.activateEvent(); - } - return; - } - - // Update active event - this.eventTimer -= deltaTime; - - if (this.eventTimer <= 0) { - this.endEvent(); - } else { - this.updateEvent(deltaTime); - } - } - - /** - * Start a random weather event - */ - startRandomEvent() { - // Select random event based on weights - const totalWeight = this.events.reduce((sum, e) => sum + e.weight, 0); - let random = Math.random() * totalWeight; - - let selectedEvent = this.events[0]; - for (const event of this.events) { - random -= event.weight; - if (random <= 0) { - selectedEvent = event; - break; - } - } - - this.activeEvent = selectedEvent; - this.showingWarning = true; - // Longer warning for black holes (4s) to give players time to react - this.warningTimer = selectedEvent.type === 'black_hole' ? 4.0 : 2.0; - - // Play warning sound - this.audioManager.playSFX('warning'); - - logger.info('WeatherSystem', `Starting event: ${selectedEvent.type}`); - } - - /** - * Activate the event after warning - */ - activateEvent() { - // Random duration variation for meteor storm (12-18s) - if (this.activeEvent.type === 'meteor_storm') { - this.eventTimer = 12 + Math.random() * 6; // 12-18 seconds - } else { - this.eventTimer = this.activeEvent.duration; - } - - if (this.activeEvent.type === 'black_hole') { - this.spawnBlackHole(); - } else if (this.activeEvent.type === 'magnetic_storm') { - this.startMagneticStorm(); - } - - // Play event start sound - if (this.activeEvent.type === 'meteor_storm') { - this.audioManager.playSFX('meteor_warning'); - } else if (this.activeEvent.type === 'black_hole') { - this.audioManager.playSFX('black_hole_spawn'); - } else if (this.activeEvent.type === 'magnetic_storm') { - this.audioManager.playSFX('electric'); - } - } - - /** - * Update the current active event - * @param {number} deltaTime - Time elapsed - */ - updateEvent(deltaTime) { - if (this.activeEvent.type === 'meteor_storm') { - this.updateMeteorStorm(deltaTime); - } else if (this.activeEvent.type === 'black_hole') { - this.updateBlackHole(deltaTime); - } else if (this.activeEvent.type === 'magnetic_storm') { - this.updateMagneticStorm(deltaTime); - } - } - - /** - * Update meteor storm event - * @param {number} deltaTime - Time elapsed - */ - updateMeteorStorm(deltaTime) { - this.meteorSpawnTimer += deltaTime; - - if (this.meteorSpawnTimer >= this.meteorSpawnInterval) { - this.meteorSpawnTimer = 0; - - // Check meteor count - const meteorCount = this.world.getEntitiesByType('meteor').length; - if (meteorCount < this.maxMeteors) { - this.spawnMeteor(); - } - } - } - - /** - * Spawn a single meteor - */ - spawnMeteor() { - const meteor = this.world.createEntity('meteor'); - - // Random position at top of screen - const x = Math.random() * this.canvas.width; - const y = -50; - - // Random velocity (downward with slight horizontal variation) - const vx = (Math.random() - 0.5) * 100; - const vy = 200 + Math.random() * 150; // 200-350 downward speed - - // Random size - const size = 20 + Math.random() * 25; // 20-45 radius - - // Random rotation speed - const rotationSpeed = (Math.random() - 0.5) * 3; - - meteor.addComponent('position', createPosition(x, y)); - meteor.addComponent('velocity', createVelocity(vx, vy)); - meteor.addComponent('collision', createCollision(size)); - meteor.addComponent('renderable', createRenderable('meteor')); - meteor.addComponent('meteor', { - damage: 30 + Math.floor(Math.random() * 20), // 30-50 damage - size: size, - rotation: Math.random() * Math.PI * 2, - rotationSpeed: rotationSpeed - }); - } - - /** - * Update black hole event - * @param {number} deltaTime - Time elapsed - */ - updateBlackHole(deltaTime) { - if (!this.blackHoleEntity || !this.blackHoleEntity.active) { - return; - } - - const blackHolePos = this.blackHoleEntity.getComponent('position'); - const blackHoleComp = this.blackHoleEntity.getComponent('black_hole'); - - // Update rotation for visual effect - blackHoleComp.rotation += deltaTime * 2; - - // Update age for grace period - blackHoleComp.age = (blackHoleComp.age || 0) + deltaTime; - const isActive = blackHoleComp.age > blackHoleComp.gracePeriod; - - // Apply gravitational pull to player (only after grace period) - const player = this.world.getEntitiesByType('player')[0]; - if (player) { - const playerPos = player.getComponent('position'); - const playerVel = player.getComponent('velocity'); - - if (playerPos && playerVel) { - const dx = blackHolePos.x - playerPos.x; - const dy = blackHolePos.y - playerPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < this.blackHolePullRadius && distance > 0 && isActive) { - // Calculate pull force (stronger when closer) - // Gentler initial pull - ramp up over 2 seconds after grace period - const eventAge = blackHoleComp.age - blackHoleComp.gracePeriod; - const pullMultiplier = Math.min(eventAge / 2.0, 1.0); // 0 to 1 over 2 seconds - const basePullStrength = (1 - distance / this.blackHolePullRadius) * 1200; // DOUBLED AGAIN from 600 for much stronger pull - const pullStrength = basePullStrength * (0.3 + 0.7 * pullMultiplier); // 30% min, 100% max - const pullX = (dx / distance) * pullStrength * deltaTime; - const pullY = (dy / distance) * pullStrength * deltaTime; - - // Apply pull to player velocity - playerVel.vx += pullX; - playerVel.vy += pullY; - } - } - } - - // Apply gravitational pull to enemies (only after grace period) - const enemies = this.world.getEntitiesByType('enemy'); - for (const enemy of enemies) { - const enemyPos = enemy.getComponent('position'); - const enemyVel = enemy.getComponent('velocity'); - - if (!enemyPos || !enemyVel) continue; - - const dx = blackHolePos.x - enemyPos.x; - const dy = blackHolePos.y - enemyPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < this.blackHolePullRadius && distance > 0 && isActive) { - // Calculate pull force (same as player but slightly weaker) - const eventAge = blackHoleComp.age - blackHoleComp.gracePeriod; - const pullMultiplier = Math.min(eventAge / 2.0, 1.0); - const basePullStrength = (1 - distance / this.blackHolePullRadius) * 250; // Slightly weaker than player - const pullStrength = basePullStrength * (0.3 + 0.7 * pullMultiplier); - const pullX = (dx / distance) * pullStrength * deltaTime; - const pullY = (dy / distance) * pullStrength * deltaTime; - - // Apply pull to enemy velocity - enemyVel.vx += pullX; - enemyVel.vy += pullY; - } - } - - // Pull and destroy XP pickups - const pickups = this.world.getEntitiesByType('pickup'); - const destroyRadius = 240; // DOUBLED from 120 - Destroy XP closer to black hole center - - for (const pickup of pickups) { - const pickupComp = pickup.getComponent('pickup'); - if (!pickupComp || pickupComp.type !== 'xp') continue; - - const pickupPos = pickup.getComponent('position'); - const pickupVel = pickup.getComponent('velocity'); - - if (!pickupPos || !pickupVel) continue; - - const dx = blackHolePos.x - pickupPos.x; - const dy = blackHolePos.y - pickupPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Pull XP orbs (faster than player) - only after grace period - if (distance < this.blackHolePullRadius && distance > 0 && isActive) { - const pullStrength = (1 - distance / this.blackHolePullRadius) * 1000; // DOUBLED from 500 - Stronger than player - const pullX = (dx / distance) * pullStrength * deltaTime; - const pullY = (dy / distance) * pullStrength * deltaTime; - - pickupVel.vx += pullX; - pickupVel.vy += pullY; - } - - // Destroy XP if too close to black hole (only after grace period) - if (distance < destroyRadius && isActive) { - // Create destruction particles - this.world.createParticles({ - x: pickupPos.x, - y: pickupPos.y, - count: 8, - color: '#00ff00', // XP orb green color - speed: 50, - lifetime: 0.5, - size: 3 - }); - - // Remove the pickup - this.world.removeEntity(pickup.id); - } - } - } - - /** - * Spawn a black hole - */ - spawnBlackHole() { - // Random position not too close to edges - const margin = 150; - const x = margin + Math.random() * (this.canvas.width - margin * 2); - const y = margin + Math.random() * (this.canvas.height - margin * 2); - - this.blackHoleEntity = this.world.createEntity('black_hole'); - - this.blackHoleEntity.addComponent('position', createPosition(x, y)); - this.blackHoleEntity.addComponent('collision', createCollision(this.blackHoleDamageRadius)); - this.blackHoleEntity.addComponent('renderable', createRenderable('black_hole')); - this.blackHoleEntity.addComponent('black_hole', { - pullRadius: this.blackHolePullRadius, - damageRadius: this.blackHoleDamageRadius, - damage: 25, // Damage per tick when too close - rotation: 0, - age: 0, // Track age for grace period - gracePeriod: 1.0, // 1 second grace period before damage/pull starts - lastPlayerDamageTime: 0, // Track last time player was damaged - lastEnemyDamageTime: {} // Track last time each enemy was damaged (by enemy ID) - }); - } - - /** - * Start magnetic storm event - */ - startMagneticStorm() { - // Random weapon disable duration 2-6 seconds - this.magneticStormTimer = 2 + Math.random() * 4; - this.gameState.weaponDisabled = true; - - // Initialize storm visual effects - this.magneticStormLightningTimer = 0; - this.magneticStormParticles = []; - - // Create storm particles (nebula effect) - for (let i = 0; i < 20; i++) { - this.magneticStormParticles.push({ - x: Math.random() * this.canvas.width, - y: Math.random() * this.canvas.height, - vx: (Math.random() - 0.5) * 50, - vy: (Math.random() - 0.5) * 50, - size: 2 + Math.random() * 3 - }); - } - - logger.info('WeatherSystem', `Magnetic storm disabling weapons for ${this.magneticStormTimer.toFixed(1)}s`); - } - - /** - * Update magnetic storm event - * @param {number} deltaTime - Time elapsed - */ - updateMagneticStorm(deltaTime) { - // Update weapon disable timer - if (this.magneticStormTimer !== undefined && this.magneticStormTimer > 0) { - this.magneticStormTimer -= deltaTime; - - // Draw visual effects (fog, lightning, nebula particles) - const canvas = this.canvas; - const ctx = canvas.getContext('2d'); - - // Draw purple fog overlay with pulsing effect - const pulseFactor = Math.sin(Date.now() / 1000 * Math.PI); // 2-second pulse period - const fogIntensity = 0.15 * (1 + 0.3 * pulseFactor); - ctx.fillStyle = `rgba(150, 0, 255, ${fogIntensity})`; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Lightning flashes - if (!this.magneticStormLightningTimer) { - this.magneticStormLightningTimer = 0.3 + Math.random() * 0.5; - } - - this.magneticStormLightningTimer -= deltaTime; - if (this.magneticStormLightningTimer <= 0) { - // Flash effect - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - this.magneticStormLightningTimer = 0.3 + Math.random() * 0.5; - } - - // Update and draw storm particles (nebula effect) - if (this.magneticStormParticles) { - ctx.fillStyle = 'rgba(200, 100, 255, 0.6)'; - for (const particle of this.magneticStormParticles) { - // Update position - particle.x += particle.vx * deltaTime; - particle.y += particle.vy * deltaTime; - - // Wrap around screen - if (particle.x < 0) particle.x = canvas.width; - if (particle.x > canvas.width) particle.x = 0; - if (particle.y < 0) particle.y = canvas.height; - if (particle.y > canvas.height) particle.y = 0; - - // Draw particle - ctx.beginPath(); - ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); - ctx.fill(); - } - } - - // Re-enable weapons when timer expires - if (this.magneticStormTimer <= 0) { - this.gameState.weaponDisabled = false; - logger.info('WeatherSystem', 'Magnetic storm ended - weapons re-enabled'); - } - } - } - - /** - * End the current event - */ - endEvent() { - // Guard: Don't try to end an event if there isn't one active - if (!this.activeEvent) return; - - logger.info('WeatherSystem', `Ending event: ${this.activeEvent.type}`); - - if (this.activeEvent.type === 'meteor_storm') { - // Remove all meteors - const meteors = this.world.getEntitiesByType('meteor'); - meteors.forEach(meteor => this.world.removeEntity(meteor.id)); - } else if (this.activeEvent.type === 'black_hole') { - // Remove black hole - if (this.blackHoleEntity) { - this.world.removeEntity(this.blackHoleEntity.id); - this.blackHoleEntity = null; - } - } else if (this.activeEvent.type === 'magnetic_storm') { - // Re-enable weapons - this.gameState.weaponDisabled = false; - this.magneticStormTimer = 0; - } - - this.activeEvent = null; - this.nextEventIn = this.getRandomEventDelay(); - this.meteorSpawnTimer = 0; - } - - /** - * Get random delay until next event - * @returns {number} Delay in seconds - */ - getRandomEventDelay() { - return 30 + Math.random() * 30; // 30-60 seconds between events - } - - /** - * Get current weather warning text - * @returns {string|null} Warning text or null - */ - getWarningText() { - if (!this.showingWarning || !this.activeEvent) { - return null; - } - - if (this.activeEvent.type === 'meteor_storm') { - return 'ALERTE: TEMPÊTE DE MÉTÉORITES APPROCHE!'; - } else if (this.activeEvent.type === 'black_hole') { - return 'ALERTE: ANOMALIE GRAVITATIONNELLE DÉTECTÉE!'; - } else if (this.activeEvent.type === 'magnetic_storm') { - return 'ALERTE: TEMPÊTE MAGNÉTIQUE APPROCHE!'; - } - - return null; - } - - /** - * Get magnetic storm status text (during active event) - * @returns {string|null} Status text or null - */ - getMagneticStormStatus() { - if (!this.activeEvent || this.activeEvent.type !== 'magnetic_storm' || this.showingWarning) { - return null; - } - - if (this.gameState.weaponDisabled && this.magneticStormTimer > 0) { - return `TEMPÊTE MAGNÉTIQUE: ARMES OFF (${Math.ceil(this.magneticStormTimer)}s)`; - } - - return null; - } - - /** - * Check if an event is currently active - * @returns {boolean} - */ - isEventActive() { - return this.activeEvent !== null && !this.showingWarning; - } - - /** - * Get current active event type - * @returns {string|null} - */ - getActiveEventType() { - if (!this.activeEvent || this.showingWarning) { - return null; - } - return this.activeEvent.type; - } - - /** - * Reset weather system (for new game) - */ - reset() { - this.endEvent(); - this.activeEvent = null; - this.eventTimer = 0; - this.nextEventIn = this.getRandomEventDelay(); - this.warningTimer = 0; - this.showingWarning = false; - this.meteorSpawnTimer = 0; - this.blackHoleEntity = null; - this.magneticStormTimer = 0; - this.gameState.weaponDisabled = false; - } -} diff --git a/js/utils/DebugOverlay.js b/js/utils/DebugOverlay.js deleted file mode 100644 index 9ccee43..0000000 --- a/js/utils/DebugOverlay.js +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Debug Overlay - On-screen debug information display - * Toggle with F3 key - */ - -class DebugOverlay { - constructor(game) { - this.game = game; - this.enabled = false; - this.container = null; - this.fpsHistory = []; - this.maxFpsHistory = 60; - this.lastFrameTime = performance.now(); - this.frameCount = 0; - this.fps = 60; - - // Performance tracking - this.updateTime = 0; - this.renderTime = 0; - - // Load saved state - this.loadSettings(); - - // Create UI - this.createUI(); - - // Setup keyboard listener - this.setupKeyboardListener(); - - // Update display regularly - if (this.enabled) { - this.show(); - } - } - - /** - * Load settings from localStorage - */ - loadSettings() { - try { - const saved = localStorage.getItem('debugOverlayEnabled'); - if (saved !== null) { - this.enabled = saved === 'true'; - } - } catch (e) { - console.warn('Could not load debug overlay settings:', e); - } - } - - /** - * Save settings to localStorage - */ - saveSettings() { - try { - localStorage.setItem('debugOverlayEnabled', this.enabled.toString()); - } catch (e) { - console.warn('Could not save debug overlay settings:', e); - } - } - - /** - * Setup keyboard listener for F3 toggle - */ - setupKeyboardListener() { - window.addEventListener('keydown', (e) => { - if (e.key === 'F3') { - e.preventDefault(); - this.toggle(); - } - // Backtick/tilde key for console commands (future) - if (e.key === '`' || e.key === '~') { - e.preventDefault(); - logger.info('DebugOverlay', 'Console commands not yet implemented'); - } - }); - } - - /** - * Create debug UI overlay - */ - createUI() { - this.container = document.createElement('div'); - this.container.id = 'debugOverlay'; - this.container.style.cssText = ` - position: fixed; - top: 10px; - right: 10px; - background: rgba(0, 0, 0, 0.85); - color: #00ff00; - font-family: 'Courier New', monospace; - font-size: 12px; - padding: 10px; - border: 2px solid #00ff00; - border-radius: 5px; - z-index: 10000; - min-width: 300px; - max-width: 400px; - max-height: 80vh; - overflow-y: auto; - display: none; - pointer-events: none; - `; - - // Add sections - this.container.innerHTML = ` -
- DEBUG MODE (F3 to toggle) -
-
-
-
-
-
- `; - - document.body.appendChild(this.container); - } - - /** - * Toggle debug overlay - */ - toggle() { - this.enabled = !this.enabled; - this.saveSettings(); - - if (this.enabled) { - this.show(); - logger.info('DebugOverlay', 'Debug overlay enabled'); - } else { - this.hide(); - logger.info('DebugOverlay', 'Debug overlay disabled'); - } - } - - /** - * Show debug overlay - */ - show() { - if (this.container) { - this.container.style.display = 'block'; - } - } - - /** - * Hide debug overlay - */ - hide() { - if (this.container) { - this.container.style.display = 'none'; - } - } - - /** - * Update FPS calculation - */ - updateFPS() { - const now = performance.now(); - const delta = now - this.lastFrameTime; - this.lastFrameTime = now; - - if (delta > 0) { - const currentFps = 1000 / delta; - this.fpsHistory.push(currentFps); - - if (this.fpsHistory.length > this.maxFpsHistory) { - this.fpsHistory.shift(); - } - - // Calculate average FPS - const sum = this.fpsHistory.reduce((a, b) => a + b, 0); - this.fps = Math.round(sum / this.fpsHistory.length); - } - - this.frameCount++; - } - - /** - * Update debug overlay display - */ - update() { - if (!this.enabled || !this.container) { - return; - } - - this.updateFPS(); - - // Update performance section - const perfSection = document.getElementById('debugPerformance'); - if (perfSection) { - const minFps = this.fpsHistory.length > 0 ? Math.min(...this.fpsHistory).toFixed(0) : 0; - const maxFps = this.fpsHistory.length > 0 ? Math.max(...this.fpsHistory).toFixed(0) : 0; - - perfSection.innerHTML = ` -
- PERFORMANCE
- FPS: ${this.fps} (min: ${minFps}, max: ${maxFps})
- Update: ${this.updateTime.toFixed(2)}ms
- Render: ${this.renderTime.toFixed(2)}ms
- Frame: ${this.frameCount} -
- `; - } - - // Update game state section - const stateSection = document.getElementById('debugGameState'); - if (stateSection && this.game.gameState) { - const state = this.game.gameState; - stateSection.innerHTML = ` -
- GAME STATE
- State: ${state.currentState}
- Time: ${state.stats.time.toFixed(1)}s
- Kills: ${state.stats.kills}
- Score: ${state.stats.score} -
- `; - } - - // Update entities section - const entitiesSection = document.getElementById('debugEntities'); - if (entitiesSection && this.game.world) { - const entities = Array.from(this.game.world.entities.values()); - const byType = {}; - - entities.forEach(entity => { - if (entity.active) { - byType[entity.type] = (byType[entity.type] || 0) + 1; - } - }); - - let entitiesHtml = '
ENTITIES
'; - entitiesHtml += `Total: ${entities.filter(e => e.active).length}
`; - for (const [type, count] of Object.entries(byType)) { - entitiesHtml += `${type}: ${count}
`; - } - entitiesHtml += '
'; - - entitiesSection.innerHTML = entitiesHtml; - } - - // Update player section - const playerSection = document.getElementById('debugPlayer'); - if (playerSection && this.game.player) { - const player = this.game.player; - const playerComp = player.getComponent('player'); - const health = player.getComponent('health'); - const pos = player.getComponent('position'); - - if (playerComp && health && pos) { - let playerHtml = '
PLAYER
'; - playerHtml += `HP: ${Math.ceil(health.current)}/${health.max}
`; - playerHtml += `Level: ${playerComp.level}
`; - playerHtml += `XP: ${playerComp.xp}/${playerComp.xpRequired}
`; - playerHtml += `Position: (${Math.round(pos.x)}, ${Math.round(pos.y)})
`; - playerHtml += `Weapons: ${playerComp.weapons.length}
`; - playerHtml += `Passives: ${playerComp.passives.length}
`; - - // Show weapon list - if (playerComp.weapons.length > 0) { - playerHtml += 'Weapons:
'; - playerComp.weapons.forEach(w => { - playerHtml += ` ${w.type} Lv${w.level}
`; - }); - } - - playerHtml += '
'; - playerSection.innerHTML = playerHtml; - } - } - - // Update logs section - const logsSection = document.getElementById('debugLogs'); - if (logsSection && window.logger) { - const recentLogs = logger.getRecentLogs(5); - let logsHtml = '
RECENT LOGS
'; - - if (recentLogs.length === 0) { - logsHtml += 'No recent logs'; - } else { - recentLogs.forEach(log => { - const color = - log.level === LogLevel.ERROR ? '#ff0000' : - log.level === LogLevel.WARN ? '#ffaa00' : - log.level === LogLevel.INFO ? '#00ff00' : - '#888888'; - - logsHtml += `[${log.category}] ${log.message}
`; - }); - } - - logsHtml += '
'; - logsSection.innerHTML = logsHtml; - } - } - - /** - * Track update time - * @param {number} time - Time in milliseconds - */ - setUpdateTime(time) { - this.updateTime = time; - } - - /** - * Track render time - * @param {number} time - Time in milliseconds - */ - setRenderTime(time) { - this.renderTime = time; - } -} - -// Make it globally accessible -if (typeof window !== 'undefined') { - window.DebugOverlay = DebugOverlay; -} diff --git a/js/utils/Logger.js b/js/utils/Logger.js deleted file mode 100644 index 95f7e13..0000000 --- a/js/utils/Logger.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Logger utility for game debugging and logging - * Provides different log levels and on-screen display - */ - -const LogLevel = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - NONE: 4 -}; - -class Logger { - constructor() { - this.currentLevel = LogLevel.INFO; - this.enabled = false; - this.maxLogs = 100; - this.logs = []; - this.categories = new Set(); - - // Load settings from localStorage - this.loadSettings(); - } - - /** - * Load logger settings from localStorage - */ - loadSettings() { - try { - const savedLevel = localStorage.getItem('debugLogLevel'); - const savedEnabled = localStorage.getItem('debugEnabled'); - - if (savedLevel !== null) { - this.currentLevel = parseInt(savedLevel); - } - if (savedEnabled !== null) { - this.enabled = savedEnabled === 'true'; - } - } catch (e) { - console.warn('Could not load logger settings:', e); - } - } - - /** - * Save logger settings to localStorage - */ - saveSettings() { - try { - localStorage.setItem('debugLogLevel', this.currentLevel.toString()); - localStorage.setItem('debugEnabled', this.enabled.toString()); - } catch (e) { - console.warn('Could not save logger settings:', e); - } - } - - /** - * Toggle debug mode on/off - */ - toggle() { - this.enabled = !this.enabled; - this.saveSettings(); - console.log(`Debug logging ${this.enabled ? 'enabled' : 'disabled'}`); - return this.enabled; - } - - /** - * Set log level - * @param {number} level - Log level from LogLevel enum - */ - setLevel(level) { - this.currentLevel = level; - this.saveSettings(); - console.log(`Log level set to: ${this.getLevelName(level)}`); - } - - /** - * Get name of log level - * @param {number} level - Log level - * @returns {string} Level name - */ - getLevelName(level) { - const names = ['DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE']; - return names[level] || 'UNKNOWN'; - } - - /** - * Add a log entry - * @param {number} level - Log level - * @param {string} category - Log category/system name - * @param {string} message - Log message - * @param {*} data - Optional additional data - */ - log(level, category, message, data = null) { - if (!this.enabled || level < this.currentLevel) { - return; - } - - const timestamp = Date.now(); - const entry = { - timestamp, - level, - category, - message, - data, - time: new Date().toLocaleTimeString() - }; - - this.logs.push(entry); - this.categories.add(category); - - // Keep only last maxLogs entries - if (this.logs.length > this.maxLogs) { - this.logs.shift(); - } - - // Also log to console - const levelName = this.getLevelName(level); - const prefix = `[${entry.time}] [${levelName}] [${category}]`; - - switch (level) { - case LogLevel.DEBUG: - console.debug(prefix, message, data || ''); - break; - case LogLevel.INFO: - console.log(prefix, message, data || ''); - break; - case LogLevel.WARN: - console.warn(prefix, message, data || ''); - break; - case LogLevel.ERROR: - console.error(prefix, message, data || ''); - break; - } - } - - /** - * Log debug message - */ - debug(category, message, data) { - this.log(LogLevel.DEBUG, category, message, data); - } - - /** - * Log info message - */ - info(category, message, data) { - this.log(LogLevel.INFO, category, message, data); - } - - /** - * Log warning message - */ - warn(category, message, data) { - this.log(LogLevel.WARN, category, message, data); - } - - /** - * Log error message - */ - error(category, message, data) { - this.log(LogLevel.ERROR, category, message, data); - } - - /** - * Get recent logs - * @param {number} count - Number of logs to retrieve - * @param {string} category - Optional category filter - * @returns {Array} Recent log entries - */ - getRecentLogs(count = 20, category = null) { - let logs = this.logs; - - if (category) { - logs = logs.filter(log => log.category === category); - } - - return logs.slice(-count); - } - - /** - * Clear all logs - */ - clear() { - this.logs = []; - console.clear(); - } - - /** - * Get all tracked categories - * @returns {Array} List of categories - */ - getCategories() { - return Array.from(this.categories); - } -} - -// Create global logger instance -const logger = new Logger(); - -// Make it accessible globally -if (typeof window !== 'undefined') { - window.logger = logger; - window.LogLevel = LogLevel; -} diff --git a/js/utils/Math.js b/js/utils/Math.js deleted file mode 100644 index 0d7627f..0000000 --- a/js/utils/Math.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @file Math.js - * @description Mathematical utility functions for game calculations - */ - -const MathUtils = { - /** - * Calculate distance between two points - * @param {number} x1 - First point x - * @param {number} y1 - First point y - * @param {number} x2 - Second point x - * @param {number} y2 - Second point y - * @returns {number} Distance - */ - distance(x1, y1, x2, y2) { - const dx = x2 - x1; - const dy = y2 - y1; - return Math.sqrt(dx * dx + dy * dy); - }, - - /** - * Calculate angle between two points - * @param {number} x1 - First point x - * @param {number} y1 - First point y - * @param {number} x2 - Second point x - * @param {number} y2 - Second point y - * @returns {number} Angle in radians - */ - angle(x1, y1, x2, y2) { - return Math.atan2(y2 - y1, x2 - x1); - }, - - /** - * Clamp a value between min and max - * @param {number} value - Value to clamp - * @param {number} min - Minimum value - * @param {number} max - Maximum value - * @returns {number} Clamped value - */ - clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); - }, - - /** - * Linear interpolation - * @param {number} a - Start value - * @param {number} b - End value - * @param {number} t - Interpolation factor (0-1) - * @returns {number} Interpolated value - */ - lerp(a, b, t) { - return a + (b - a) * t; - }, - - /** - * Random integer between min and max (inclusive) - * @param {number} min - Minimum value - * @param {number} max - Maximum value - * @returns {number} Random integer - */ - randomInt(min, max) { - return Math.floor(Math.random() * (max - min + 1)) + min; - }, - - /** - * Random float between min and max - * @param {number} min - Minimum value - * @param {number} max - Maximum value - * @returns {number} Random float - */ - randomFloat(min, max) { - return Math.random() * (max - min) + min; - }, - - /** - * Choose random element from array - * @param {Array} array - Array to choose from - * @returns {*} Random element - */ - randomChoice(array) { - return array[Math.floor(Math.random() * array.length)]; - }, - - /** - * Normalize a vector - * @param {number} x - X component - * @param {number} y - Y component - * @returns {Object} Normalized vector {x, y} - */ - normalize(x, y) { - const len = Math.sqrt(x * x + y * y); - if (len === 0) return { x: 0, y: 0 }; - return { x: x / len, y: y / len }; - }, - - /** - * Check if two circles collide - * @param {number} x1 - Circle 1 x - * @param {number} y1 - Circle 1 y - * @param {number} r1 - Circle 1 radius - * @param {number} x2 - Circle 2 x - * @param {number} y2 - Circle 2 y - * @param {number} r2 - Circle 2 radius - * @returns {boolean} True if colliding - */ - circleCollision(x1, y1, r1, x2, y2, r2) { - const dist = this.distance(x1, y1, x2, y2); - return dist < r1 + r2; - } -}; diff --git a/js/utils/ScreenEffects.js b/js/utils/ScreenEffects.js deleted file mode 100644 index 3e8b554..0000000 --- a/js/utils/ScreenEffects.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @file ScreenEffects.js - * @description Screen shake, flash, and juice effects for game feedback - */ - -class ScreenEffects { - constructor(canvas) { - this.canvas = canvas; - - // Shake state - this.shakeIntensity = 0; - this.shakeDuration = 0; - this.shakeTimer = 0; - this.shakeOffset = { x: 0, y: 0 }; - - // Flash state - this.flashColor = null; - this.flashIntensity = 0; - this.flashDuration = 0; - this.flashTimer = 0; - - logger.info('ScreenEffects', 'Initialized'); - } - - /** - * Trigger screen shake effect - * @param {number} intensity - Shake intensity (pixels) - * @param {number} duration - Shake duration (seconds) - */ - shake(intensity, duration) { - this.shakeIntensity = Math.max(this.shakeIntensity, intensity); - this.shakeDuration = Math.max(this.shakeDuration, duration); - this.shakeTimer = 0; - } - - /** - * Trigger screen flash effect - * @param {string} color - Flash color - * @param {number} intensity - Flash opacity (0-1) - * @param {number} duration - Flash duration (seconds) - */ - flash(color, intensity, duration) { - this.flashColor = color; - this.flashIntensity = Math.max(this.flashIntensity, intensity); - this.flashDuration = Math.max(this.flashDuration, duration); - this.flashTimer = 0; - } - - /** - * Update effects - * @param {number} deltaTime - Time elapsed since last frame - */ - update(deltaTime) { - // Update shake - if (this.shakeTimer < this.shakeDuration) { - this.shakeTimer += deltaTime; - - // Calculate shake offset with decay - const progress = this.shakeTimer / this.shakeDuration; - const currentIntensity = this.shakeIntensity * (1 - progress); - - this.shakeOffset.x = (Math.random() - 0.5) * currentIntensity * 2; - this.shakeOffset.y = (Math.random() - 0.5) * currentIntensity * 2; - } else { - this.shakeOffset.x = 0; - this.shakeOffset.y = 0; - } - - // Update flash - if (this.flashTimer < this.flashDuration) { - this.flashTimer += deltaTime; - } - } - - /** - * Apply shake offset to context - * @param {CanvasRenderingContext2D} ctx - Canvas context - */ - applyShake(ctx) { - if (this.shakeOffset.x !== 0 || this.shakeOffset.y !== 0) { - ctx.translate(this.shakeOffset.x, this.shakeOffset.y); - } - } - - /** - * Render flash overlay - * @param {CanvasRenderingContext2D} ctx - Canvas context - */ - renderFlash(ctx) { - if (this.flashTimer < this.flashDuration && this.flashColor) { - const progress = this.flashTimer / this.flashDuration; - const currentIntensity = this.flashIntensity * (1 - progress); - - ctx.save(); - ctx.globalAlpha = currentIntensity; - ctx.fillStyle = this.flashColor; - ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - ctx.restore(); - } - } - - /** - * Get current shake offset (returns direct reference for performance) - * @returns {{x: number, y: number}} Shake offset - */ - getShakeOffset() { - return this.shakeOffset; - } - - /** - * Reset all effects - */ - reset() { - this.shakeIntensity = 0; - this.shakeDuration = 0; - this.shakeTimer = 0; - this.shakeOffset = { x: 0, y: 0 }; - this.flashColor = null; - this.flashIntensity = 0; - this.flashDuration = 0; - this.flashTimer = 0; - } -} diff --git a/manual-test.html b/manual-test.html deleted file mode 100644 index 45ba949..0000000 --- a/manual-test.html +++ /dev/null @@ -1,59 +0,0 @@ - - - - Manual Test - - -

Manual Game Test

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..50af133 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Copy of Space InZader: Tactical Roguelite", + "description": "A deep spatial tactical roguelite featuring multi-layered defense systems, complex weapon synergies, heat management, and game-changing keystones.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/music/1263681_8-Bit-Flight.mp3 b/music/1263681_8-Bit-Flight.mp3 deleted file mode 100644 index 129b88a..0000000 Binary files a/music/1263681_8-Bit-Flight.mp3 and /dev/null differ diff --git a/music/19583_newgrounds_robot_.mp3 b/music/19583_newgrounds_robot_.mp3 deleted file mode 100644 index 40112c8..0000000 Binary files a/music/19583_newgrounds_robot_.mp3 and /dev/null differ diff --git a/music/290077_spacecake.mp3 b/music/290077_spacecake.mp3 deleted file mode 100644 index 57c244b..0000000 Binary files a/music/290077_spacecake.mp3 and /dev/null differ diff --git a/music/575907_Space-Dumka-8bit.mp3 b/music/575907_Space-Dumka-8bit.mp3 deleted file mode 100644 index 659e4c6..0000000 Binary files a/music/575907_Space-Dumka-8bit.mp3 and /dev/null differ diff --git a/music/770175_Outer-Space-Adventure-Agen.mp3 b/music/770175_Outer-Space-Adventure-Agen.mp3 deleted file mode 100644 index 1014f38..0000000 Binary files a/music/770175_Outer-Space-Adventure-Agen.mp3 and /dev/null differ diff --git a/music/888921_8-Bit-Flight-Loop.mp3 b/music/888921_8-Bit-Flight-Loop.mp3 deleted file mode 100644 index c0fae5a..0000000 Binary files a/music/888921_8-Bit-Flight-Loop.mp3 and /dev/null differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..da482af --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ + +{ + "name": "space-inzader", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/render/CoreRenderer.ts b/render/CoreRenderer.ts new file mode 100644 index 0000000..7402d55 --- /dev/null +++ b/render/CoreRenderer.ts @@ -0,0 +1,56 @@ + +import { GameState } from '../types'; +import { VIEW_SCALE } from '../constants'; +import { clearScreen, drawHexGrid, drawWorldBounds } from './WorldRenderer'; +import { drawShip } from './ShipRenderer'; +import { drawParticles, drawXPDrops, drawVisualEffects } from './EffectRenderer'; +import { renderEnvironmentalEffects } from './EventRenderer'; + +export const renderGame = ( + ctx: CanvasRenderingContext2D, + state: GameState, + dimensions: { width: number, height: number }, + camera: { x: number, y: number }, + screenShake: number, + time: number +) => { + clearScreen(ctx, dimensions); + + ctx.save(); + if (screenShake > 0) { + ctx.translate((Math.random() - 0.5) * screenShake, (Math.random() - 0.5) * screenShake); + } + + // 1. Rendu des effets plein écran (Tempêtes) avant la caméra + renderEnvironmentalEffects(ctx, state, dimensions, time); + + ctx.save(); + ctx.scale(VIEW_SCALE, VIEW_SCALE); + ctx.translate(-camera.x, -camera.y); + + // Fond de carte + drawHexGrid(ctx, camera, dimensions); + drawWorldBounds(ctx, time); // Ajout de la barrière ici + + // Layered rendering + drawParticles(ctx, state.particles); + drawXPDrops(ctx, state.xpDrops); + + // Projectiles + state.projectiles.forEach(p => { + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); + ctx.fill(); + }); + + // Entities + state.enemies.forEach(e => drawShip(ctx, e, false, time)); + drawShip(ctx, state.player, true, time); + + // Floating text + drawVisualEffects(ctx, state.effects); + + ctx.restore(); + ctx.restore(); +}; diff --git a/render/EffectRenderer.ts b/render/EffectRenderer.ts new file mode 100644 index 0000000..dbcc5b0 --- /dev/null +++ b/render/EffectRenderer.ts @@ -0,0 +1,66 @@ + +import { GameState, XPDrop, VisualEffect, Particle } from '../types'; + +export const drawXPDrops = (ctx: CanvasRenderingContext2D, drops: XPDrop[]) => { + ctx.fillStyle = '#38bdf8'; + drops.forEach(d => { + ctx.beginPath(); + ctx.arc(d.x, d.y, 4, 0, Math.PI * 2); + ctx.fill(); + }); +}; + +export const drawParticles = (ctx: CanvasRenderingContext2D, particles: Particle[]) => { + particles.forEach(p => { + ctx.globalAlpha = p.life; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.globalAlpha = 1.0; +}; + +export const drawVisualEffects = (ctx: CanvasRenderingContext2D, effects: VisualEffect[]) => { + effects.forEach(ef => { + ctx.save(); + + // Animation de "Pop" : Le texte apparaît gros puis se stabilise + const age = 1.2 - ef.life; // ef.life part de 1.2 vers 0 + const popScale = age < 0.1 ? 0.5 + (age / 0.1) * 1.0 : Math.max(1, 1.5 - (age - 0.1) * 2); + + ctx.globalAlpha = Math.min(1.0, ef.life * 2); + ctx.translate(ef.x, ef.y); + ctx.scale(popScale, popScale); + + const isSpecial = ef.text.includes('!') || ef.text.includes('GOD') || ef.text.includes('SYSTEM') || ef.text.includes('CRIT'); + const fontSize = isSpecial ? 26 : 18; + + ctx.font = `900 ${fontSize}px Orbitron`; + ctx.textAlign = 'center'; + + // Ombre portée profonde pour détacher du fond + ctx.shadowBlur = 4; + ctx.shadowColor = 'black'; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + + // Outline pour lisibilité maximale + ctx.strokeStyle = 'rgba(0,0,0,0.8)'; + ctx.lineWidth = 4; + ctx.strokeText(ef.text, 0, 0); + + // Texte principal avec un léger gradient ou couleur pure + ctx.fillStyle = ef.color; + ctx.fillText(ef.text, 0, 0); + + // Si c'est un crit, on rajoute un petit éclat blanc interne + if (ef.text.includes('CRIT')) { + ctx.fillStyle = 'white'; + ctx.font = `900 ${fontSize * 0.8}px Orbitron`; + ctx.fillText(ef.text, 0, 0); + } + + ctx.restore(); + }); +}; diff --git a/render/EventRenderer.ts b/render/EventRenderer.ts new file mode 100644 index 0000000..d843f3b --- /dev/null +++ b/render/EventRenderer.ts @@ -0,0 +1,52 @@ + +import { GameState, EnvEventType } from '../types'; + +export const renderEnvironmentalEffects = (ctx: CanvasRenderingContext2D, state: GameState, dimensions: { width: number, height: number }, time: number) => { + state.activeEvents.forEach(event => { + switch (event.type) { + case EnvEventType.SOLAR_STORM: + // Teinte orange pulsante sur tout l'écran + ctx.save(); + ctx.globalCompositeOperation = 'overlay'; + const alpha = 0.1 + Math.sin(time / 500) * 0.05; + ctx.fillStyle = `rgba(251, 146, 60, ${alpha})`; + ctx.fillRect(0, 0, dimensions.width, dimensions.height); + ctx.restore(); + break; + + case EnvEventType.BLACK_HOLE: + // Trou noir localisé + ctx.save(); + const pulse = 1.0 + Math.sin(time / 200) * 0.1; + const grd = ctx.createRadialGradient(event.x, event.y, 0, event.x, event.y, event.radius * pulse); + grd.addColorStop(0, 'rgba(0, 0, 0, 1)'); + grd.addColorStop(0.4, 'rgba(30, 27, 75, 0.8)'); + grd.addColorStop(0.7, 'rgba(139, 92, 246, 0.3)'); + grd.addColorStop(1, 'transparent'); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(event.x, event.y, event.radius * 2, 0, Math.PI * 2); + ctx.fill(); + + // Anneau d'accrétion + ctx.strokeStyle = 'rgba(236, 72, 153, 0.4)'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.ellipse(event.x, event.y, event.radius * 1.5, event.radius * 0.5, time / 1000, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + break; + + case EnvEventType.MAGNETIC_STORM: + // Glitch visuel aléatoire + if (Math.random() < 0.1) { + ctx.save(); + ctx.fillStyle = 'rgba(34, 211, 238, 0.05)'; + const h = Math.random() * 50; + ctx.fillRect(0, Math.random() * dimensions.height, dimensions.width, h); + ctx.restore(); + } + break; + } + }); +}; diff --git a/render/ParticleSystem.ts b/render/ParticleSystem.ts new file mode 100644 index 0000000..0266e01 --- /dev/null +++ b/render/ParticleSystem.ts @@ -0,0 +1,40 @@ + +import { Particle, GameState } from '../types'; + +export const emitParticles = ( + state: GameState, + x: number, + y: number, + color: string, + count: number, + speed: number = 5 +) => { + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const s = Math.random() * speed; + state.particles.push({ + x, + y, + vx: Math.cos(angle) * s, + vy: Math.sin(angle) * s, + life: 1.0, + maxLife: 0.5 + Math.random() * 0.5, + color, + size: 2 + Math.random() * 3 + }); + } +}; + +export const updateParticles = (state: GameState, deltaTime: number) => { + for (let i = state.particles.length - 1; i >= 0; i--) { + const p = state.particles[i]; + p.x += p.vx; + p.y += p.vy; + p.vx *= 0.98; + p.vy *= 0.98; + p.life -= deltaTime / p.maxLife; + if (p.life <= 0) { + state.particles.splice(i, 1); + } + } +}; diff --git a/render/ShipRenderer.ts b/render/ShipRenderer.ts new file mode 100644 index 0000000..df74809 --- /dev/null +++ b/render/ShipRenderer.ts @@ -0,0 +1,128 @@ + +import { Entity } from '../types'; + +export const drawShip = (ctx: CanvasRenderingContext2D, entity: Entity, isPlayer: boolean, time: number) => { + const { radius, rotation, vx, vy, defense, runtimeStats, subtype, lastDamageTime } = entity; + const isMoving = Math.abs(vx) > 0.1 || Math.abs(vy) > 0.1; + + // Déterminer si on doit flasher en blanc (80ms pour un effet percutant et rapide) + const flashDuration = 80; + const isFlashing = lastDamageTime && (time - lastDamageTime < flashDuration); + + ctx.save(); + ctx.translate(entity.x, entity.y); + + // 1. Barres de Santé & Shields (On ne les dessine pas si on flash pour plus de clarté) + if (!isPlayer && !isFlashing) { + const barWidth = radius * (subtype === 'boss' ? 2.5 : 2.0); + const barHeight = subtype === 'boss' ? 10 : 4; + const startY = radius + (subtype === 'boss' ? 30 : 15); + const drawHealthBar = (val: number, max: number, color: string, offset: number) => { + if (max <= 0) return; + ctx.fillStyle = 'rgba(0,0,0,0.8)'; + ctx.fillRect(-barWidth/2, startY + offset, barWidth, barHeight); + ctx.fillStyle = color; + const w = Math.max(0, (val / max) * barWidth); + ctx.fillRect(-barWidth/2, startY + offset, w, barHeight); + }; + drawHealthBar(defense.hull, runtimeStats.maxHull, '#ef4444', 8); + drawHealthBar(defense.armor, runtimeStats.maxArmor, '#f97316', 4); + if (runtimeStats.maxShield > 0) drawHealthBar(defense.shield, runtimeStats.maxShield, '#22d3ee', 0); + } + + // 2. Propulsion (Engine Trails) + if (isMoving && !isFlashing) { + ctx.save(); + ctx.rotate(rotation); + const flicker = Math.random() * 0.5 + 0.5; + const grd = ctx.createLinearGradient(-radius, 0, -radius - 30, 0); + grd.addColorStop(0, isPlayer ? 'rgba(34, 211, 238, 0.8)' : 'rgba(239, 68, 68, 0.8)'); + grd.addColorStop(1, 'transparent'); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.moveTo(-radius * 0.8, -radius * 0.3); + ctx.lineTo(-radius * 0.8 - (20 * flicker), 0); + ctx.lineTo(-radius * 0.8, radius * 0.3); + ctx.fill(); + ctx.restore(); + } + + ctx.rotate(rotation); + + // 3. Dessin de la géométrie du vaisseau + const drawGeometry = () => { + if (subtype === 'boss') { + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + } else if (subtype === 'sniper') { + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(-radius * 0.4, -radius * 0.8); + ctx.lineTo(-radius * 0.4, radius * 0.8); + ctx.closePath(); + } else if (subtype === 'kamikaze') { + const s = 1.0 + Math.sin(time / 60) * 0.1; + ctx.beginPath(); + ctx.moveTo(radius * s, 0); + ctx.lineTo(0, -radius * 0.6 * s); + ctx.lineTo(-radius * s, 0); + ctx.lineTo(0, radius * 0.6 * s); + ctx.closePath(); + } else { + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(0, -radius * 0.7); + ctx.lineTo(-radius * 0.8, -radius * 0.6); + ctx.lineTo(-radius * 0.8, radius * 0.6); + ctx.lineTo(0, radius * 0.7); + ctx.closePath(); + } + }; + + // Rendu principal ou Flash + if (isFlashing) { + ctx.fillStyle = 'white'; + drawGeometry(); + ctx.fill(); + // On ajoute un petit glow blanc pour l'impact + ctx.shadowBlur = 15; + ctx.shadowColor = 'white'; + ctx.stroke(); + } else { + const primaryColor = isPlayer ? '#1e293b' : '#450a0a'; + ctx.fillStyle = primaryColor; + drawGeometry(); + ctx.fill(); + ctx.strokeStyle = isPlayer ? '#22d3ee' : '#ef4444'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Effet spécial Boss (Cœur d'énergie) + if (subtype === 'boss') { + const grd = ctx.createRadialGradient(0, 0, 0, 0, 0, radius); + grd.addColorStop(0, '#facc15'); + grd.addColorStop(1, 'transparent'); + ctx.fillStyle = grd; + ctx.globalAlpha = 0.4 + Math.sin(time/200)*0.2; + ctx.beginPath(); + ctx.arc(0, 0, radius * 0.7, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + } + } + + ctx.restore(); + + // 4. Boucliers Globaux (Si pas en train de flasher pour éviter le bruit visuel) + if (defense.shield > 0 && !isFlashing) { + ctx.save(); + ctx.translate(entity.x, entity.y); + ctx.strokeStyle = isPlayer ? 'rgba(34, 211, 238, 0.4)' : 'rgba(239, 68, 68, 0.4)'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.arc(0, 0, radius * 1.3, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } +}; diff --git a/render/WorldRenderer.ts b/render/WorldRenderer.ts new file mode 100644 index 0000000..39b0560 --- /dev/null +++ b/render/WorldRenderer.ts @@ -0,0 +1,101 @@ + +import { VIEW_SCALE, WORLD_WIDTH, WORLD_HEIGHT } from '../constants'; + +export const drawHexGrid = (ctx: CanvasRenderingContext2D, camera: { x: number, y: number }, dimensions: { width: number, height: number }) => { + const hexSize = 100; + const hexHeight = hexSize * 2; + const hexWidth = Math.sqrt(3) * hexSize; + const vertDist = hexHeight * 0.75; + const horizDist = hexWidth; + + ctx.save(); + ctx.strokeStyle = '#0f172a'; + ctx.lineWidth = 1; + ctx.beginPath(); + + const startX = Math.floor(camera.x / horizDist) - 1; + const endX = startX + Math.ceil((dimensions.width / VIEW_SCALE) / horizDist) + 2; + const startY = Math.floor(camera.y / vertDist) - 1; + const endY = startY + Math.ceil((dimensions.height / VIEW_SCALE) / vertDist) + 2; + + for (let r = startY; r <= endY; r++) { + for (let c = startX; c <= endX; c++) { + const x = c * horizDist + (r % 2 === 1 ? horizDist / 2 : 0); + const y = r * vertDist; + + // On vérifie si on est dans les limites du monde pour dessiner la grille + if (x < -hexSize || x > WORLD_WIDTH + hexSize || y < -hexSize || y > WORLD_HEIGHT + hexSize) continue; + + for (let i = 0; i < 3; i++) { + const angle = (Math.PI / 3) * i - (Math.PI / 6); + const px = x + hexSize * Math.cos(angle); + const py = y + hexSize * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + } + } + ctx.stroke(); + ctx.restore(); +}; + +export const drawWorldBounds = (ctx: CanvasRenderingContext2D, time: number) => { + const glow = 5 + Math.sin(time / 200) * 3; + + ctx.save(); + + // 1. Ombre portée / Glow externe + ctx.shadowBlur = glow * 2; + ctx.shadowColor = '#22d3ee'; + ctx.strokeStyle = '#22d3ee'; + ctx.lineWidth = 4; + + // 2. Tracé du rectangle principal + ctx.strokeRect(0, 0, WORLD_WIDTH, WORLD_HEIGHT); + + // 3. Ligne interne plus fine + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(34, 211, 238, 0.3)'; + ctx.lineWidth = 20; + ctx.strokeRect(-10, -10, WORLD_WIDTH + 20, WORLD_HEIGHT + 20); + + // 4. Hazard Stripes dans les coins + const stripeSize = 200; + const drawCornerStripes = (x: number, y: number, rot: number) => { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rot); + ctx.beginPath(); + for (let i = 0; i < stripeSize; i += 20) { + ctx.moveTo(i, 0); + ctx.lineTo(i + 10, 0); + ctx.lineTo(0, i + 10); + ctx.lineTo(0, i); + ctx.closePath(); + } + ctx.fillStyle = 'rgba(34, 211, 238, 0.15)'; + ctx.fill(); + ctx.restore(); + }; + + drawCornerStripes(0, 0, 0); + drawCornerStripes(WORLD_WIDTH, 0, Math.PI / 2); + drawCornerStripes(WORLD_WIDTH, WORLD_HEIGHT, Math.PI); + drawCornerStripes(0, WORLD_HEIGHT, -Math.PI / 2); + + // 5. Texte d'avertissement aux bords + ctx.font = 'bold 40px Orbitron'; + ctx.fillStyle = 'rgba(34, 211, 238, 0.2)'; + ctx.textAlign = 'center'; + + // Top + ctx.fillText("WARNING - BOUNDARY REACHED - SECTOR ALPHA", WORLD_WIDTH / 2, -40); + // Bottom + ctx.fillText("WARNING - BOUNDARY REACHED - SECTOR ALPHA", WORLD_WIDTH / 2, WORLD_HEIGHT + 80); + + ctx.restore(); +}; + +export const clearScreen = (ctx: CanvasRenderingContext2D, dimensions: { width: number, height: number }) => { + ctx.fillStyle = '#020617'; + ctx.fillRect(0, 0, dimensions.width, dimensions.height); +}; diff --git a/system-test.html b/system-test.html deleted file mode 100644 index 196a9ef..0000000 --- a/system-test.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - System Test - - -

Loading Systems...

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test-new-content.html b/test-new-content.html deleted file mode 100644 index 1e1838a..0000000 --- a/test-new-content.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - Test New Weapons and Passives - - - -

🚀 Space InZader - New Content Test

- -
-

Weapon Data Validation

-
-
- -
-

Passive Data Validation

-
-
- - - - - - diff --git a/test.html b/test.html deleted file mode 100644 index a03ce6b..0000000 --- a/test.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - Test - - -

Test Page

- - - diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..4b5568a --- /dev/null +++ b/types.ts @@ -0,0 +1,243 @@ + +export enum DamageType { + EM = 'EM', + KINETIC = 'KINETIC', + EXPLOSIVE = 'EXPLOSIVE', + THERMAL = 'THERMAL', +} + +export enum Tag { + ENERGY = 'Energy', + DRONE = 'Drone', + EXPLOSIVE = 'Explosive', + BEAM = 'Beam', + KINETIC = 'Kinetic', + MINING = 'Mining', + SWARM = 'Swarm', + DEFENSIVE = 'Defensive', + AREA = 'Area', + DOT = 'DoT', + CHAIN = 'Chain', + HOMING = 'Homing', + ORBITAL = 'Orbital', + BALLISTIC = 'Ballistic', +} + +export interface Stats { + maxShield: number; + maxArmor: number; + maxHull: number; + shieldRegen: number; + armorHardness: number; + speed: number; + rotationSpeed: number; + damageMult: number; + fireRate: number; + critChance: number; + critMult: number; + cooling: number; + maxHeat: number; + magnetRange: number; + xpMult: number; + res_EM: number; + res_Kinetic: number; + res_Explosive: number; + res_Thermal: number; + res_Hull: number; + dmgTakenMult: number; + auraSlowAmount: number; + auraSlowRange: number; + overheatHullDmg: number; + missTolerance: number; + comboWindow: number; + rangeMult: number; + projectileSpeedMult: number; + dodgeChance: number; + luck: number; +} + +export interface Modifier { + id: string; + property: keyof Stats; + value: number; + type: 'additive' | 'multiplicative'; +} + +export interface Passive { + id: string; + name: string; + description: string; + modifiers: Modifier[]; + rarity: 'common' | 'rare' | 'epic' | 'legendary'; + maxStacks: number; + tags: Tag[]; +} + +export interface ActiveAbility { + id: string; + name: string; + description: string; + cooldown: number; + currentCooldown: number; + icon: string; + key: string; + execute: (state: GameState) => void; +} + +export enum EnvEventType { + SOLAR_STORM = 'SOLAR_STORM', + BLACK_HOLE = 'BLACK_HOLE', + MAGNETIC_STORM = 'MAGNETIC_STORM', + ASTEROID_BELT = 'ASTEROID_BELT' +} + +export interface EnvironmentalEvent { + id: string; + type: EnvEventType; + x: number; + y: number; + radius: number; + duration: number; + maxDuration: number; + intensity: number; +} + +export interface DamagePacket { + amount: number; + type: DamageType; + penetration: number; + isCrit: boolean; + isSynergy?: boolean; +} + +export interface DefenseState { + shield: number; + armor: number; + hull: number; +} + +export interface Weapon { + id: string; + name: string; + type: DamageType; + tags: Tag[]; + damage: number; + fireRate: number; + heatPerShot: number; + bulletSpeed: number; + bulletColor: string; + range: number; + lastFired: number; + description: string; + level: number; +} + +export interface Keystone { + id: string; + name: string; + description: string; + modifiers: Modifier[]; +} + +export interface VisualEffect { + id: string; + x: number; + y: number; + text: string; + color: string; + life: number; + vx: number; + vy: number; +} + +export interface Particle { + x: number; + y: number; + vx: number; + vy: number; + life: number; + maxLife: number; + color: string; + size: number; +} + +export interface XPDrop { + id: string; + x: number; + y: number; + amount: number; + vx: number; + vy: number; + collected: boolean; +} + +export interface Entity { + id: string; + x: number; + y: number; + rotation: number; + vx: number; + vy: number; + radius: number; + type: 'player' | 'enemy' | 'boss'; + subtype?: 'basic' | 'sniper' | 'kamikaze' | 'swarmer' | 'boss'; + dead?: boolean; + baseStats: Stats; + modifiers: Modifier[]; + runtimeStats: Stats; + statsDirty: boolean; + defense: DefenseState; + currentSlow?: number; + lastFired?: number; + lastDamageTime?: number; + marks?: { type: 'resonance', count: number }; + isGodMode?: boolean; +} + +export interface Projectile { + x: number; + y: number; + vx: number; + vy: number; + packet: DamagePacket; + color: string; + ownerId: string; + radius: number; + distanceTraveled: number; + maxRange: number; + dead?: boolean; + heatGenerated: number; +} + +export interface GameState { + player: Entity; + heat: number; + maxHeat: number; + isOverheated: boolean; + score: number; + level: number; + experience: number; + expToNextLevel: number; + wave: number; + waveTimer: number; + waveKills: number; + waveQuota: number; + totalKills: number; + startTime: number; + enemies: Entity[]; + projectiles: Projectile[]; + xpDrops: XPDrop[]; + effects: VisualEffect[]; + particles: Particle[]; + activeWeapons: Weapon[]; + activeAbilities: ActiveAbility[]; + activeEvents: EnvironmentalEvent[]; + keystones: Keystone[]; + activePassives: { passive: Passive, stacks: number }[]; + status: 'playing' | 'paused' | 'menu' | 'gameover' | 'leveling' | 'dev' | 'lab'; + comboCount: number; + comboTimer: number; + currentMisses: number; + bossSpawned: boolean; + isDebugMode: boolean; +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..fe9caee --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + open: true + } +});