// renderEnemies.js : rendu des ennemis (sprites + barre de vie + ombre) // pool de sprites reutilises (pas de recreate par frame) import { Container, Graphics, Assets, Sprite } from 'pixi.js'; import { iso, getTh, getTw } from './renderer.js'; import { ENEMY_HITBOX_RADIUS } from './constants.js'; let _debugHitboxes = false; export function setDebugHitboxes(v) { _debugHitboxes = v; } // Assets Fracture const _ft = {}; let _fractureReady = false; export async function loadFractureAssets() { if (_fractureReady) return; const b = '../assets/sprites/fracture/'; function anim(prefix, dirs, n, folder) { const out = []; for (const d of dirs) { for (let i = 0; i < n; i++) { const pad = String(i).padStart(3, '0'); out.push([`${prefix}_${d}_${i}`, `${b}animations/${folder}/${d}/frame_${pad}.png`]); } } return out; } const D4 = ['south', 'north', 'east', 'west']; const entries = [ ['f_south', b + 'rotations/south.png'], ['f_north', b + 'rotations/north.png'], ['f_east', b + 'rotations/east.png'], ['f_west', b + 'rotations/west.png'], ...anim('frun', D4, 4, 'running'), ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _ft[key] = tex; } _fractureReady = true; } // Assets Rampant const _rt = {}; let _rampantReady = false; export async function loadRampantAssets() { if (_rampantReady) return; const b = '../assets/sprites/rampant/'; function anim(prefix, dirs, n, folder) { const out = []; for (const d of dirs) { for (let i = 0; i < n; i++) { const pad = String(i).padStart(3, '0'); out.push([`${prefix}_${d}_${i}`, `${b}animations/${folder}/${d}/frame_${pad}.png`]); } } return out; } const D4 = ['south', 'north', 'east', 'west']; const entries = [ ['r_south', b + 'rotations/south.png'], ['r_north', b + 'rotations/north.png'], ['r_east', b + 'rotations/east.png'], ['r_west', b + 'rotations/west.png'], ...anim('rrun', D4, 6, 'running'), ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _rt[key] = tex; } _rampantReady = true; } // Assets Colosse const _ct = {}; let _colosseReady = false; export async function loadColosseAssets() { if (_colosseReady) return; const b = '../assets/sprites/colosse/'; function anim(prefix, dirs, n, folder) { const out = []; for (const d of dirs) { for (let i = 0; i < n; i++) { const pad = String(i).padStart(3, '0'); out.push([`${prefix}_${d}_${i}`, `${b}animations/${folder}/${d}/frame_${pad}.png`]); } } return out; } const D4 = ['south', 'north', 'east', 'west']; const entries = [ ['c_south', b + 'rotations/south.png'], ['c_north', b + 'rotations/north.png'], ['c_east', b + 'rotations/east.png'], ['c_west', b + 'rotations/west.png'], ...anim('crun', D4, 6, 'running'), ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _ct[key] = tex; } _colosseReady = true; } // Assets Éclat const _et = {}; let _eclatReady = false; export async function loadEclatAssets() { if (_eclatReady) return; const b = '../assets/sprites/eclat/'; function anim(prefix, dirs, n, folder) { const out = []; for (const d of dirs) { for (let i = 0; i < n; i++) { const pad = String(i).padStart(3, '0'); out.push([`${prefix}_${d}_${i}`, `${b}animations/${folder}/${d}/frame_${pad}.png`]); } } return out; } const D4 = ['south', 'north', 'east', 'west']; const entries = [ ['e_south', b + 'rotations/south.png'], ['e_north', b + 'rotations/north.png'], ['e_east', b + 'rotations/east.png'], ['e_west', b + 'rotations/west.png'], ...anim('erun', D4, 8, 'running'), ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _et[key] = tex; } _eclatReady = true; } // Assets Vexaris const _vt = {}; let _vexarisReady = false; export async function loadVexarisAssets() { if (_vexarisReady) return; const b = '../assets/sprites/vexaris/'; function anim(prefix, dirs, n, folder) { const out = []; for (const d of dirs) { for (let i = 0; i < n; i++) { const pad = String(i).padStart(3, '0'); out.push([`${prefix}_${d}_${i}`, `${b}animations/${folder}/${d}/frame_${pad}.png`]); } } return out; } const D4 = ['south', 'north', 'east', 'west']; const entries = [ ['v_south', b + 'rotations/south.png'], ['v_north', b + 'rotations/north.png'], ['v_east', b + 'rotations/east.png'], ['v_west', b + 'rotations/west.png'], ...anim('vrun', D4, 4, 'running'), ...anim('vatk', D4, 7, 'attack'), ...anim('vchg', D4, 7, 'charge'), ...anim('vbst', ['south'], 9, 'burst'), ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _vt[key] = tex; } _vexarisReady = true; } // Animation state par ennemi (position précédente + état animation) const _ePrevPos = {}; // id → { x, y } const _eAnimState = {}; // id → { facing, frame, timer, action } const F_RUN_FPS = 8; const F_RUN_TICK = 1 / F_RUN_FPS; const R_RUN_FPS = 12; const R_RUN_TICK = 1 / R_RUN_FPS; const C_RUN_FPS = 5; const C_RUN_TICK = 1 / C_RUN_FPS; // colosse très lent const E_RUN_FPS = 16; const E_RUN_TICK = 1 / E_RUN_FPS; // éclat ultrarapide const V_RUN_FPS = 8; const V_RUN_TICK = 1 / V_RUN_FPS; // running 4 frames const V_ATK_FPS = 10; const V_ATK_TICK = 1 / V_ATK_FPS; // attack 7 frames const V_CHG_FPS = 14; const V_CHG_TICK = 1 / V_CHG_FPS; // charge 7 frames const V_BST_FPS = 8; const V_BST_TICK = 1 / V_BST_FPS; // burst 9 frames south const TICK_DT = 0.05; // iso() = projection monde → pixels isométriques // getTh() = pixels par unité monde (pour les tailles proportionnelles) // Couleur de chaque type d'ennemi (valeur hexadécimale RGB) const ENEMY_COLORS = { fracture: 0xcc3333, // rouge foncé — ennemi de base qui fonce sur le Soulgate rampant: 0xff6600, // orange — attaque les joueurs colosse: 0x882200, // brun-rouge — lent et résistant eclat: 0xffcc00, // jaune — petit et rapide vexaris: 0xaa22ff, // violet — boss vague 2 general: 0xcc6600, // orange foncé — sous-boss invoqué par Morveth morveth: 0x550088, // violet très foncé — boss final vague 3 }; // Tailles des ennemis selon le type // min/max = limites en pixels (pour les petits et grands écrans) // scale = facteur multiplicateur de th (pixels par unité monde) const ENEMY_SIZES = { morveth: { min: 14, max: 55, scale: 0.65 }, // boss final = très grand vexaris: { min: 12, max: 45, scale: 0.50 }, // boss vague 2 = grand general: { min: 8, max: 30, scale: 0.32 }, // sous-boss = moyen }; const DEFAULT_SIZE = { min: 6, max: 25, scale: 0.22 }; // Taille par défaut pour les ennemis normaux (fracture, rampant, colosse, eclat) export function updateEnemies(layer, pool, enemies) { // Met à jour les sprites de tous les ennemis depuis les données serveur // layer = Container PixiJS du layer "ennemis" // pool = { id → Container } — sprites existants // enemies = tableau d'EnemyState reçus dans msg.enemies // 1. Supprimer les sprites des ennemis morts (plus dans la liste serveur) const ids = new Set(enemies.map(e => e.id)); // ids = Set des IDs encore vivants ce tick for (const [id, container] of Object.entries(pool)) { if (!ids.has(id)) { layer.removeChild(container); delete pool[id]; delete _ePrevPos[id]; delete _eAnimState[id]; } } // 2. Créer ou mettre à jour chaque ennemi for (const e of enemies) { // e = objet ennemi : { id, type, x, y, hp, max_hp, is_boss, frozen } if (!pool[e.id]) { pool[e.id] = _createEnemyGraphic(e.type); layer.addChild(pool[e.id]); _ePrevPos[e.id] = { x: e.x, y: e.y }; _eAnimState[e.id] = { facing: 'south', frame: 0, timer: 0 }; } const ctr = pool[e.id]; const pos = iso(e.x, e.y); const prev = _ePrevPos[e.id]; const anim = _eAnimState[e.id]; // Détecter direction depuis le déplacement const ddx = e.x - prev.x; const ddy = e.y - prev.y; const moving = Math.abs(ddx) > 0.01 || Math.abs(ddy) > 0.01; if (moving) anim.facing = _enemyDir(ddx, ddy) ?? anim.facing; _ePrevPos[e.id] = { x: e.x, y: e.y }; ctr.x = pos.x; ctr.y = pos.y; // Sprite Fracture if (e.type === 'fracture' && _fractureReady && ctr._sprite) { anim.timer += TICK_DT; if (anim.timer >= F_RUN_TICK) { anim.timer -= F_RUN_TICK; anim.frame = (anim.frame + 1) % 4; } ctr._sprite.texture = moving ? _ft[`frun_${anim.facing}_${anim.frame}`] : _ft[`f_${anim.facing}`]; } // Sprite Rampant if (e.type === 'rampant' && _rampantReady && ctr._sprite) { anim.timer += TICK_DT; if (anim.timer >= R_RUN_TICK) { anim.timer -= R_RUN_TICK; anim.frame = (anim.frame + 1) % 6; } ctr._sprite.texture = moving ? _rt[`rrun_${anim.facing}_${anim.frame}`] : _rt[`r_${anim.facing}`]; } // Sprite Colosse if (e.type === 'colosse' && _colosseReady && ctr._sprite) { anim.timer += TICK_DT; if (anim.timer >= C_RUN_TICK) { anim.timer -= C_RUN_TICK; anim.frame = (anim.frame + 1) % 6; } ctr._sprite.texture = moving ? _ct[`crun_${anim.facing}_${anim.frame}`] : _ct[`c_${anim.facing}`]; } // Sprite Éclat if (e.type === 'eclat' && _eclatReady && ctr._sprite) { anim.timer += TICK_DT; if (anim.timer >= E_RUN_TICK) { anim.timer -= E_RUN_TICK; anim.frame = (anim.frame + 1) % 8; } ctr._sprite.texture = moving ? _et[`erun_${anim.facing}_${anim.frame}`] : _et[`e_${anim.facing}`]; } // Sprite Vexaris if (e.type === 'vexaris' && _vexarisReady && ctr._sprite) { const prevAction = anim.action ?? 'idle'; let action; if (e.is_charging) { action = 'charge'; } else if (moving) { action = 'run'; } else if (anim.action === 'burst' && anim.frame < 8) { action = 'burst'; // laisser le burst se terminer } else { action = 'attack'; } if (action !== prevAction) { anim.frame = 0; anim.timer = 0; } anim.action = action; anim.timer += TICK_DT; if (action === 'charge') { if (anim.timer >= V_CHG_TICK) { anim.timer -= V_CHG_TICK; anim.frame = (anim.frame + 1) % 7; } ctr._sprite.texture = _vt[`vchg_${anim.facing}_${anim.frame}`]; } else if (action === 'run') { if (anim.timer >= V_RUN_TICK) { anim.timer -= V_RUN_TICK; anim.frame = (anim.frame + 1) % 4; } ctr._sprite.texture = _vt[`vrun_${anim.facing}_${anim.frame}`]; } else if (action === 'burst') { if (anim.timer >= V_BST_TICK) { anim.timer -= V_BST_TICK; anim.frame = Math.min(8, anim.frame + 1); } ctr._sprite.texture = _vt[`vbst_south_${anim.frame}`]; } else { if (anim.timer >= V_ATK_TICK) { anim.timer -= V_ATK_TICK; anim.frame = (anim.frame + 1) % 7; } ctr._sprite.texture = _vt[`vatk_${anim.facing}_${anim.frame}`]; } } ctr.tint = e.frozen ? 0x88bbff : 0xffffff; // Si gelé (sort divin Seris) → teinte bleue // Sinon → blanc = pas de teinte (couleur de base) // e.frozen = bool envoyé par le serveur (frozen_timer > 0) _updateHealthBar(ctr, e.hp, e.max_hp); // Debug : hitbox de collision if (ctr._hboxG) { const hg = ctr._hboxG; hg.clear(); if (_debugHitboxes) { const rx = ENEMY_HITBOX_RADIUS * getTw() / 2; const ry = ENEMY_HITBOX_RADIUS * getTh() / 2; hg.ellipse(0, 0, rx, ry).stroke({ color: 0xff4400, width: 1.5, alpha: 0.9 }); } } } } function _updateHealthBar(ctr, hp, maxHp) { // Met à jour la barre de vie d'un ennemi // ctr = le Container de l'ennemi (contient barFg en tant que propriété custom) // hp = points de vie actuels // maxHp = points de vie max const ratio = maxHp > 0 ? Math.max(0, hp / maxHp) : 0; // ratio = pourcentage de vie restante (0.0 à 1.0) // Math.max(0, ...) = éviter les valeurs négatives si hp < 0 (sécurité) // maxHp > 0 ? ... : 0 = éviter la division par zéro const barFg = ctr._barFg; // _barFg = la Graphics de la barre de vie (stockée comme propriété custom sur le Container) // Le _ devant = convention "propriété interne" if (!barFg) return; // sécurité si le Container n'a pas été initialisé correctement barFg.clear(); // effacer le dessin précédent de la barre if (ratio > 0) { // Redessiner la barre avec la nouvelle largeur proportionnelle au ratio barFg.rect(ctr._barX, ctr._barY, ctr._barW * ratio, ctr._barH) .fill({ color: 0xff3333 }); // ctr._barW * ratio = largeur totale × ratio HP → barre qui rétrécit // 0xff3333 = rouge } // Si ratio = 0 → on ne dessine rien (ennemi presque mort) } function _enemyDir(dx, dy) { if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return null; if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? 'east' : 'west'; return dy > 0 ? 'south' : 'north'; } function _createEnemyGraphic(type) { // Crée le Container PixiJS pour un ennemi (appelé une seule fois par ennemi) // type = string : "fracture", "rampant", "colosse", "eclat", "vexaris", "morveth", "general" const th = getTh(); // pixels par unité monde (pour les tailles proportionnelles) const container = new Container(); // Calculer le rayon selon le type const sz = ENEMY_SIZES[type] ?? DEFAULT_SIZE; // ENEMY_SIZES[type] = taille spécifique si boss/général, sinon DEFAULT_SIZE // ?? = operateur nullish coalescing : si null ou undefined → utiliser DEFAULT_SIZE const r = Math.max(sz.min, Math.min(sz.max, th * sz.scale)); // th * sz.scale = taille proportionnelle au zoom // Math.max(sz.min, Math.min(sz.max, ...)) = clamp entre min et max const color = ENEMY_COLORS[type] ?? 0xff0000; // Sprite Rampant // Canvas 48×48, char bbox y=[8..41], anchor y=0.75 → anchor canvas=36 // Char top au-dessus de l'anchor = 36-8 = 28 canvas pixels // Scale = (th*1.4)/48 → char top à screen y = -28*(th*1.4/48) = -th*0.817 if (type === 'rampant' && _rampantReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.4, r * 0.5, r * 0.12).fill({ color: 0x000000, alpha: 0.3 }); const scale = (th * 1.4) / 48; const sprite = new Sprite(_rt.r_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); const barW = th * 0.7; const barH = Math.max(2, th * 0.035); const barY = -(th * 0.82 + barH + 8); // 8px marge au-dessus de la tête const barBg = new Graphics(); barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220000 }); const barFg = new Graphics(); barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xff3333 }); const hboxG = new Graphics(); container.addChild(shadow, sprite, barBg, barFg, hboxG); container._sprite = sprite; container._barFg = barFg; container._barX = -barW / 2; container._barY = barY; container._barW = barW; container._barH = barH; container._hboxG = hboxG; return container; } // Sprite Fracture // Canvas 68×68, char bbox y=[10..58], anchor y=0.75 → anchor canvas=51 // Char top au-dessus de l'anchor = 51-10 = 41 canvas pixels // Scale = (th*1.2)/48 → char top à screen y = -41*(th*1.2/48) = -th*1.025 if (type === 'fracture' && _fractureReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.5, r * 0.6, r * 0.15).fill({ color: 0x000000, alpha: 0.3 }); const scale = (th * 1.2) / 48; const sprite = new Sprite(_ft.f_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); const barW = th * 0.8; const barH = Math.max(2, th * 0.04); const barY = -(th * 1.025 + barH + 10); // 10px marge au-dessus de la tête const barBg = new Graphics(); barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220000 }); const barFg = new Graphics(); barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xff3333 }); const hboxG = new Graphics(); container.addChild(shadow, sprite, barBg, barFg, hboxG); container._sprite = sprite; container._barFg = barFg; container._barX = -barW / 2; container._barY = barY; container._barW = barW; container._barH = barH; container._hboxG = hboxG; return container; } // Sprite Colosse // Canvas 92×92, char top à y=13, anchor y=0.75 → anchor=69, dist=56px // Scale = (th*2.0)/92 → char_top = -56*(th*2.0/92) = -th*1.217 if (type === 'colosse' && _colosseReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.6, r * 0.9, r * 0.22).fill({ color: 0x000000, alpha: 0.35 }); const scale = (th * 2.0) / 92; const sprite = new Sprite(_ct.c_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); const barW = th * 1.2; const barH = Math.max(2, th * 0.05); const barY = -(th * 1.22 + barH + 10); const barBg = new Graphics(); barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220000 }); const barFg = new Graphics(); barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xff3333 }); const hboxG = new Graphics(); container.addChild(shadow, sprite, barBg, barFg, hboxG); container._sprite = sprite; container._barFg = barFg; container._barX = -barW / 2; container._barY = barY; container._barW = barW; container._barH = barH; container._hboxG = hboxG; return container; } // Sprite Vexaris // Canvas 92×92, char top à y≈12, anchor y=0.75 → anchor=69, dist=57px // Scale = (th*2.2)/92 → char_top = -57*(th*2.2/92) = -th*1.363 if (type === 'vexaris' && _vexarisReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.7, r * 1.1, r * 0.28).fill({ color: 0x330033, alpha: 0.45 }); const scale = (th * 2.2) / 92; const sprite = new Sprite(_vt.v_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); const barW = th * 1.6; const barH = Math.max(3, th * 0.06); const barY = -(th * 1.36 + barH + 12); const barBg = new Graphics(); barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220022 }); const barFg = new Graphics(); barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xaa22ff }); const hboxG = new Graphics(); container.addChild(shadow, sprite, barBg, barFg, hboxG); container._sprite = sprite; container._barFg = barFg; container._barX = -barW / 2; container._barY = barY; container._barW = barW; container._barH = barH; container._hboxG = hboxG; return container; } // Sprite Éclat // Canvas 36×36, char top à y=4, anchor y=0.75 → anchor=27, dist=23px // Scale = (th*1.0)/36 → char_top = -23*(th/36) = -th*0.639 if (type === 'eclat' && _eclatReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.3, r * 0.4, r * 0.10).fill({ color: 0x000000, alpha: 0.25 }); const scale = (th * 1.0) / 36; const sprite = new Sprite(_et.e_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); const barW = th * 0.5; const barH = Math.max(2, th * 0.03); const barY = -(th * 0.64 + barH + 6); const barBg = new Graphics(); barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220000 }); const barFg = new Graphics(); barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xff3333 }); const hboxG = new Graphics(); container.addChild(shadow, sprite, barBg, barFg, hboxG); container._sprite = sprite; container._barFg = barFg; container._barX = -barW / 2; container._barY = barY; container._barW = barW; container._barH = barH; container._hboxG = hboxG; return container; } const body = new Graphics(); // Ombre au sol body.ellipse(0, r * 0.5, r * 0.7, r * 0.18).fill({ color: 0x000000, alpha: 0.25 }); // Ellipse aplatie légèrement en dessous du centre (r*0.5) → simuler une ombre // Cercle principal de l'ennemi body.circle(0, 0, r).fill({ color }); // Contour blanc subtil body.circle(0, 0, r).stroke({ color: 0xffffff, width: 1, alpha: 0.2 }); // Barre de vie const barW = r * 2.4; // largeur totale de la barre = légèrement plus large que le cercle const barH = Math.max(2, th * 0.04); // hauteur de la barre (au moins 2px) const barY = -(r * 1.1 + barH + 3); // position Y = au-dessus du cercle (négatif = vers le haut) const barBg = new Graphics(); // Fond de la barre (rouge très foncé = barre vide) barBg.rect(-barW / 2, barY, barW, barH).fill({ color: 0x220000 }); // rect(x, y, largeur, hauteur) — centré horizontalement (-barW/2) const barFg = new Graphics(); // Remplissage de la barre (rouge vif = vie restante) // Dessiné à 100% au départ, puis mis à jour par _updateHealthBar() barFg.rect(-barW / 2, barY, barW, barH).fill({ color: 0xff3333 }); const hboxG = new Graphics(); container.addChild(body, barBg, barFg, hboxG); // Ordre d'ajout = ordre d'affichage (barFg par-dessus barBg par-dessus body) // Stocker les infos de la barre sur le Container pour les réutiliser dans _updateHealthBar // On les préfixe avec _ pour indiquer que c'est de l'état interne container._barFg = barFg; // référence au Graphics à redessiner container._barX = -barW / 2; // X de départ de la barre container._barY = barY; // Y de la barre container._barW = barW; // largeur TOTALE (× ratio HP = largeur réelle) container._barH = barH; // hauteur de la barre container._hboxG = hboxG; // Graphics de la hitbox debug return container; }