+
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
setSelected(s.id)}
- className={`p-3 border-2 transition-all text-left ${active ? 'bg-white/10' : 'bg-slate-900/60 hover:bg-white/5'} ${ok ? '' : 'opacity-40'}`}
+ className={`p-3 border-2 transition-all text-left ${active ? 'bg-white/10' : 'bg-slate-900/60 hover:bg-white/5'} ${ok ? '' : 'opacity-50'}`}
style={{ borderColor: active ? s.color : 'rgba(255,255,255,0.08)' }}
>
- {ok ? s.name : '???'}
- {ok ? s.difficulty : 'verrouillé'}
+ {ok ? s.name : '???'}
+ {ok ? s.difficulty : '🔒 verrouillé'}
);
})}
@@ -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
onStart(ship.id)}
- className="px-8 py-6 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-800 disabled:text-slate-600 text-white font-bold text-2xl border-b-8 border-cyan-800 disabled:border-slate-900 transition-all uppercase active:translate-y-1"
+ className="px-8 py-5 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-orbitron font-black text-2xl border-b-8 border-cyan-800 disabled:border-slate-900 transition-all uppercase active:translate-y-1"
>
Démarrer mission
- DATABASE_DEV
- ENGINEERING_LAB
+ Base de données
+ Labo d'essais
-
- 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
+
+
+
+
+ Reprendre (P / Échap)
+
+
+ Abandonner
+
+
+
+
+
+
+ {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 (
+
+
+ {index + 1}
+
+
+ {upgradeKind(state, opt)}
+
+
+ {opt.type === 'keystone' && opt.item.icon ? `${opt.item.icon} ` : ''}{opt.item.name}
+
+ {opt.item.description}
+
+ {opt.type === 'weapon' && owned && techNote(opt.item, owned.level + 1) && (
+
+
Bonus Tech {owned.level + 1}
+
{techNote(opt.item, owned.level + 1)}
+
+ )}
+ {opt.type === 'weapon' && !owned && opt.item.techNotes && (
+
+ {Array.from({ length: MAX_WEAPON_LEVEL - 1 }, (_, i) => i + 2).map(l => techNote(opt.item, l) && (
+
T{l} {techNote(opt.item, l)}
+ ))}
+
+ )}
+
+ {opt.type === 'weapon' && stats && (
+
+ {DAMAGE_LABELS[opt.item.type]}} />
+
+
+
+
+ )}
+
+ {'tags' in opt.item && opt.item.tags.length > 0 && (
+
+ {opt.item.tags.map(t => (
+ {TAG_LABELS[t]}
+ ))}
+
+ )}
+
+ {gains.length > 0 && (
+
+
Synergies
+ {gains.map(g => (
+
+ {g.name} {' '}
+ {g.from}{g.to !== g.from ? ` → ${g.to}` : ''}
+ {g.unlocks
+ ? ✓ Débloque : {g.unlocks}
+ : g.next && Prochain palier à {g.next.count} : {g.next.description} }
+
+ ))}
+
+ )}
+
+ );
};
-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 (
-
onSelect(opt)}
- className={`group relative flex flex-col p-5 lg:p-10 border-2 transition-all hover:scale-105 text-left shadow-xl ${RARITY_BGS[rarity as keyof typeof RARITY_BGS]} ${RARITY_COLORS[rarity as keyof typeof RARITY_COLORS]}`}
- >
-
- // {getTypeName(opt.type, opt.item)}
-
-
- {opt.item.name} {existingWeapon ? `(T-${existingWeapon.level + 1})` : ''}
-
-
- "{opt.item.description}"
-
- {'tags' in opt.item && (
-
- {opt.item.tags.map(t => (
- {t}
- ))}
-
- )}
-
-
-
- {existingWeapon ? 'Fusionner les Matériaux' : 'Cliquer pour Intégrer'}
-
-
-
-
- );
- })}
+
+ {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 }) => (
{ e.preventDefault(); onTap(); }}
- className={`w-16 h-16 rounded-full border-2 border-cyan-400/60 bg-black/50 text-2xl text-white active:bg-cyan-400/30 select-none ${className}`}
+ className={`relative rounded-full border-2 border-cyan-400/70 bg-slate-950/70 text-white active:bg-cyan-400/30 select-none flex items-center justify-center overflow-hidden ${className}`}
+ style={style}
>
{label}
);
+ /** 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
-
+