diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8345c274..8bde130a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,6 +61,8 @@ scripts/ rapport d'équilibrage (npm run balance) ### Une arme Ajoute une entrée dans `data/weapons.ts`. Choisis un `behavior.kind` existant (`projectile`, `beam`, `chain`, `pulse`, `strike`, `drone`, `flame`, `mine`) et ses paramètres (`count`, `spread`, `pierce`, `homing`, `explodeRadius`, `burn`, `slow`, `knockback`, `split`, `gravity`, `fireZone`, `autoTarget`…). Les `tags` comptent pour les synergies. +Bonus de niveau : `tech: { 2: {...}, 3: {...} }` (fusionnés dans `behavior` par `weaponBehavior(w)`) et `techNotes: ['Tech II', 'Tech III']` pour le texte affiché. Les dégâts et la cadence montent automatiquement via `TECH_MULTIPLIERS`. +Mesure ton arme avec `npm run bench:weapons` (DPS mono-cible, de foule, nombre de kills contre une nuée, part du temps sans surchauffe) et compare-la aux autres. Pour un **nouveau type de tir**, ajoute la valeur dans `WeaponKind` (types.ts) et une routine dans `FIRE_HANDLERS` (WeaponSystem.ts). ### Un ennemi @@ -100,9 +102,10 @@ Valeur dans `EnvEventType` (types.ts), définition dans `data/events.ts` (durée - `addEnemy(state, type, dx, dy)` place un ennemi ; - `run(state, secondes, { keys, mouse })` fait avancer la simulation ; - `seedRandom(n)` rend l'aléatoire déterministe. +- `npm run bench:weapons` : banc d'essai des armes (Tech I et III) sur mannequins fixes ancrés et sur une nuée. - `npm run balance` : le bot de `tests/bot.ts` joue N parties par vaisseau et affiche survie, vague, kills, dégâts, et les dégâts subis par source. Variables d'environnement : `BALANCE_RUNS`, `BALANCE_SECONDS`. ## Pièges connus - Si Vite affiche « does not provide an export named … » après de grosses modifications, c'est un cache HMR périmé : redémarre `npm run dev`. -- Le HUD est conçu pour 1700×950 puis mis à l'échelle (`useHudScale`, `transform: scale`). La caméra dézoome sur petit écran (`viewScaleFor`). +- HUD (`components/HUD.tsx`) : données d'affichage calculées par des fonctions pures dans `components/hud/model.ts` (testées dans `tests/hud.test.ts`), briques visuelles dans `components/hud/widgets.tsx`. Disposition bureau dessinée pour 1280×720 puis agrandie (`hudLayout` : ×1.5 en 1080p, ×2 en 1440p) ; en dessous de ×0.9, disposition compacte (téléphone, petite fenêtre) qui laisse libres les coins des boutons tactiles. Polices : Orbitron (titres), Chakra Petch (`font-hud`, libellés), JetBrains Mono (`font-mono`, chiffres). Menus agrandis par paliers sur grand écran (classe `.ui-zoom`, `index.css`). La caméra dézoome sur petit écran (`viewScaleFor`). diff --git a/App.tsx b/App.tsx index e4d1bc24..b6b28750 100644 --- a/App.tsx +++ b/App.tsx @@ -17,6 +17,7 @@ import { triggerEvent } from './engine/EventSystem'; import { MainMenu } from './components/Menu/MainMenu'; import { TouchControls, isTouchDevice } from './components/TouchControls'; import { GameOverScreen } from './components/Menu/GameOverScreen'; +import { PauseMenu } from './components/Menu/PauseMenu'; import { MetaSave, RunSummary, loadSave, writeSave, recordRun } from './engine/Meta'; import { calculateRuntimeStats, syncDefenseState, shipBaseStats } from './engine/StatsCalculator'; @@ -47,6 +48,18 @@ const App: React.FC = () => { setUiState({ ...s }); }; + /** Abandon depuis la pause : la partie est enregistrée comme une défaite. */ + const [abandoned, setAbandoned] = useState(false); + const abandonRun = () => { + const s = engineState.current; + if (s.status !== 'paused') return; + s.status = 'gameover'; + stopBGM(); + setAbandoned(true); + finishRun(s); + setUiState({ ...s }); + }; + /** Fin de partie : enregistre records / déblocages. */ const finishRun = (s: GameState) => { const { save: next, summary } = recordRun(saveRef.current, s); @@ -82,6 +95,7 @@ const App: React.FC = () => { freshState.autoFire = saveRef.current.settings.autoFire || isTouch; freshState.autoAim = isTouch; setRunSummary(null); + setAbandoned(false); freshState.status = newStatus; freshState.startTime = Date.now(); engineState.current = freshState; @@ -312,7 +326,7 @@ const App: React.FC = () => { {uiState.isDebugMode && } - {uiState.status !== 'menu' && uiState.status !== 'dev' && uiState.status !== 'lab' && } + {uiState.status !== 'menu' && uiState.status !== 'dev' && uiState.status !== 'lab' && } {uiState.status === 'menu' && ( { {uiState.status === 'leveling' && ( { const s = engineState.current; @@ -348,23 +363,20 @@ const App: React.FC = () => { } setUiState({ ...s }); }} - currentWeapons={uiState.activeWeapons} /> )} - {isTouch && uiState.status === 'playing' && } + {isTouch && uiState.status === 'playing' && } {uiState.status === 'paused' && ( -
-

SYSTEM_PAUSE

-

P / Échap / toucher pour reprendre

-
+ )} {uiState.status === 'gameover' && ( resetGame('playing', uiState.shipId)} onMenu={() => resetGame('menu', uiState.shipId)} /> diff --git a/CLAUDE.md b/CLAUDE.md index 784024c8..63f0c7ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ > puis la section « Journal » en bas pour savoir exactement où on s'est arrêté. ## Règles du propriétaire (Linkatplug) -- **Ne JAMAIS commit ni push** sans demande explicite : il veut un seul gros commit à la fin. +- **Ne JAMAIS commit ni push** sans demande explicite. Il préfère peu de commits, gros et propres. +- **Organisation multi-sessions** : la session « Space-InZader project review » est l'ORGANISATRICE GÉNÉRALE. Tout passe par elle : elle répartit le travail, intègre, vérifie et commite. Les autres sessions (ex. « Space InZader HUD refactor ») lui rendent compte en fin de tâche. Toutes travaillent dans le MÊME dossier `H:\Space InZader` : avant de modifier un fichier, vérifier qu'aucune autre session ne travaille dessus (le dire à l'organisatrice) ; ne pas changer de branche sans prévenir ; un onglet navigateur par session (`tabs_create`) car le panneau est partagé. - Il parle français, réponses courtes et concrètes. - Objectif : **continuer et finir le jeu**. Liberté sur les priorités. - Exigences : code facile à étendre/modifier (piloté par les données), et **tests automatisés** (Vitest). @@ -59,5 +60,8 @@ Astuce : sous Git Bash, les heredocs python longs avec apostrophes cassent parfois → écrire le script dans le scratchpad et l'exécuter. - 2026-10-07 — 30 passifs (16 portés V1 : perforation, exécution, soin à l'élimination, cryo, multi-tir…, stats entières sans rendement dégressif `INTEGER_STATS`, chance → rareté `rarityWeight`). Événements data-driven (`data/events.ts` + `EVENT_HANDLERS` : météores qui blessent aussi les ennemis, trou noir qui mange l'XP, éruption solaire, tempête magnétique ; alerte → actif ; `playerModifiers` ; planification 40–70s ; bouton dev). Musique MP3 V1 dans `public/music` (playlist, repli procédural, M = muet, N = piste suivante, réglage sauvegardé). Mobile : `components/TouchControls.tsx` (joystick, dash/nova, pause), `state.autoAim`/`analogMove`, zoom caméra `viewScaleFor`, menus `justify-[safe_center]`. **166 tests verts**. - 2026-10-07 — Bot d'équilibrage (`tests/bot.ts`, `npm run balance`, `vitest.balance.config.ts`) + suivi `damageBySource`/`lastHitBy` (« Détruit par » en fin de partie). Équilibrage : refroidissement 15→25, reprise après surchauffe à 50% (`OVERHEAT_RECOVERY`), Tireur/Sniper/Kamikaze adoucis, drones nerfés. Tailwind installé localement (v3, `tailwind.config.js`, `index.css`) au lieu du CDN, `base: './'`, workflow `.github/workflows/ci.yml` (tests + déploiement gh-pages). README + ARCHITECTURE réécrits (guides « ajouter du contenu »). HUD synchronisé à 30 Hz. **166 tests verts, build OK, vérifié en navigateur (dev + build prod + mobile)**. -- ÉTAT : jeu complet et jouable. RIEN N'EST COMMITÉ (attente de l'accord du propriétaire pour un seul gros commit ; `package-lock.json` doit être inclus pour `npm ci` en CI). +- 2026-10-07 — Tout fusionné dans main (PR #37), CI + déploiement gh-pages OK, site en ligne https://linkatplug.github.io/Space-InZader/. +- 2026-10-07 — Une AUTRE SESSION refait l'UI (HUD/menus, branche ui-lisibilite) : NE PAS toucher components/ ici. Cette session : branche `gameplay-equilibrage` — banc `npm run bench:weapons` (scripts/weapons.test.ts), harmonisation des 24 armes (chiffres, noms FR, descriptions), bonus de Tech par arme (`Weapon.tech`, `Weapon.techNotes`, `weaponBehavior()`), Onde de choc touche le bord des ennemis, mines 8s, tags passifs corrigés, rééquilibrage vaisseaux. 171 tests verts. À SIGNALER à la session UI : afficher `techNotes[level-1]` dans UpgradeMenu quand on améliore une arme. +- 2026-10-07 — **Refonte UI lisibilité** (branche `ui-lisibilite`, session séparée) : HUD réécrit (`components/hud/model.ts` pur + tests `tests/hud.test.ts`, `widgets.tsx`), échelle 1280×720 + disposition compacte mobile, barre de boss, alerte surchauffe centrale, keystones actives/inactives, synergies avec paliers + infobulles, menu pause avec build complet (+ abandon), level-up avec stats d'arme et progression de synergie (touches 1/2/3), polices Chakra Petch / JetBrains Mono. Preview sur port 5174 (config `dev-ui`). +- 2026-10-07 — Intégration (organisatrice) : travail UI + gameplay réunis sur `gameplay-equilibrage`. Constante unique `BOSS_WAVE_INTERVAL` / `isBossWave` (data/enemies.ts, utilisée par CoreEngine et le HUD). Arcs bouclier/chaleur autour du vaisseau (`drawPlayerGauges`, render/CombatRenderer.ts). **189 tests verts, build OK, vérifié en navigateur.** Commité sur `gameplay-equilibrage` (à fusionner dans main via PR → déclenche le déploiement). - IDÉES SUITE : plus d'ennemis/boss, compétences actives supplémentaires (V2 n'en a que 2), écran d'options (volume), choix de keystone par vaisseau plus marqués, sprites/effets, traduction, `metadata.json` (reliquat AI Studio) à supprimer si inutile, nettoyage des branches copilot/* (demander). diff --git a/components/HUD.tsx b/components/HUD.tsx index 4169d334..16e60d8f 100644 --- a/components/HUD.tsx +++ b/components/HUD.tsx @@ -1,289 +1,513 @@ - -import React, { useMemo, useState, useEffect } from 'react'; -import { GameState, Tag, Weapon, DamageType, ActiveAbility } from '../types'; +import React, { useEffect, useState } from 'react'; +import { GameState, ActiveAbility } from '../types'; import { DAMAGE_COLORS } from '../constants'; -import { weaponCooldown } from '../engine/WeaponSystem'; -import { synergyStatus } from '../engine/Synergies'; import { EVENTS } from '../data/events'; +import { MAX_WEAPON_LEVEL } from '../engine/Progression'; +import { + HudLayout, hudLayout, formatClock, ratio, heatInfo, HeatInfo, weaponSlots, WeaponSlot, + synergyRows, SynergyRow, keystoneInfo, bossIncoming, bossInfo, +} from './hud/model'; +import { Panel, Label, Num, Meter, Pips, cx } from './hud/widgets'; + +/** + * HUD en jeu. Deux dispositions : + * - bureau : dessinée pour 1280×720 puis agrandie (×1.5 en 1080p, ×2 en 1440p) ; + * - compacte : téléphone / petite fenêtre, en bandeaux haut et bas. + * Voir `hudLayout` (components/hud/model.ts) pour le choix de l'échelle. + */ + +export const useHudLayout = (): HudLayout => { + const [layout, setLayout] = useState(() => hudLayout(window.innerWidth, window.innerHeight)); + useEffect(() => { + const onResize = () => setLayout(hudLayout(window.innerWidth, window.innerHeight)); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + return layout; +}; + +const COLORS = { + shield: '#22d3ee', + armor: '#f59e0b', + hull: '#ef4444', + xp: '#a78bfa', + wave: '#22d3ee', +}; + +const HEAT_STYLE: Record = { + ok: { color: '#38bdf8', text: 'Stable' }, + warm: { color: '#f59e0b', text: 'Chaude' }, + critical: { color: '#f97316', text: 'Critique !' }, + overheated: { color: '#ef4444', text: 'Surchauffe' }, +}; + +// --- Haut : pilote (niveau / XP / score) ------------------------------------ + +const PilotPanel: React.FC<{ state: GameState }> = ({ state }) => ( + +
+ + {state.level} +
+
+
+ + {Math.floor(state.experience)} / {state.expToNextLevel} +
+ +
+ + {state.autoFire && } +
+
+
+); + +// --- Haut : vague ------------------------------------------------------------- -const EventBanner: React.FC<{ state: GameState }> = ({ state }) => { +const WavePanel: React.FC<{ state: GameState }> = ({ state }) => { + const boss = bossIncoming(state); + return ( + +
+
+ + {formatClock(state.time)} +
+
+ + {state.wave} +
+
+ + {state.totalKills} +
+
+
+ + {state.waveKills} / {state.waveQuota} +
+ + {boss && ( +
+ +
+ )} +
+ ); +}; + +const BossBar: React.FC<{ state: GameState; width: number }> = ({ state, width }) => { + const boss = bossInfo(state); + if (!boss) return null; + return ( +
+
+ ☠ {boss.name} + {Math.ceil(boss.ratio * 100)}% +
+ +
+ ); +}; + +const EventBanner: React.FC<{ state: GameState; compact?: boolean }> = ({ state, compact }) => { const ev = state.activeEvents[0]; if (!ev) return null; const def = EVENTS[ev.type]; return (
-
- {ev.started ? def.name : `⚠ ${def.name} dans ${Math.ceil(ev.warning)}s`} - {ev.started && {Math.ceil(ev.duration)}s} +
+ {ev.started ? def.name : `⚠ ${def.name} dans ${Math.ceil(ev.warning)} s`} + {ev.started && {Math.ceil(ev.duration)} s}
-
{def.description}
+
{def.description}
); }; -/** Échelle du HUD : conçu pour 1700×950, réduit sur petits écrans. */ -export const useHudScale = () => { - const compute = () => Math.max(0.45, Math.min(1, window.innerWidth / 1700, window.innerHeight / 950)); - const [scale, setScale] = useState(compute); - useEffect(() => { - const onResize = () => setScale(compute()); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - return scale; -}; +// --- Haut droite : keystones + synergies ------------------------------------- + +const SynergyTooltip: React.FC<{ row: SynergyRow }> = ({ row }) => ( +
+
{row.name} — {row.count} objet{row.count > 1 ? 's' : ''}
+ {row.tiers.map(t => ( +
+ {t.active ? '✓' : t.count} + {t.description} +
+ ))} +
Chaque arme ou module portant un tag de la synergie compte pour 1.
+
+); -const SynergyPanel: React.FC<{ state: GameState }> = ({ state }) => { - const list = synergyStatus(state).filter(s => s.count > 0); - if (list.length === 0 && state.keystones.length === 0) return null; +const SynergyLine: React.FC<{ row: SynergyRow; compact?: boolean }> = ({ row, compact }) => { + const active = !!row.current; return ( -
- {state.keystones.map(k => ( -
- {k.icon} {k.name} -
- ))} - {list.map(({ synergy, count, activeTiers, nextTier }) => ( -
`${t.count}: ${t.description}`).join('\n')} - > - {synergy.name} - {count}{nextTier ? `/${nextTier.count}` : ' ★'} +
+
+ {row.name} + {/* Une case par objet, les paliers sont soulignés */} + + {Array.from({ length: row.maxCount }, (_, i) => { + const tier = row.tiers.some(t => t.count === i + 1); + return ( + + ); + })} + + {row.count}{row.next ? `/${row.next.count}` : '★'} +
+ {!compact && ( +
+ {active + ? ✓ {row.current} + : À {row.next?.count} : {row.next?.description}}
- ))} + )} + {!compact && }
); }; -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); +const BuildPanel: React.FC<{ state: GameState; compact?: boolean; maxHeight?: number }> = ({ state, compact, maxHeight }) => { + const rows = synergyRows(state); + if (rows.length === 0 && state.keystones.length === 0) return null; return ( -
- {Array.from({ length: segments }).map((_, i) => ( -
- ))} +
+ {state.keystones.map(k => { + const info = keystoneInfo(state, k); + const color = k.color ?? '#fbbf24'; + return ( +
+
+ {k.icon} + {k.name} + {info.kind === 'scaling' && {info.value}/{info.max}} + {info.kind === 'conditional' && compact && ( + + )} + {info.kind === 'conditional' && !compact && ( + + {info.active ? 'Actif' : 'Inactif'} + + )} +
+ {!compact && info.kind === 'conditional' && !info.active && ( +
Si {info.label}
+ )} + {!compact && ( +
+
{k.icon} {k.name}
+ {k.description} +
+ )} +
+ ); + })} + {rows.length > 0 && ( +
+ + {!compact && survoler : détails} +
+ )} + {rows.map(r => )}
); }; -const ShipBlueprint: React.FC<{ shield: number, maxShield: number, isGodMode?: boolean }> = ({ shield, maxShield, isGodMode }) => { - const shieldPerc = shield / (maxShield || 1); - return ( -
- - - - - - - - - -
- {isGodMode ? 'GOD_MODE' : 'BOUCLIER'} +// --- Bas gauche : défenses + compétences -------------------------------------- + +const DefenseRow: React.FC<{ label: string; value: number; max: number; color: string; height: number; compact?: boolean; blink?: boolean }> = + ({ label, value, max, color, height, compact, blink }) => ( +
+
+ + + {Math.ceil(Math.max(0, value))} / {Math.round(max)} +
+ +
+ ); + +const DefensePanel: React.FC<{ state: GameState; compact?: boolean }> = ({ state, compact }) => { + const { defense, runtimeStats, isGodMode } = state.player; + const lowHull = ratio(defense.hull, runtimeStats.maxHull) <= 0.3; + const h = compact ? 8 : 12; + return ( +
+ {isGodMode && } + + +
); }; -const AbilitySlot: React.FC<{ ability: ActiveAbility }> = ({ ability }) => { - const progress = ability.currentCooldown / ability.cooldown; - const isReady = ability.currentCooldown <= 0; - +const AbilitySlot: React.FC<{ ability: ActiveAbility; size?: number }> = ({ ability, size = 54 }) => { + const ready = ability.currentCooldown <= 0; + const remaining = ratio(ability.currentCooldown, ability.cooldown); return ( -
- {ability.icon} - {!isReady && ( -
- {Math.ceil(ability.currentCooldown)}s -
+
+ {ability.icon} + {!ready && ( + <> +
+ {Math.ceil(ability.currentCooldown)} + )} -
- {ability.key} + {ability.key} +
+ ); +}; + +// --- Bas centre : armes ------------------------------------------------------- + +/** En dessous de ce délai de recharge, la barre clignoterait en permanence : on l'affiche pleine. */ +const FAST_WEAPON_MS = 250; + +const WeaponCard: React.FC<{ slot: WeaponSlot | null; overheated: boolean }> = ({ slot, overheated }) => { + if (!slot) { + return ( +
+ +
+ ); + } + const { weapon } = slot; + const color = DAMAGE_COLORS[weapon.type]; + const readiness = slot.cooldownMs < FAST_WEAPON_MS ? 1 : slot.readiness; + const ready = readiness >= 1 && !overheated; + return ( +
+
+ + {weapon.name} +
+
+ + T{weapon.level} + + +
- {!isReady && ( - - - - )}
); }; -const TECH_LABELS: Record = { 1: 'I', 2: 'II', 3: 'III' }; +/** Version réduite (compacte) : pastille avec niveau Tech et recharge. */ +const WeaponChip: React.FC<{ slot: WeaponSlot | null; overheated: boolean }> = ({ slot, overheated }) => { + if (!slot) return
; + const color = DAMAGE_COLORS[slot.weapon.type]; + const readiness = overheated ? 0 : slot.cooldownMs < FAST_WEAPON_MS ? 1 : slot.readiness; + return ( +
+ T{slot.weapon.level} +
= 1 ? color : '#64748b' }} /> +
+ ); +}; -export const HUD: React.FC = ({ state }) => { - const scale = useHudScale(); - 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; +// --- Bas droite : chaleur + série -------------------------------------------- - const [now, setNow] = useState(Date.now()); - useEffect(() => { - const timer = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(timer); - }, []); +const HeatPanel: React.FC<{ state: GameState; compact?: boolean }> = ({ state, compact }) => { + const heat = heatInfo(state); + const style = HEAT_STYLE[heat.level]; + const alarm = heat.level === 'overheated' || heat.level === 'critical'; + return ( +
+
+ + + {style.text} + {heat.percent}% + +
+ +
+ ); +}; + +const ComboBadge: React.FC<{ state: GameState; compact?: boolean }> = ({ state, compact }) => { + if (state.comboCount <= 0) return null; + return ( +
+
+ + ×{state.comboCount} +
+ +
+ ); +}; - 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]); +// --- Alertes centrales -------------------------------------------------------- +const CenterAlert: React.FC<{ state: GameState; compact?: boolean }> = ({ state, compact }) => { + const heat = heatInfo(state); + if (heat.level !== 'overheated' && heat.level !== 'critical') return null; + const over = heat.level === 'overheated'; return ( -
-
- +
+
+ + {over ? '⚠ Surchauffe' : 'Chaleur critique'} + + + {over + ? `Armes coupées — reprise à ${Math.round(heat.recovery * 100)}%` + : state.autoFire ? 'Surchauffe imminente' : 'Relâchez le tir pour refroidir'} +
-
+
+ ); +}; + +/** Bordure rouge pulsante quand la coque est basse (hors échelle, plein écran). */ +const LowHullVignette: React.FC<{ state: GameState }> = ({ state }) => { + const { defense, runtimeStats, isGodMode } = state.player; + if (isGodMode || ratio(defense.hull, runtimeStats.maxHull) > 0.3) return null; + return
; +}; + +// --- Dispositions ------------------------------------------------------------- + +const DesktopHUD: React.FC<{ state: GameState; layout: HudLayout }> = ({ state }) => { + const slots = weaponSlots(state); + return ( + <> +
+ +
+ +
- {state.autoFire && ( -
Tir auto [F]
- )} - -
-
-
- Chrono - {missionTime} -
-
-
- Secteur - {wave} -
-
-
- Éliminations - {totalKills} -
-
- -
-
-
- - VAGUE {wave} : {waveKills} / {waveQuota} SUPPRESSIONS - -
-
-
- Niveau {level} - Score: {score.toLocaleString()} -
-
-
+
+ +
+
+ {state.activeAbilities.map(a => )}
+ + +
-
- -
-
- {activeAbilities.map(a => )} -
-
-
- Armure - {Math.round(defense.armor)} -
- +
+ {slots.map((s, i) => )} +
+ +
+ + + + +
+ + + + ); +}; + +const CompactHUD: React.FC<{ state: GameState; layout: HudLayout; touch: boolean }> = ({ state, layout, touch }) => { + const slots = weaponSlots(state); + const boss = bossIncoming(state); + const wide = layout.width >= 640; + return ( + <> + {/* Bandeau haut (à droite : place pour le bouton pause tactile) */} +
+ +
+ + {state.wave} + + {state.waveKills}/{state.waveQuota} + {formatClock(state.time)}
-
-
- Coque - {Math.round(defense.hull)} -
- +
+ + {state.level} + + {state.totalKills} élim.
+ {boss && } + + {bossInfo(state) && } +
+ {/* Série + build (paysage) à droite sous le bandeau */} +
+ + {wide && }
-
- {activeWeapons.map((w, i) => { - const cooldown = weaponCooldown(state, w); - const elapsed = state.time - w.lastFired; - const progress = Math.min(1, elapsed / cooldown); - return ( -
-
- TECH {TECH_LABELS[w.level]} -
-
- {w.name} -
-
-
-
= 1 ? DAMAGE_COLORS[w.type] : '#1e293b' }} /> -
-
- ); - })} + {/* Bas gauche : armes, défenses, chaleur (à droite : place pour les boutons tactiles) */} +
+
+ {slots.map((s, i) => )} +
+ + + +
-
- {comboCount > 0 && ( -
-
- SÉRIE - x{comboCount} -
-
- )} - -
- Thermique - + {!touch && ( +
+ {state.activeAbilities.map(a => )}
+ )} + + + + ); +}; -
- 0 ? 'text-cyan-400' : 'text-slate-500'}`}> - {comboCount > 0 ? 'Série' : 'Neutre'} - - 0 ? comboTimer : 0} - max={runtimeStats.comboWindow} - segments={10} - color="#22d3ee" - vertical - /> +export const HUD: React.FC<{ state: GameState; touch?: boolean }> = ({ state, touch = false }) => { + const layout = useHudLayout(); + return ( +
+ +
+
+ {layout.compact ? : }
-
); }; diff --git a/components/Menu/GameOverScreen.tsx b/components/Menu/GameOverScreen.tsx index fe1bbaca..01baced1 100644 --- a/components/Menu/GameOverScreen.tsx +++ b/components/Menu/GameOverScreen.tsx @@ -3,6 +3,8 @@ import { GameState } from '../../types'; import { RunSummary } from '../../engine/Meta'; import { getShip } from '../../data/ships'; import { ENEMIES } from '../../data/enemies'; +import { DAMAGE_COLORS } from '../../constants'; +import { formatClock } from '../hud/model'; const SOURCE_NAMES: Record = { meteor: 'Météore', black_hole: 'Trou noir', solar_storm: 'Éruption solaire' }; const sourceName = (id: string) => ENEMIES[id]?.name ?? SOURCE_NAMES[id] ?? id; @@ -10,63 +12,69 @@ const sourceName = (id: string) => ENEMIES[id]?.name ?? SOURCE_NAMES[id] ?? id; interface GameOverProps { state: GameState; summary: RunSummary | null; + abandoned?: boolean; onRetry: () => void; onMenu: () => void; } -const fmtTime = (sec: number) => `${Math.floor(sec / 60).toString().padStart(2, '0')}:${Math.floor(sec % 60).toString().padStart(2, '0')}`; +const Stat: React.FC<{ label: string; value: string | number; record?: boolean }> = ({ label, value, record }) => ( +
+
{label}
+
{value}
+ {record &&
★ Nouveau record
} +
+); -export const GameOverScreen: React.FC = ({ state, summary, onRetry, onMenu }) => { +export const GameOverScreen: React.FC = ({ state, summary, abandoned, onRetry, onMenu }) => { const ship = getShip(state.shipId); - const Stat = ({ label, value, highlight }: { label: string; value: string | number; highlight?: boolean }) => ( -
-
{label}
-
{value}{highlight ? ' ★' : ''}
-
- ); - return ( -
-

Critical_Failure

-
- {ship.name} détruit{state.lastHitBy ? ` par : ${sourceName(state.lastHitBy)}` : ''} +
+
+

+ {abandoned ? 'Mission abandonnée' : 'Vaisseau détruit'} +

+
+ {ship.name}{!abandoned && state.lastHitBy ? ` — abattu par : ${sourceName(state.lastHitBy)}` : ''}
-
- - +
+ + - + - - + +
-
-
Build
+
+
Build
{state.activeWeapons.map(w => ( - {w.name} T{w.level} + + {w.name} T{w.level} + ))} {state.keystones.map(k => ( - {k.icon} {k.name} + {k.icon} {k.name} ))} {state.activePassives.map(p => ( - {p.passive.name} ×{p.stacks} + {p.passive.name} ×{p.stacks} ))}
{summary && summary.unlockedShips.length > 0 && ( -
+
Nouveau vaisseau débloqué : {summary.unlockedShips.map(s => s.name).join(', ')}
)} -
- - +
+ + +
); diff --git a/components/Menu/MainMenu.tsx b/components/Menu/MainMenu.tsx index ccdf5b87..9c5f8840 100644 --- a/components/Menu/MainMenu.tsx +++ b/components/Menu/MainMenu.tsx @@ -13,6 +13,13 @@ interface MainMenuProps { const DIFFICULTY_COLOR = { facile: 'text-green-400', moyen: 'text-amber-400', difficile: 'text-red-400' }; +const CONTROLS_HELP: [string, string][] = [ + ['ZQSD', 'Se déplacer'], ['Souris', 'Viser'], + ['Clic / Espace', 'Tirer'], ['F', 'Tir auto'], + ['Shift', 'Dash'], ['E', 'Nova'], + ['P / Échap', 'Pause'], ['M / N', 'Son / piste'], +]; + export const MainMenu: React.FC = ({ save, onStart, onDev, onLab }) => { const initial = SHIPS.find(s => s.id === save.lastShipId && isShipUnlocked(s, save)) ?? SHIPS[0]; const [selected, setSelected] = useState(initial.id); @@ -22,10 +29,11 @@ export const MainMenu: React.FC = ({ save, onStart, onDev, onLab const keystone = KEYSTONES.find(k => k.id === ship.signatureKeystone); return ( -
-

Space InZader

-
- Record : vague {save.bestWave} · {save.bestScore.toLocaleString()} pts · {save.totalRuns} parties +
+
+

Space InZader

+
+ Record : vague {save.bestWave} · {save.bestScore.toLocaleString('fr-FR')} pts · {save.totalRuns} partie{save.totalRuns > 1 ? 's' : ''}
@@ -36,14 +44,14 @@ export const MainMenu: React.FC = ({ save, onStart, onDev, onLab ); })} @@ -51,27 +59,27 @@ export const MainMenu: React.FC = ({ save, onStart, onDev, onLab
-

{unlocked ? ship.name : 'Vaisseau verrouillé'}

- {ship.difficulty} +

{unlocked ? ship.name : 'Vaisseau verrouillé'}

+ {ship.difficulty}
{unlocked ? ( <> -

{ship.description}

-
+

{ship.description}

+
-
Arme de départ
-
{weapon?.name}
-
{weapon?.description}
+
Arme de départ
+
{weapon?.name}
+
{weapon?.description}
-
Keystone signature
-
{keystone?.icon} {keystone?.name}
-
{keystone?.description}
+
Keystone signature
+
{keystone?.icon} {keystone?.name}
+
{keystone?.description}
) : ( -

Condition : {unlockLabel(ship)}

+

🔒 Condition : {unlockLabel(ship)}

)}
@@ -79,18 +87,21 @@ export const MainMenu: React.FC = ({ save, onStart, onDev, onLab
- - + +
-
- ZQSD/WASD : déplacer · Souris : viser · Clic/Espace : tirer · F : tir auto · Shift : dash · E : nova · P : pause · M : son · N : piste suivante +
+ {CONTROLS_HELP.map(([k, v]) => ( +
{k}{v}
+ ))}
+
); }; diff --git a/components/Menu/PauseMenu.tsx b/components/Menu/PauseMenu.tsx new file mode 100644 index 00000000..6a3885b3 --- /dev/null +++ b/components/Menu/PauseMenu.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { GameState } from '../../types'; +import { DAMAGE_COLORS } from '../../constants'; +import { MAX_WEAPON_LEVEL } from '../../engine/Progression'; +import { formatClock, keystoneInfo, synergyRows, techNote, DAMAGE_LABELS, RARITY_LABELS } from '../hud/model'; +import { Pips, cx } from '../hud/widgets'; + +interface PauseMenuProps { + state: GameState; + onResume: () => void; + onQuit: () => void; +} + +const Section: React.FC<{ title: string; children: React.ReactNode; empty?: string }> = ({ title, children, empty }) => ( +
+

{title}

+ {children ??

{empty}

} +
+); + +const Item: React.FC<{ color: string; title: React.ReactNode; right?: React.ReactNode; children?: React.ReactNode }> = ({ color, title, right, children }) => ( +
+
+ {title} + {right} +
+ {children &&
{children}
} +
+); + +/** Pause : reprise, abandon et récapitulatif complet du build (synergies, keystones, modules). */ +export const PauseMenu: React.FC = ({ state, onResume, onQuit }) => { + const synergies = synergyRows(state); + return ( +
+
+
+
+

PAUSE

+
+ Vague {state.wave} · Niveau {state.level} · {formatClock(state.time)} · {state.totalKills} éliminations +
+
+
+ + +
+
+ +
+
+ {state.activeWeapons.map(w => ( + T{w.level}} + > + {DAMAGE_LABELS[w.type]} — {w.description} + {Array.from({ length: w.level - 1 }, (_, i) => i + 2).map(l => techNote(w, l) && ( +
T{l} ✓ {techNote(w, l)}
+ ))} +
+ ))} +
+ +
+ {synergies.length > 0 ? synergies.map(r => ( + {r.name}} right={{r.count} objet{r.count > 1 ? 's' : ''}}> + {r.tiers.map(t => ( +
+ {t.active ? '✓' : t.count} + {t.description} +
+ ))} +
+ )) : null} +
+ +
+
+ {state.keystones.length > 0 ? state.keystones.map(k => { + const info = keystoneInfo(state, k); + const status = info.kind === 'scaling' ? `${info.value} / ${info.max} ${info.label}` + : info.kind === 'conditional' ? (info.active ? 'Active' : `Inactive — si ${info.label}`) + : 'Permanente'; + return ( + + {k.description} +
{status}
+
+ ); + }) : null} +
+ +
+ {state.activePassives.length > 0 ? state.activePassives.map(({ passive, stacks }) => ( + {stacks}/{passive.maxStacks}}> + {RARITY_LABELS[passive.rarity]} — {passive.description} + + )) : null} +
+
+
+
+
+ ); +}; diff --git a/components/Menu/UpgradeMenu.tsx b/components/Menu/UpgradeMenu.tsx index f79f7bb0..ae6ffbb4 100644 --- a/components/Menu/UpgradeMenu.tsx +++ b/components/Menu/UpgradeMenu.tsx @@ -1,84 +1,142 @@ - -import React from 'react'; -import { Weapon, Passive } from '../../types'; +import React, { useEffect, useRef } from 'react'; +import { GameState, Passive } from '../../types'; import { UpgradeOption } from '../../engine/Progression'; +import { DAMAGE_COLORS } from '../../constants'; +import { synergyGains, upgradeKind, weaponStats, techNote, TAG_LABELS, DAMAGE_LABELS } from '../hud/model'; +import { MAX_WEAPON_LEVEL } from '../../engine/Progression'; +import { cx } from '../hud/widgets'; interface UpgradeMenuProps { + state: GameState; options: UpgradeOption[]; onSelect: (upgrade: UpgradeOption) => void; - currentWeapons: Weapon[]; } -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_COLOR: Record = { + common: '#94a3b8', + rare: '#22d3ee', + epic: '#c084fc', + legendary: '#fbbf24', }; -const RARITY_BGS = { - common: 'bg-slate-900/60', - rare: 'bg-cyan-950/20', - epic: 'bg-purple-950/20', - legendary: 'bg-amber-950/20', +const accentOf = (opt: UpgradeOption) => + opt.type === 'keystone' ? (opt.item.color ?? RARITY_COLOR.legendary) + : opt.type === 'weapon' ? DAMAGE_COLORS[opt.item.type] + : RARITY_COLOR[opt.item.rarity]; + +const Stat: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+ {label} + {value} +
+); + +const OptionCard: React.FC<{ state: GameState; opt: UpgradeOption; index: number; onSelect: () => void }> = ({ state, opt, index, onSelect }) => { + const accent = accentOf(opt); + const gains = synergyGains(state, opt); + const owned = opt.type === 'weapon' ? state.activeWeapons.find(w => w.id === opt.item.id) : undefined; + const stats = opt.type === 'weapon' ? weaponStats(opt.item, owned ? owned.level + 1 : 1) : null; + return ( + + ); }; -export const UpgradeMenu: React.FC = ({ options, onSelect, 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`; - if (type === 'keystone') return 'KEYSTONE — UNIQUE'; - return 'SYSTÈME'; +export const UpgradeMenu: React.FC = ({ state, options, onSelect }) => { + // Un seul choix par tirage (évite le double clic / la répétition de touche) + const locked = useRef(false); + useEffect(() => { locked.current = false; }, [options]); + const choose = (opt: UpgradeOption) => { + if (locked.current) return; + locked.current = true; + onSelect(opt); }; + // Raccourcis clavier 1 / 2 / 3 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.repeat) return; + const i = Number(e.key) - 1; + if (Number.isInteger(i) && i >= 0 && i < options.length) choose(options[i]); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); + return ( -
-
-
-

Modification Système Détectée

- Auth : Terminal de Commandement +
+
+
+
+
Niveau {state.level + 1} atteint
+

Choisissez une amélioration

+
+ Clic ou touches 1 2 3
- -
- {options.map((opt, i) => { - const rarity = opt.type === 'keystone' ? 'legendary' : ((opt.item as Passive).rarity || 'common'); - const existingWeapon = opt.type === 'weapon' ? currentWeapons.find(cw => cw.id === opt.item.id) : null; - - return ( - - ); - })} +
+ {options.map((opt, i) => ( + choose(opt)} /> + ))}
diff --git a/components/TouchControls.tsx b/components/TouchControls.tsx index 29b889e1..6500a8c0 100644 --- a/components/TouchControls.tsx +++ b/components/TouchControls.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState } from 'react'; import { input } from '../engine/InputManager'; -import { CONTROLS } from '../constants'; +import { ActiveAbility } from '../types'; /** Détection d'un écran tactile principal (téléphone / tablette). */ export const isTouchDevice = () => @@ -12,7 +12,7 @@ const RADIUS = 60; * Contrôles tactiles : joystick virtuel à gauche (déplacement), * boutons de compétences et de pause à droite. Visée et tir automatiques. */ -export const TouchControls: React.FC<{ onPause: () => void }> = ({ onPause }) => { +export const TouchControls: React.FC<{ onPause: () => void; abilities: ActiveAbility[] }> = ({ onPause, abilities }) => { const [stick, setStick] = useState<{ ox: number; oy: number; x: number; y: number } | null>(null); const touchId = useRef(null); @@ -38,15 +38,32 @@ export const TouchControls: React.FC<{ onPause: () => void }> = ({ onPause }) => input.setAnalog(0, 0); }; - const Btn = ({ label, onTap, className = '' }: { label: string; onTap: () => void; className?: string }) => ( + const Btn = ({ label, onTap, className = '', style }: { label: React.ReactNode; onTap: () => void; className?: string; style?: React.CSSProperties }) => ( ); + /** Bouton de compétence : anneau de recharge + secondes restantes. */ + const AbilityBtn = ({ ability }: { ability: ActiveAbility }) => { + const ready = ability.currentCooldown <= 0; + const remaining = Math.min(1, ability.currentCooldown / (ability.cooldown || 1)); + return ( + input.tap(ability.key)} + className={`w-[68px] h-[68px] ${ready ? 'shadow-[0_0_14px_rgba(34,211,238,0.6)]' : 'border-slate-500/70'}`} + style={{ background: ready ? undefined : `conic-gradient(rgba(2,6,23,0.85) ${remaining * 360}deg, rgba(34,211,238,0.25) 0)` }} + label={ready + ? {ability.icon} + : {Math.ceil(ability.currentCooldown)}} + /> + ); + }; + return (
{/* Zone joystick : moitié gauche */} @@ -65,12 +82,19 @@ export const TouchControls: React.FC<{ onPause: () => void }> = ({ onPause }) => style={{ left: stick.x - 24, top: stick.y - 24, width: 48, height: 48 }} /> )} -
- input.tap(CONTROLS.ABILITY_1)} /> - input.tap(CONTROLS.ABILITY_2)} /> + {/* Compétences : coin bas droit (le HUD compact laisse cette zone libre) */} +
+ {[...abilities].reverse().map(a => )}
-
- + {/* Pause : coin haut droit (le bandeau du HUD compact s'arrête avant) */} +
+ ⏸} onTap={onPause} className="w-11 h-11" />
); diff --git a/components/hud/model.ts b/components/hud/model.ts new file mode 100644 index 00000000..bfc4e170 --- /dev/null +++ b/components/hud/model.ts @@ -0,0 +1,266 @@ +/** + * Modèle du HUD : fonctions pures qui transforment le GameState en données d'affichage. + * Aucune dépendance au DOM ni à React → testable sous Node (tests/hud.test.ts). + */ +import { ConditionId, DamageType, GameState, Keystone, Passive, ScalingSource, Tag, Weapon } from '../../types'; +import type { UpgradeOption } from '../../engine/Progression'; +import { synergyStatus } from '../../engine/Synergies'; +import { CONDITIONS, SCALING_SOURCES } from '../../engine/Conditions'; +import { OVERHEAT_RECOVERY } from '../../engine/CoreEngine'; +import { weaponCooldown } from '../../engine/WeaponSystem'; +import { MAX_WEAPON_SLOTS, TECH_MULTIPLIERS } from '../../constants'; +import { ENEMIES, BOSS_WAVE_INTERVAL } from '../../data/enemies'; + +// --- Mise à l'échelle ------------------------------------------------------- + +/** Résolution de référence du HUD bureau : tout est dessiné pour 1280×720 puis agrandi. */ +export const HUD_BASE = { width: 1280, height: 720 }; +/** En dessous de cette échelle, le HUD bureau deviendrait illisible → disposition compacte. */ +export const COMPACT_THRESHOLD = 0.9; +/** Côté de référence de la disposition compacte (téléphone, petite fenêtre). */ +export const COMPACT_BASE = 400; + +export interface HudLayout { + scale: number; // facteur CSS appliqué au HUD + compact: boolean; // disposition mobile / petite fenêtre + width: number; // taille du « viewport virtuel » du HUD (px avant mise à l'échelle) + height: number; +} + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + +/** + * Échelle et disposition du HUD pour une fenêtre donnée. + * Bureau : 1280×720 → ×1, 1920×1080 → ×1.5, 2560×1440 → ×2. + * Compact : le plus petit côté vaut ~400 px virtuels (375×812 → ×0.94). + */ +export const hudLayout = (w: number, h: number): HudLayout => { + const s = Math.min(w / HUD_BASE.width, h / HUD_BASE.height); + const compact = s < COMPACT_THRESHOLD; + const scale = compact ? clamp(Math.min(w, h) / COMPACT_BASE, 0.75, 1.15) : Math.min(s, 2.5); + return { scale, compact, width: w / scale, height: h / scale }; +}; + +// --- Formats ---------------------------------------------------------------- + +export const formatClock = (ms: number) => { + const sec = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(sec / 60).toString().padStart(2, '0')}:${(sec % 60).toString().padStart(2, '0')}`; +}; + +export const ratio = (value: number, max: number) => (max > 0 ? clamp(value / max, 0, 1) : 0); + +// --- Chaleur ---------------------------------------------------------------- + +export type HeatLevel = 'ok' | 'warm' | 'critical' | 'overheated'; + +export interface HeatInfo { + ratio: number; + percent: number; + level: HeatLevel; + recovery: number; // seuil de reprise après surchauffe (0..1) +} + +export const heatInfo = (s: GameState): HeatInfo => { + const r = ratio(s.heat, s.maxHeat); + const level: HeatLevel = s.isOverheated ? 'overheated' : r >= 0.85 ? 'critical' : r >= 0.6 ? 'warm' : 'ok'; + return { ratio: r, percent: Math.round(r * 100), level, recovery: OVERHEAT_RECOVERY }; +}; + +// --- Armes ------------------------------------------------------------------ + +export interface WeaponSlot { + weapon: Weapon; + readiness: number; // 0..1, 1 = prêt à tirer + cooldownMs: number; +} + +/** Emplacements d'armes (MAX_WEAPON_SLOTS), `null` pour un emplacement libre. */ +export const weaponSlots = (s: GameState): (WeaponSlot | null)[] => + Array.from({ length: MAX_WEAPON_SLOTS }, (_, i) => { + const weapon = s.activeWeapons[i]; + if (!weapon) return null; + const cooldownMs = weaponCooldown(s, weapon); + return { weapon, cooldownMs, readiness: ratio(s.time - weapon.lastFired, cooldownMs) }; + }); + +// --- Synergies -------------------------------------------------------------- + +export interface SynergyRow { + id: string; + name: string; + color: string; + count: number; + maxCount: number; // palier le plus haut + tiers: { count: number; description: string; active: boolean }[]; + current?: string; // effet du palier actif le plus haut + next?: { count: number; description: string }; +} + +/** Synergies entamées (au moins un objet), actives d'abord. */ +export const synergyRows = (s: GameState): SynergyRow[] => + synergyStatus(s) + .filter(st => st.count > 0) + .map(({ synergy, count, activeTiers, nextTier }) => ({ + id: synergy.id, + name: synergy.name, + color: synergy.color, + count, + maxCount: synergy.tiers[synergy.tiers.length - 1].count, + tiers: synergy.tiers.map(t => ({ count: t.count, description: t.description, active: count >= t.count })), + current: activeTiers[activeTiers.length - 1]?.description, + next: nextTier && { count: nextTier.count, description: nextTier.description }, + })) + .sort((a, b) => Number(!!b.current) - Number(!!a.current) || b.count - a.count); + +// --- Keystones -------------------------------------------------------------- + +export const CONDITION_LABELS: Record = { + highHeat: 'chaleur ≥ 80%', + overheated: 'en surchauffe', + lowHull: 'coque ≤ 30%', + shieldDown: 'bouclier à zéro', + stationary: 'immobile', +}; + +export const SCALING_LABELS: Record = { + hitStreak: 'série d\'impacts', + droneCount: 'drones', + comboCount: 'série', + onHitStacks: 'cumul', +}; + +export type KeystoneInfo = + | { kind: 'permanent'; active: true } + | { kind: 'conditional'; active: boolean; label: string } + | { kind: 'scaling'; active: boolean; value: number; max: number; label: string }; + +/** État dynamique d'une keystone : permanente, conditionnelle (active ?) ou à l'échelle (x / max). */ +export const keystoneInfo = (s: GameState, k: Keystone): KeystoneInfo => { + const scaled = k.modifiers.find(m => m.scaling); + if (scaled?.scaling) { + const { source, max } = scaled.scaling; + const value = Math.min(SCALING_SOURCES[source](s), max); + return { kind: 'scaling', active: value > 0, value, max, label: SCALING_LABELS[source] }; + } + const conds = [...new Set(k.modifiers.map(m => m.condition).filter((c): c is ConditionId => !!c))]; + if (conds.length) { + return { kind: 'conditional', active: conds.every(c => CONDITIONS[c](s)), label: conds.map(c => CONDITION_LABELS[c]).join(' + ') }; + } + return { kind: 'permanent', active: true }; +}; + +// --- Vague / boss ----------------------------------------------------------- + +/** Les boss apparaissent quand la vague atteint un multiple de BOSS_WAVE_INTERVAL (data/enemies.ts). */ +export { BOSS_WAVE_INTERVAL }; +export const bossIncoming = (s: GameState) => (s.wave + 1) % BOSS_WAVE_INTERVAL === 0; + +export interface BossInfo { name: string; color: string; ratio: number; } + +export const bossInfo = (s: GameState): BossInfo | null => { + const boss = s.enemies.find(e => e.type === 'boss' && !e.dead); + if (!boss) return null; + const def = boss.subtype ? ENEMIES[boss.subtype] : undefined; + const { maxShield, maxArmor, maxHull } = boss.runtimeStats; + const { shield, armor, hull } = boss.defense; + return { + name: def?.name ?? 'Boss', + color: def?.color ?? '#facc15', + ratio: ratio(shield + armor + hull, maxShield + maxArmor + maxHull), + }; +}; + +// --- Libellés --------------------------------------------------------------- + +export const TAG_LABELS: Record = { + [Tag.ENERGY]: 'Énergie', + [Tag.DRONE]: 'Drone', + [Tag.EXPLOSIVE]: 'Explosif', + [Tag.BEAM]: 'Rayon', + [Tag.KINETIC]: 'Cinétique', + [Tag.MINING]: 'Minage', + [Tag.SWARM]: 'Essaim', + [Tag.DEFENSIVE]: 'Défensif', + [Tag.AREA]: 'Zone', + [Tag.DOT]: 'Brûlure', + [Tag.CHAIN]: 'Chaîne', + [Tag.HOMING]: 'Guidé', + [Tag.ORBITAL]: 'Orbital', + [Tag.BALLISTIC]: 'Balistique', +}; + +export const DAMAGE_LABELS: Record = { + [DamageType.EM]: 'EM', + [DamageType.KINETIC]: 'Cinétique', + [DamageType.EXPLOSIVE]: 'Explosif', + [DamageType.THERMAL]: 'Thermique', +}; + +export const RARITY_LABELS: Record = { + common: 'Commun', + rare: 'Rare', + epic: 'Épique', + legendary: 'Légendaire', +}; + +// --- Choix d'amélioration ----------------------------------------------------- + +export interface SynergyGain { + id: string; + name: string; + color: string; + from: number; + to: number; + unlocks?: string; // palier débloqué par ce choix + next?: { count: number; description: string }; // palier suivant après ce choix +} + +/** Progression de synergie qu'apporterait un choix (un objet déjà possédé ne compte pas deux fois). */ +export const synergyGains = (s: GameState, opt: UpgradeOption): SynergyGain[] => { + if (opt.type === 'keystone') return []; + const isNew = opt.type === 'weapon' + ? !s.activeWeapons.some(w => w.id === opt.item.id) + : !s.activePassives.some(p => p.passive.id === opt.item.id); + const tags = opt.item.tags; + return synergyStatus(s) + .filter(st => st.synergy.tags.some(t => tags.includes(t))) + .map(({ synergy, count }) => { + const to = count + (isNew ? 1 : 0); + return { + id: synergy.id, + name: synergy.name, + color: synergy.color, + from: count, + to, + unlocks: isNew ? synergy.tiers.find(t => t.count === to)?.description : undefined, + next: synergy.tiers.find(t => t.count > to), + }; + }); +}; + +/** Description courte d'un choix : nouvelle arme, Tech n → n+1, module (cumul), keystone. */ +export const upgradeKind = (s: GameState, opt: UpgradeOption): string => { + if (opt.type === 'keystone') return 'Keystone — unique'; + if (opt.type === 'weapon') { + const owned = s.activeWeapons.find(w => w.id === opt.item.id); + return owned ? `Amélioration Tech ${owned.level} → ${owned.level + 1}` : 'Nouvelle arme'; + } + const owned = s.activePassives.find(p => p.passive.id === opt.item.id); + const stacks = owned?.stacks ?? 0; + return `Module ${RARITY_LABELS[opt.item.rarity].toLowerCase()} · ${stacks} → ${stacks + 1} / ${opt.item.maxStacks}`; +}; + +/** Caractéristiques affichées d'une arme au niveau Tech donné. */ +export const weaponStats = (w: Weapon, level = w.level) => { + const mult = TECH_MULTIPLIERS[level] || 1; + return { + damage: Math.round(w.damage * mult), + fireRate: Math.round(w.fireRate * Math.sqrt(mult) * 10) / 10, + heat: w.heatPerShot, + range: w.range, + }; +}; + +/** Bonus apporté par le passage au niveau Tech `level` (2 ou 3), depuis `Weapon.techNotes`. */ +export const techNote = (w: Weapon, level: number): string | undefined => w.techNotes?.[level - 2]; diff --git a/components/hud/widgets.tsx b/components/hud/widgets.tsx new file mode 100644 index 00000000..5d2f03d8 --- /dev/null +++ b/components/hud/widgets.tsx @@ -0,0 +1,72 @@ +/** + * Briques visuelles du HUD (panneaux, libellés, jauges). + * Tailles minimales : libellés 12-13 px, chiffres 14 px et plus (avant mise à l'échelle). + */ +import React from 'react'; + +export const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' '); + +/** Cadre sombre semi-transparent avec liseré néon. */ +export const Panel: React.FC<{ className?: string; accent?: string; style?: React.CSSProperties; children: React.ReactNode; title?: string }> = + ({ className, accent = '#22d3ee', style, children, title }) => ( +
+ + + {children} +
+ ); + +/** Libellé court en capitales, lisible (≥ 12 px). */ +export const Label: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }> = ({ className, style, children }) => ( + + {children} + +); + +/** Valeur numérique à chasse fixe. */ +export const Num: React.FC<{ className?: string; style?: React.CSSProperties; children: React.ReactNode }> = ({ className, style, children }) => ( + {children} +); + +/** + * Jauge horizontale. `value` ∈ [0,1]. `markers` : repères verticaux (seuils) ∈ [0,1]. + */ +export const Meter: React.FC<{ + value: number; + color: string; + height?: number; + markers?: number[]; + className?: string; + blink?: boolean; + track?: string; +}> = ({ value, color, height = 10, markers, className, blink, track = '#0b1222' }) => ( +
+
+ {markers?.map(m => ( + + ))} +
+); + +/** Petits crans (niveau Tech, paliers). */ +export const Pips: React.FC<{ value: number; max: number; color: string; size?: number }> = ({ value, max, color, size = 7 }) => ( + + {Array.from({ length: max }, (_, i) => ( + + ))} + +); diff --git a/data/enemies.ts b/data/enemies.ts index f6c96386..8b5e738d 100644 --- a/data/enemies.ts +++ b/data/enemies.ts @@ -138,8 +138,12 @@ export const ENEMIES: Record = { export const BOSS_ROTATION = ['boss', 'hive', 'warden']; +/** Un boss apparaît à chaque vague multiple de cette valeur. */ +export const BOSS_WAVE_INTERVAL = 10; +export const isBossWave = (wave: number) => wave % BOSS_WAVE_INTERVAL === 0; + /** Boss de la vague (toutes les 10 vagues, en rotation). */ -export const bossForWave = (wave: number) => BOSS_ROTATION[(Math.floor(wave / 10) - 1 + BOSS_ROTATION.length) % BOSS_ROTATION.length]; +export const bossForWave = (wave: number) => BOSS_ROTATION[(Math.floor(wave / BOSS_WAVE_INTERVAL) - 1 + BOSS_ROTATION.length) % BOSS_ROTATION.length]; /** Multiplicateur de résistance des ennemis selon la vague. */ export const difficultyForWave = (wave: number) => 1 + (wave - 1) * 0.15; diff --git a/data/passives.ts b/data/passives.ts index 577025ca..9ca2a21f 100644 --- a/data/passives.ts +++ b/data/passives.ts @@ -3,7 +3,7 @@ import { Passive, Tag } from '../types'; 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: 'salvager_1', name: 'Récupérateur', description: 'Aimant à épaves. +30 portée de ramassage de l\'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: [ @@ -14,10 +14,10 @@ export const PASSIVES: Passive[] = [ ] }, // --- DÉFENSE --- - { id: 'cap_battery', name: 'Capacitor Battery', description: 'Batterie de Condensateur. +10% Refroidissement.', rarity: 'common', maxStacks: 10, tags: [Tag.ENERGY], modifiers: [ + { id: 'cap_battery', name: 'Batterie de Condensateurs', description: '+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: 'pds_module', name: 'Système de Diagnostic', description: 'Couteau suisse : +5 bouclier max, +0.5 régén. bouclier.', 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' } ] }, @@ -29,7 +29,7 @@ export const PASSIVES: Passive[] = [ ] }, // --- 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: 'co_processor', name: 'Co-Processeur', 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' } ] }, @@ -69,7 +69,7 @@ export const PASSIVES: Passive[] = [ { id: 'multi_shot', name: 'Multi-Tir', description: '+1 projectile par salve.', rarity: 'epic', maxStacks: 3, tags: [Tag.BALLISTIC], modifiers: [ { id: 'ms-1', property: 'extraProjectiles', value: 1, type: 'additive' } ] }, - { id: 'vampirism', name: 'Vampirisme', description: 'Convertit 2% des dégâts infligés en coque.', rarity: 'rare', maxStacks: 5, tags: [Tag.DOT], modifiers: [ + { id: 'vampirism', name: 'Vampirisme', description: 'Convertit 2% des dégâts infligés en coque.', rarity: 'rare', maxStacks: 5, tags: [Tag.DEFENSIVE], modifiers: [ { id: 'va-1', property: 'lifesteal', value: 0.02, type: 'additive' } ] }, { id: 'execution', name: 'Exécution', description: '+25% dégâts contre les ennemis sous 30% de coque.', rarity: 'rare', maxStacks: 3, tags: [Tag.KINETIC], modifiers: [ @@ -104,7 +104,7 @@ export const PASSIVES: Passive[] = [ { id: 'fl-1', property: 'rangeMult', value: 1.1, type: 'multiplicative' }, { id: 'fl-2', property: 'projectileSpeedMult', value: 1.1, type: 'multiplicative' } ] }, - { id: 'black_heart', name: 'Cœur Noir', description: '+30% dégâts, -20 coque max.', rarity: 'legendary', maxStacks: 2, tags: [Tag.DOT], modifiers: [ + { id: 'black_heart', name: 'Cœur Noir', description: '+30% dégâts, -20 coque max.', rarity: 'legendary', maxStacks: 2, tags: [], modifiers: [ { id: 'bh-1', property: 'damageMult', value: 1.3, type: 'multiplicative' }, { id: 'bh-2', property: 'maxHull', value: -20, type: 'additive' } ] }, diff --git a/data/ships.ts b/data/ships.ts index 4dbb6d1c..eb6c1805 100644 --- a/data/ships.ts +++ b/data/ships.ts @@ -15,7 +15,7 @@ export const SHIPS: ShipClass[] = [ { id: 'gunner', name: 'Mitrailleur', difficulty: 'moyen', color: '#facc15', description: 'Cadence de tir élevée, gestion de la surchauffe.', - stats: { fireRate: 1.15, maxShield: 40, cooling: 18 }, + stats: { fireRate: 1.25, maxShield: 40, cooling: 30 }, startingWeapon: 'auto_cannon', signatureKeystone: 'overclock_core', preferredTags: [Tag.KINETIC, Tag.BALLISTIC], }, @@ -29,7 +29,7 @@ export const SHIPS: ShipClass[] = [ { id: 'deadeye', name: 'Œil de Lynx', difficulty: 'moyen', color: '#c084fc', description: 'Précision et coups critiques à longue portée.', - stats: { critChance: 0.12, critMult: 2.3, rangeMult: 1.2, maxArmor: 70, fireRate: 0.9 }, + stats: { critChance: 0.12, critMult: 2.3, rangeMult: 1.2, maxArmor: 90, fireRate: 1.0 }, startingWeapon: 'gauss_repeater', signatureKeystone: 'dead_eye', preferredTags: [Tag.KINETIC, Tag.HOMING], }, diff --git a/data/weapons.ts b/data/weapons.ts index 693d7d96..e0ad6b2b 100644 --- a/data/weapons.ts +++ b/data/weapons.ts @@ -7,34 +7,34 @@ import { DamageType, Tag, Weapon } from '../types'; export const WEAPONS: 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, behavior: { kind: 'projectile' } }, - { 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, behavior: { kind: 'pulse', slow: 0.4, knockback: 4 } }, - { 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, behavior: { kind: 'chain', chainTargets: 3 } }, - { 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, behavior: { kind: 'beam', slow: 0.25 } }, - { id: 'em_drone_wing', name: 'Escadrille EM', type: DamageType.EM, tags: [Tag.DRONE, Tag.ENERGY], damage: 22, fireRate: 1.2, heatPerShot: 15, bulletSpeed: 12, bulletColor: '#22d3ee', range: 900, lastFired: 0, description: 'Déploie des drones de combat ioniques.', level: 1, behavior: { kind: 'drone', count: 2 } }, - { 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, behavior: { kind: 'projectile', homing: 0.08, explodeRadius: 90 } }, + { id: 'ion_blaster', name: 'Blaster à Ions', type: DamageType.EM, tags: [Tag.ENERGY, Tag.BALLISTIC], damage: 26, fireRate: 3.5, heatPerShot: 6, bulletSpeed: 18, bulletColor: '#22d3ee', range: 800, lastFired: 0, description: 'Tirs ioniques rapides. Déchire les boucliers.', level: 1, behavior: { kind: 'projectile' } , tech: { 2: { pierce: 1 }, 3: { count: 2, spread: 0.12 } }, techNotes: ['Perce 1 ennemi', 'Tir double'] }, + { 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 IEM autour du vaisseau qui ralentit les ennemis.', level: 1, behavior: { kind: 'pulse', slow: 0.4, knockback: 4 } , tech: { 3: { slow: 0.6 } }, techNotes: ['Dégâts et cadence accrus', 'Ralentissement renforcé (60%)'] }, + { id: 'arc_disruptor', name: 'Disrupteur d\'Arc', type: DamageType.EM, tags: [Tag.ENERGY, Tag.CHAIN], damage: 24, fireRate: 2.0, heatPerShot: 12, bulletSpeed: 25, bulletColor: '#7dd3fc', range: 600, lastFired: 0, description: 'Éclairs rebondissants entre les cibles.', level: 1, behavior: { kind: 'chain', chainTargets: 4 } , tech: { 3: { slow: 0.3 } }, techNotes: ['+1 rebond', '+1 rebond, ralentit les cibles'] }, + { id: 'disruptor_beam', name: 'Rayon Disrupteur', type: DamageType.EM, tags: [Tag.ENERGY, Tag.BEAM], damage: 9, fireRate: 10.0, heatPerShot: 2, bulletSpeed: 30, bulletColor: '#22d3ee', range: 700, lastFired: 0, description: 'Laser continu affaiblissant.', level: 1, behavior: { kind: 'beam', slow: 0.25 } , tech: { 3: { pierce: 2 } }, techNotes: ['Dégâts et cadence accrus', 'Le rayon traverse 2 ennemis'] }, + { id: 'em_drone_wing', name: 'Escadrille EM', type: DamageType.EM, tags: [Tag.DRONE, Tag.ENERGY], damage: 22, fireRate: 1.2, heatPerShot: 15, bulletSpeed: 12, bulletColor: '#22d3ee', range: 900, lastFired: 0, description: 'Déploie des drones de combat ioniques.', level: 1, behavior: { kind: 'drone', count: 2 } , techNotes: ['+1 drone', '+1 drone'] }, + { id: 'overload_missile', name: 'Missile Overload', type: DamageType.EM, tags: [Tag.ENERGY, Tag.HOMING], damage: 85, fireRate: 0.6, heatPerShot: 30, bulletSpeed: 10, bulletColor: '#06b6d4', range: 1200, lastFired: 0, description: 'Missile lourd à tête chercheuse EM.', level: 1, behavior: { kind: 'projectile', homing: 0.08, explodeRadius: 90 } , tech: { 3: { count: 2, spread: 0.4 } }, techNotes: ['Dégâts et cadence accrus', 'Deux missiles par salve'] }, // --- 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, behavior: { kind: 'projectile', burn: 0.6, jitter: 0.06 } }, - { 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, behavior: { kind: 'flame', burn: 0.8 } }, - { 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, behavior: { kind: 'beam', pierce: 99 } }, - { 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, behavior: { kind: 'mine', fireZone: true, explodeRadius: 110 } }, - { 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, behavior: { kind: 'projectile', explodeRadius: 120, burn: 0.3 } }, - { 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, behavior: { kind: 'strike', count: 3, explodeRadius: 90, burn: 0.3, autoTarget: true } }, + { 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, behavior: { kind: 'projectile', burn: 0.6, jitter: 0.06 } , tech: { 2: { burn: 0.9 }, 3: { count: 2, spread: 0.15 } }, techNotes: ['Brûlure renforcée', 'Tir double'] }, + { id: 'plasma_stream', name: 'Flux de Plasma', type: DamageType.THERMAL, tags: [Tag.ENERGY, Tag.BEAM], damage: 10, fireRate: 12.0, heatPerShot: 3, bulletSpeed: 22, bulletColor: '#f97316', range: 450, lastFired: 0, description: 'Lance-flammes à plasma : courte portée, brûlures.', level: 1, behavior: { kind: 'flame', burn: 0.8 } , tech: { 3: { burn: 1.2 } }, techNotes: ['Dégâts et cadence accrus', 'Brûlure renforcée'] }, + { 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, behavior: { kind: 'beam', pierce: 99 } , tech: { 3: { burn: 0.5 } }, techNotes: ['Dégâts et cadence accrus', 'Enflamme les cibles transpercées'] }, + { 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, behavior: { kind: 'mine', fireZone: true, explodeRadius: 110 } , tech: { 3: { count: 2 } }, techNotes: ['Dégâts et cadence accrus', 'Deux mines par pose'] }, + { id: 'fusion_rocket', name: 'Roquette Fusion', type: DamageType.THERMAL, tags: [Tag.EXPLOSIVE, Tag.ENERGY], damage: 80, fireRate: 0.7, heatPerShot: 22, bulletSpeed: 15, bulletColor: '#f59e0b', range: 1000, lastFired: 0, description: 'Projectile explosif à fusion thermique.', level: 1, behavior: { kind: 'projectile', explodeRadius: 100, burn: 0.3 } , tech: { 3: { explodeRadius: 140 } }, techNotes: ['Dégâts et cadence accrus', 'Explosion élargie'] }, + { id: 'starfire_array', name: 'Matrice Starfire', type: DamageType.THERMAL, tags: [Tag.AREA, Tag.ORBITAL], damage: 14, fireRate: 2.5, heatPerShot: 12, bulletSpeed: 0, bulletColor: '#fbbf24', range: 600, lastFired: 0, description: 'Bombarde automatiquement les ennemis proches.', level: 1, behavior: { kind: 'strike', count: 3, explodeRadius: 70, burn: 0.3, autoTarget: true } , techNotes: ['Dégâts et cadence accrus', '+1 frappe par salve'] }, // --- 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, behavior: { kind: 'projectile', pierce: 99, knockback: 6 } }, - { 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, behavior: { kind: 'projectile', jitter: 0.12 } }, - { 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, behavior: { kind: 'projectile', pierce: 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, behavior: { kind: 'projectile', knockback: 12, pierce: 2 } }, - { 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, behavior: { kind: 'projectile', count: 7, spread: 0.7, jitter: 0.05 } }, - { 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, behavior: { kind: 'projectile', pierce: 4, explodeRadius: 160, knockback: 18 } }, + { id: 'railgun_mk2', name: 'Railgun Mk2', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 200, fireRate: 0.35, heatPerShot: 45, bulletSpeed: 50, bulletColor: '#ffffff', range: 1500, lastFired: 0, description: 'Projectile hypersonique qui traverse tout.', level: 1, behavior: { kind: 'projectile', pierce: 99, knockback: 6 } , tech: { 2: { knockback: 10 }, 3: { count: 2, spread: 0.06 } }, techNotes: ['Recul accru', 'Double rail'] }, + { 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, behavior: { kind: 'projectile', jitter: 0.12 } , tech: { 2: { jitter: 0.08 }, 3: { pierce: 1 } }, techNotes: ['Meilleure précision', 'Perce 1 ennemi'] }, + { id: 'gauss_repeater', name: 'Répéteur Gauss', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 40, fireRate: 2.8, heatPerShot: 10, bulletSpeed: 28, bulletColor: '#94a3b8', range: 1100, lastFired: 0, description: 'Fusil d\'assaut électromagnétique équilibré.', level: 1, behavior: { kind: 'projectile', pierce: 1 } , tech: { 3: { pierce: 3 } }, techNotes: ['Dégâts et cadence accrus', 'Perce 3 ennemis'] }, + { id: 'mass_driver', name: 'Accélérateur de Masse', 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 qui transperce et repousse.', level: 1, behavior: { kind: 'projectile', knockback: 12, pierce: 2 } , tech: { 3: { explodeRadius: 50 } }, techNotes: ['Dégâts et cadence accrus', 'Explose en fin de course'] }, + { id: 'shrapnel_burst', name: 'Mitraille', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.AREA], damage: 14, fireRate: 1.2, heatPerShot: 25, bulletSpeed: 18, bulletColor: '#94a3b8', range: 600, lastFired: 0, description: 'Salve d\'éclats à courte portée.', level: 1, behavior: { kind: 'projectile', count: 7, spread: 0.45, jitter: 0.05 } , tech: { 3: { knockback: 6 } }, techNotes: ['Dégâts et cadence accrus', '+2 éclats, repousse'] }, + { id: 'siege_slug', name: 'Obusier de Siège', type: DamageType.KINETIC, tags: [Tag.KINETIC, Tag.BALLISTIC], damage: 260, fireRate: 0.25, heatPerShot: 60, bulletSpeed: 24, bulletColor: '#ffffff', range: 1800, lastFired: 0, description: 'Artillerie lourde à ultra-longue portée.', level: 1, behavior: { kind: 'projectile', pierce: 4, explodeRadius: 160, knockback: 18 } , tech: { 3: { fireZone: true } }, techNotes: ['Dégâts et cadence accrus', 'Laisse une zone de feu'] }, // --- 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, behavior: { kind: 'projectile', homing: 0.06, explodeRadius: 80, split: 5 } }, - { 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, behavior: { kind: 'projectile', gravity: true, explodeRadius: 140 } }, - { 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, behavior: { kind: 'projectile', count: 4, spread: 1.6, homing: 0.1, explodeRadius: 60 } }, - { 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, behavior: { kind: 'strike', count: 1, explodeRadius: 180, knockback: 10 } }, - { 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, behavior: { kind: 'pulse', knockback: 16 } }, - { 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, behavior: { kind: 'mine', count: 3, explodeRadius: 120 } }, + { id: 'cluster_missile', name: 'Missile Grappe', type: DamageType.EXPLOSIVE, tags: [Tag.EXPLOSIVE, Tag.HOMING, Tag.AREA], damage: 40, fireRate: 1.0, heatPerShot: 20, bulletSpeed: 14, bulletColor: '#facc15', range: 900, lastFired: 0, description: 'Missile se divisant en sous-munitions.', level: 1, behavior: { kind: 'projectile', homing: 0.06, explodeRadius: 80, split: 5 } , tech: { 3: { split: 8 } }, techNotes: ['Dégâts et cadence accrus', '8 sous-munitions'] }, + { 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, behavior: { kind: 'projectile', gravity: true, explodeRadius: 120 } , tech: { 3: { explodeRadius: 170 } }, techNotes: ['Dégâts et cadence accrus', 'Explosion élargie'] }, + { 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, behavior: { kind: 'projectile', count: 4, spread: 1.6, homing: 0.1, explodeRadius: 60 } , tech: { 3: { burn: 0.3 } }, techNotes: ['Dégâts et cadence accrus', '+1 drone, enflamme'] }, + { id: 'orbital_strike', name: 'Frappe Orbitale', type: DamageType.EXPLOSIVE, tags: [Tag.AREA, Tag.ORBITAL], damage: 100, fireRate: 0.3, heatPerShot: 50, bulletSpeed: 30, bulletColor: '#facc15', range: 2000, lastFired: 0, description: 'Frappe chirurgicale à haute puissance.', level: 1, behavior: { kind: 'strike', count: 1, explodeRadius: 180, knockback: 10 } , tech: { 3: { fireZone: true } }, techNotes: ['Dégâts et cadence accrus', 'Laisse une zone de feu'] }, + { 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, behavior: { kind: 'pulse', knockback: 16 } , tech: { 3: { slow: 0.4 } }, techNotes: ['Dégâts et cadence accrus', 'Ralentit (40%)'] }, + { id: 'minefield_layer', name: 'Poseur de Mines', type: DamageType.EXPLOSIVE, tags: [Tag.AREA, Tag.BALLISTIC], damage: 55, fireRate: 0.35, heatPerShot: 25, bulletSpeed: 4, bulletColor: '#fde047', range: 500, lastFired: 0, description: 'Déploie un champ de mines tactique.', level: 1, behavior: { kind: 'mine', count: 3, explodeRadius: 120 } , tech: { 3: { knockback: 10 } }, techNotes: ['Dégâts et cadence accrus', '+1 mine, repousse'] }, ]; diff --git a/engine/CoreEngine.ts b/engine/CoreEngine.ts index 6de58001..dd0eb12e 100644 --- a/engine/CoreEngine.ts +++ b/engine/CoreEngine.ts @@ -3,7 +3,7 @@ import { createEffect, updateStatusEffects } from './Combat'; import { updateZones } from './WeaponSystem'; import { spawnEnemy } from './EnemyFactory'; import { updateEnemyAttacks } from './EnemyAttacks'; -import { bossForWave } from '../data/enemies'; +import { bossForWave, isBossWave } from '../data/enemies'; import { refreshPlayerStats } from './StatsCalculator'; import { activeMechanics } from './Synergies'; import { updateParticles, emitParticles } from '../render/ParticleSystem'; @@ -51,7 +51,7 @@ export const updateGameState = ( state.waveKills = 0; state.waveQuota = Math.floor(10 + (state.wave * 6)); - if (state.wave % 10 === 0) { + if (isBossWave(state.wave)) { state.enemies.push(spawnEnemy(state.wave, player, bossForWave(state.wave))); state.bossSpawned = true; createEffect(state, player.x, player.y - 160, 'ALERTE : BOSS', '#facc15'); diff --git a/engine/WeaponSystem.ts b/engine/WeaponSystem.ts index aa584f1c..1e9795cf 100644 --- a/engine/WeaponSystem.ts +++ b/engine/WeaponSystem.ts @@ -1,4 +1,4 @@ -import { GameState, Weapon, WeaponKind, Projectile, Entity, DamageType } from '../types'; +import { GameState, Weapon, WeaponKind, WeaponBehavior, Projectile, Entity, DamageType } from '../types'; import { TECH_MULTIPLIERS } from '../constants'; import { playShotSound } from './SoundEngine'; import { damageEnemy, explode, nearestEnemy, rollPacket } from './Combat'; @@ -19,11 +19,19 @@ export const weaponCooldown = (state: GameState, w: Weapon) => { const weaponDamage = (w: Weapon) => w.damage * (TECH_MULTIPLIERS[w.level] || 1.0); /** Bonus de quantité au Tech III pour les armes multi-projectiles. */ -const levelCount = (w: Weapon, base: number) => base + (w.level >= 3 ? Math.max(1, Math.floor(base / 3)) : 0); +/** Bonus de quantité au Tech III pour les armes multi-projectiles (≥3), ex. 3 → 4, 7 → 9. */ +const levelCount = (w: Weapon, base: number) => base + (w.level >= 3 ? Math.floor(base / 3) : 0); + +/** Comportement effectif d'une arme : base + bonus de Tech II / III. */ +export const weaponBehavior = (w: Weapon): WeaponBehavior => ({ + ...w.behavior, + ...(w.level >= 2 ? w.tech?.[2] : undefined), + ...(w.level >= 3 ? w.tech?.[3] : undefined), +}); const makeProjectile = (state: GameState, w: Weapon, x: number, y: number, angle: number, speedMult = 1): Projectile => { const stats = state.player.runtimeStats; - const b = w.behavior; + const b = weaponBehavior(w); const speed = Math.max(0, w.bulletSpeed * stats.projectileSpeedMult * speedMult); const packet = rollPacket(state, weaponDamage(w), w.type); let kind: Projectile['kind'] = 'bullet'; @@ -56,7 +64,7 @@ const makeProjectile = (state: GameState, w: Weapon, x: number, y: number, angle const fireProjectiles = (state: GameState, w: Weapon, aim: number) => { const { player } = state; - const b = w.behavior; + const b = weaponBehavior(w); const extra = state.player.runtimeStats.extraProjectiles; const count = (b.count ? levelCount(w, b.count) : 1) + extra; const spread = b.spread ?? (count > 1 ? 0.12 * (count - 1) : 0); @@ -72,7 +80,7 @@ const fireProjectiles = (state: GameState, w: Weapon, aim: number) => { /** Rayon instantané : touche tout ce qui est sur la ligne (ou le premier si pas de perforation). */ const fireBeam = (state: GameState, w: Weapon, aim: number) => { const { player } = state; - const b = w.behavior; + const b = weaponBehavior(w); const range = w.range * player.runtimeStats.rangeMult; const sx = player.x + Math.cos(aim) * player.radius; const sy = player.y + Math.sin(aim) * player.radius; @@ -110,7 +118,7 @@ const fireBeam = (state: GameState, w: Weapon, aim: number) => { /** Arc électrique : frappe la cible la plus proche du curseur puis rebondit. */ const fireChain = (state: GameState, w: Weapon, aimX: number, aimY: number) => { const { player } = state; - const b = w.behavior; + const b = weaponBehavior(w); const range = w.range * player.runtimeStats.rangeMult; const first = nearestEnemy(state, aimX, aimY, 300) || nearestEnemy(state, player.x, player.y, range); if (!first) return false; @@ -135,14 +143,14 @@ const fireChain = (state: GameState, w: Weapon, aimX: number, aimY: number) => { const firePulse = (state: GameState, w: Weapon) => { const { player } = state; - const b = w.behavior; + const b = weaponBehavior(w); const radius = w.range * player.runtimeStats.rangeMult * (1 + (w.level - 1) * 0.15); state.zones.push({ id: uid('z'), kind: 'pulse', x: player.x, y: player.y, radius, life: 0.4, maxLife: 0.4, color: w.bulletColor }); - const r2 = radius * radius; for (const e of state.enemies) { if (e.dead) continue; const dx = e.x - player.x, dy = e.y - player.y; - if (dx * dx + dy * dy < r2) { + const reach = radius + e.radius; + if (dx * dx + dy * dy < reach * reach) { damageEnemy(state, e, rollPacket(state, weaponDamage(w), w.type), { direct: true, knockback: b.knockback, slow: b.slow }); } } @@ -152,7 +160,7 @@ const firePulse = (state: GameState, w: Weapon) => { /** Frappe orbitale : marque une zone, l'explosion arrive après un délai. */ const fireStrike = (state: GameState, w: Weapon, aimX: number, aimY: number) => { const { player } = state; - const b = w.behavior; + const b = weaponBehavior(w); const count = levelCount(w, b.count ?? 1); const range = w.range * player.runtimeStats.rangeMult; const used = new Set(); @@ -196,16 +204,20 @@ const fireFlame = (state: GameState, w: Weapon, aim: number) => { } }; +/** Durée de vie d'une mine posée (secondes). */ +export const MINE_LIFETIME = 8; + const fireMine = (state: GameState, w: Weapon) => { const { player } = state; - const count = w.behavior.count ? levelCount(w, w.behavior.count) : 1; + const mb = weaponBehavior(w); + const count = mb.count ? levelCount(w, mb.count) : 1; for (let i = 0; i < count; i++) { const a = Math.random() * Math.PI * 2; const d = count > 1 ? 30 + Math.random() * 80 : 0; const p = makeProjectile(state, w, player.x - Math.cos(player.rotation) * player.radius + Math.cos(a) * d, player.y - Math.sin(player.rotation) * player.radius + Math.sin(a) * d, 0, 0); p.kind = 'mine'; p.radius = 12; - p.life = 12; + p.life = MINE_LIFETIME; p.armTime = 0.5; p.maxRange = Infinity; state.projectiles.push(p); @@ -216,7 +228,7 @@ const fireMine = (state: GameState, w: Weapon) => { const syncDrones = (state: GameState) => { const wanted = new Map(); state.activeWeapons.forEach(w => { - if (w.behavior.kind === 'drone') wanted.set(w.id, (w.behavior.count ?? 1) + (w.level - 1) + state.player.runtimeStats.extraDrones); + if (w.behavior.kind === 'drone') wanted.set(w.id, (weaponBehavior(w).count ?? 1) + (w.level - 1) + state.player.runtimeStats.extraDrones); }); state.drones = state.drones.filter(d => (wanted.get(d.weaponId) ?? 0) > 0); wanted.forEach((n, weaponId) => { @@ -298,7 +310,7 @@ export const updateWeapons = ( if (time - w.lastFired < weaponCooldown(state, w)) continue; let aimAngle = aim; - if (w.behavior.autoTarget) { + if (weaponBehavior(w).autoTarget) { const t = nearestEnemy(state, player.x, player.y, w.range * player.runtimeStats.rangeMult); if (!t) continue; aimAngle = Math.atan2(t.y - player.y, t.x - player.x); @@ -343,8 +355,8 @@ export const updatePlayerProjectile = (state: GameState, p: Projectile, deltaTim } } if (p.kind === 'flame') { - p.vx *= 0.94; - p.vy *= 0.94; + p.vx *= 0.95; + p.vy *= 0.95; p.radius += 0.6; } }; @@ -358,7 +370,7 @@ export const detonate = (state: GameState, p: Projectile) => { state.zones.push({ id: uid('z'), kind: 'gravity', x: p.x, y: p.y, radius: 260, life: 2.5, maxLife: 2.5, color: '#a855f7', - packet: { ...p.packet, amount: p.packet.amount * 0.4, type: DamageType.EXPLOSIVE }, + packet: { ...p.packet, amount: p.packet.amount * 0.25, type: DamageType.EXPLOSIVE }, }); } if (p.fireZone) { diff --git a/index.css b/index.css index b5c61c95..03e9f56c 100644 --- a/index.css +++ b/index.css @@ -1,3 +1,11 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Menus (hors HUD) : agrandis par paliers sur grands écrans. Le HUD a sa propre échelle (components/hud/model.ts). */ +@media (min-width: 1700px) and (min-height: 940px) { + .ui-zoom { zoom: 1.25; } +} +@media (min-width: 2300px) and (min-height: 1300px) { + .ui-zoom { zoom: 1.6; } +} diff --git a/index.html b/index.html index 3a7d2b54..5e2317dc 100644 --- a/index.html +++ b/index.html @@ -1,11 +1,11 @@ - + Space InZader - +