// renderPlayers.js — Rendu des joueurs (sprites Kael ou cercles colorés) import { Container, Graphics, Text, Assets, Sprite } from 'pixi.js'; import { CLASS_COLORS, PLAYER_HITBOX_RADIUS, KAEL_MELEE_RADIUS, KAEL_SLAM_RADIUS, KAEL_STORM_RADIUS, DISPLACEMENT_COOLDOWNS } from './constants.js'; import { iso, getTh, getTw } from './renderer.js'; let _debugAttackRange = false; export function setDebugAttackRange(v) { _debugAttackRange = v; } let _debugHitboxes = false; export function setDebugHitboxes(v) { _debugHitboxes = v; } const BUFF_TINTS = { invulnerable: 0xffcc44, casting: 0x44ffff, flying: 0x88ccff, intangible: 0xcc88ff, }; // Assets Kael const _kt = {}; let _kaelReady = false; // Assets Seris const _st = {}; let _serisReady = false; // Assets Aldric const _at = {}; let _aldricReady = false; export async function loadKaelAssets() { if (_kaelReady) return; const b = '../assets/sprites/kael/'; // Génère les entrées [clé, chemin] pour une animation // prefix = 'run', 'atk', 'dash', 'sk2', 'sk3' // dirs = tableau de directions (ex: ['south','north','east','west']) // n = nombre de frames // folder = dossier dans animations/ 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 D1 = ['south']; const entries = [ // Rotations statiques (idle) ['south', b + 'rotations/south.png'], ['north', b + 'rotations/north.png'], ['east', b + 'rotations/east.png'], ['west', b + 'rotations/west.png'], // Animations ...anim('run', D4, 4, 'running'), // running 4 dirs × 4 frames ...anim('atk', D4, 5, 'attack'), // attaque 4 dirs × 5 frames ...anim('dash', D4, 5, 'dash'), // dash 4 dirs × 5 frames ...anim('sk2', D1, 9, 'skill2'), // frappe lourde south × 9 frames ...anim('sk3', D1,17, 'skill3'), // tempête south × 17 frames ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _kt[key] = tex; } _kaelReady = true; } export async function loadSerisAssets() { if (_serisReady) return; const b = '../assets/sprites/seris/'; 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 D1 = ['south']; const entries = [ ['s_south', b + 'rotations/south.png'], ['s_north', b + 'rotations/north.png'], ['s_east', b + 'rotations/east.png'], ['s_west', b + 'rotations/west.png'], ...anim('srun', D4, 6, 'running'), // course 4 dirs × 6 frames ...anim('satk', D4, 7, 'attack'), // attaque 4 dirs × 7 frames ...anim('stele', D1, 5, 'teleport'), // téléport south × 5 frames ...anim('ssk1', D1, 9, 'skill1'), // éventail south × 9 frames ...anim('ssk2', D1,13, 'skill2'), // vide south × 13 frames ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _st[key] = tex; } _serisReady = true; } export async function loadAldricAssets() { if (_aldricReady) return; const b = '../assets/sprites/aldric/'; 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 D1 = ['south']; const entries = [ ['a_south', b + 'rotations/south.png'], ['a_north', b + 'rotations/north.png'], ['a_east', b + 'rotations/east.png'], ['a_west', b + 'rotations/west.png'], ...anim('awk', D4, 6, 'walking'), // marche 4 dirs × 6 frames ...anim('aatk', D4, 7, 'attack'), // attaque 4 dirs × 7 frames ...anim('afly', D1, 9, 'fly'), // envol south × 9 frames ...anim('ask1', D1, 9, 'skill1'), // halo south × 9 frames ...anim('ask2', D1,13, 'skill2'), // vague south × 13 frames ]; for (const [key, path] of entries) { const tex = await Assets.load(path); tex.source.scaleMode = 'nearest'; _at[key] = tex; } _aldricReady = true; } // Constantes d'animation const TICK_DT = 0.05; // durée d'un tick serveur (20 Hz) const RUN_FPS = 8; const RUN_TICK = 1 / RUN_FPS; const ATK_FPS = 14; const ATK_TICK = 1 / ATK_FPS; const ATK_FRAMES = 5; const DASH_FPS = 14; const DASH_TICK = 1 / DASH_FPS; const DASH_FRAMES = 5; const DASH_DUR = DASH_FRAMES / DASH_FPS; // ~0.36s — durée totale du dash visuel const SK2_FPS = 12; const SK2_TICK = 1 / SK2_FPS; const SK2_FRAMES = 9; const SK3_FPS = 10; const SK3_TICK = 1 / SK3_FPS; const SK3_FRAMES = 17; // Cooldowns max des skills (pour détecter le déclenchement côté client) // Si le CD passe de ~0 à une valeur > THRESHOLD, le skill vient d'être utilisé. const DISP_CD_MAX = 9.0; const SK2_CD_MAX = 7.0; // kael slam (ability_1) const SK3_CD_MAX = 12.0; // kael tempête (ability_2) // Seris animation frame counts + FPS const S_RUN_FRAMES = 6; const S_ATK_FRAMES = 7; const S_TELE_FRAMES = 5; const S_SK1_FRAMES = 9; const S_SK2_FRAMES = 13; const S_RUN_FPS = 10; const S_RUN_TICK = 1 / S_RUN_FPS; const S_ATK_FPS = 14; const S_ATK_TICK = 1 / S_ATK_FPS; const S_TELE_FPS = 14; const S_TELE_TICK = 1 / S_TELE_FPS; const S_SK1_FPS = 12; const S_SK1_TICK = 1 / S_SK1_FPS; const S_SK2_FPS = 10; const S_SK2_TICK = 1 / S_SK2_FPS; // Cooldowns Seris pour détection côté client const S_DISP_CD_MAX = 4.0; // téléport const S_SK1_CD_MAX = 6.0; // éventail const S_SK2_CD_MAX = 10.0; // vide // Aldric animation frame counts + FPS const A_WK_FRAMES = 6; const A_ATK_FRAMES = 7; const A_FLY_FRAMES = 9; const A_SK1_FRAMES = 9; const A_SK2_FRAMES = 13; const A_WK_FPS = 8; const A_WK_TICK = 1 / A_WK_FPS; const A_ATK_FPS = 12; const A_ATK_TICK = 1 / A_ATK_FPS; const A_FLY_FPS = 12; const A_FLY_TICK = 1 / A_FLY_FPS; const A_SK1_FPS = 10; const A_SK1_TICK = 1 / A_SK1_FPS; const A_SK2_FPS = 10; const A_SK2_TICK = 1 / A_SK2_FPS; // Cooldowns Aldric pour détection côté client const A_DISP_CD_MAX = 14.0; // envol const A_SK1_CD_MAX = 8.0; // halo const A_SK2_CD_MAX = 15.0; // vague // État d'animation par joueur const _prevPos = {}; // position précédente (pour détecter direction et dash) const _animState = {}; // état d'animation complet const _prevAtkCd = {}; // cooldown attaque au tick précédent const _prevDispCd = {}; // cooldown displacement au tick précédent const _prevAb2Cd = {}; // cooldown ability_2 au tick précédent const _prevAb3Cd = {}; // cooldown ability_3 au tick précédent const _dashAnim = {}; // { fromX, fromY, toX, toY, t } — interpolation de position dash // Mise à jour export function updatePlayers(layer, pool, players) { const ids = new Set(players.map(p => p.id)); // Supprimer les joueurs déconnectés for (const [id, container] of Object.entries(pool)) { if (!ids.has(id)) { layer.removeChild(container); delete pool[id]; delete _prevPos[id]; delete _animState[id]; delete _prevAtkCd[id]; delete _prevDispCd[id]; delete _prevAb2Cd[id]; delete _prevAb3Cd[id]; delete _dashAnim[id]; } } for (const p of players) { // Création du sprite si nouveau joueur if (!pool[p.id]) { pool[p.id] = _createPlayerGraphic(p); layer.addChild(pool[p.id]); _prevPos[p.id] = { x: p.x, y: p.y }; _animState[p.id] = { facing: 'south', frame: 0, timer: 0, attacking: false, atkFrame: 0, atkTimer: 0, dashing: false, dashDir: 'south', dashFrame: 0, dashTimer: 0, sk2ing: false, sk2Frame: 0, sk2Timer: 0, sk3ing: false, sk3Frame: 0, sk3Timer: 0, // Seris steling: false, steleFrame: 0, steleTimer: 0, ssk1ing: false, ssk1Frame: 0, ssk1Timer: 0, ssk2ing: false, ssk2Frame: 0, ssk2Timer: 0, // Aldric aflying: false, aflyFrame: 0, aflyTimer: 0, ask1ing: false, ask1Frame: 0, ask1Timer: 0, ask2ing: false, ask2Frame: 0, ask2Timer: 0, }; _prevAtkCd[p.id] = 0; _prevDispCd[p.id] = 0; _prevAb2Cd[p.id] = 0; _prevAb3Cd[p.id] = 0; } const prev = _prevPos[p.id]; const dx = p.x - prev.x; const dy = p.y - prev.y; const moving = Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01; const anim = _animState[p.id]; // Détecter les déclenchements d'actions // Attaque de base : attack CD 0 → > 0 const curAtkCd = p.cooldowns?.attack ?? 0; if (curAtkCd > 0 && _prevAtkCd[p.id] === 0) { anim.attacking = true; anim.atkFrame = 0; anim.atkTimer = 0; } _prevAtkCd[p.id] = curAtkCd; // Displacement (E) — seuil par classe const dispCdMax = DISPLACEMENT_COOLDOWNS[p.class] ?? DISP_CD_MAX; const curDispCd = p.cooldowns?.displacement ?? 0; if (curDispCd > _prevDispCd[p.id] + dispCdMax * 0.5) { if (p.class === 'kael') { anim.dashing = true; anim.dashDir = anim.facing; anim.dashFrame = 0; anim.dashTimer = 0; _dashAnim[p.id] = { fromX: prev.x, fromY: prev.y, toX: p.x, toY: p.y, t: 0, }; } else if (p.class === 'seris') { anim.steling = true; anim.steleFrame = 0; anim.steleTimer = 0; } else if (p.class === 'aldric') { anim.aflying = true; anim.aflyFrame = 0; anim.aflyTimer = 0; } } _prevDispCd[p.id] = curDispCd; // Skill 1 const curAb2Cd = p.cooldowns?.ability_1 ?? 0; if (p.class === 'kael') { if (curAb2Cd > _prevAb2Cd[p.id] + SK2_CD_MAX * 0.5) { anim.sk2ing = true; anim.sk2Frame = 0; anim.sk2Timer = 0; } } else if (p.class === 'seris') { if (curAb2Cd > _prevAb2Cd[p.id] + S_SK1_CD_MAX * 0.5) { anim.ssk1ing = true; anim.ssk1Frame = 0; anim.ssk1Timer = 0; } } else if (p.class === 'aldric') { if (curAb2Cd > _prevAb2Cd[p.id] + A_SK1_CD_MAX * 0.5) { anim.ask1ing = true; anim.ask1Frame = 0; anim.ask1Timer = 0; } } _prevAb2Cd[p.id] = curAb2Cd; // Skill 2 const curAb3Cd = p.cooldowns?.ability_2 ?? 0; if (p.class === 'kael') { if (curAb3Cd > _prevAb3Cd[p.id] + SK3_CD_MAX * 0.5) { anim.sk3ing = true; anim.sk3Frame = 0; anim.sk3Timer = 0; } } else if (p.class === 'seris') { if (curAb3Cd > _prevAb3Cd[p.id] + S_SK2_CD_MAX * 0.5) { anim.ssk2ing = true; anim.ssk2Frame = 0; anim.ssk2Timer = 0; } } else if (p.class === 'aldric') { if (curAb3Cd > _prevAb3Cd[p.id] + A_SK2_CD_MAX * 0.5) { anim.ask2ing = true; anim.ask2Frame = 0; anim.ask2Timer = 0; } } _prevAb3Cd[p.id] = curAb3Cd; // Mise à jour direction depuis le mouvement if (moving && !anim.dashing) { anim.facing = _detectDir(dx, dy) || anim.facing; } // Calcul de la position à afficher // Pendant un dash : interpoler linéairement de fromPos à toPos let renderX = p.x, renderY = p.y; const da = _dashAnim[p.id]; if (da) { da.t += TICK_DT; if (da.t >= DASH_DUR) { delete _dashAnim[p.id]; // dash terminé } else { const alpha = da.t / DASH_DUR; renderX = da.fromX + (da.toX - da.fromX) * alpha; renderY = da.fromY + (da.toY - da.fromY) * alpha; } } const pos = iso(renderX, renderY); // Choisir la texture if (p.class === 'kael' && _kaelReady && pool[p.id]._sprite) { _updateKaelSprite(pool[p.id]._sprite, anim, moving); } else if (p.class === 'seris' && _serisReady && pool[p.id]._sprite) { _updateSerisSprite(pool[p.id]._sprite, anim, moving); } else if (p.class === 'aldric' && _aldricReady && pool[p.id]._sprite) { _updateAldricSprite(pool[p.id]._sprite, anim, moving); } // Debug : zones d'attaque if (p.class === 'kael' && pool[p.id]._rangeGraphic) { const rg = pool[p.id]._rangeGraphic; rg.clear(); if (_debugAttackRange) { const tw = getTw(); const th2 = getTh(); if (anim.attacking) { // Attaque de base — demi-cercle (on affiche un cercle complet pour simplifier) const rx = KAEL_MELEE_RADIUS * tw / 2; const ry = KAEL_MELEE_RADIUS * th2 / 2; rg.ellipse(0, 0, rx, ry).fill({ color: 0xff4444, alpha: 0.18 }); rg.ellipse(0, 0, rx, ry).stroke({ color: 0xff6666, width: 1.5, alpha: 0.7 }); } if (anim.sk2ing) { // Skill 2 — slam cercle complet (orange) const rx = KAEL_SLAM_RADIUS * tw / 2; const ry = KAEL_SLAM_RADIUS * th2 / 2; rg.ellipse(0, 0, rx, ry).fill({ color: 0xff8800, alpha: 0.18 }); rg.ellipse(0, 0, rx, ry).stroke({ color: 0xffaa44, width: 1.5, alpha: 0.8 }); } if (anim.sk3ing) { // Skill 3 — tempête cercle large (violet) const rx = KAEL_STORM_RADIUS * tw / 2; const ry = KAEL_STORM_RADIUS * th2 / 2; rg.ellipse(0, 0, rx, ry).fill({ color: 0xaa44ff, alpha: 0.15 }); rg.ellipse(0, 0, rx, ry).stroke({ color: 0xcc88ff, width: 2, alpha: 0.8 }); } } } // Debug : hitbox de collision du joueur if (pool[p.id]._hboxG) { const hg = pool[p.id]._hboxG; hg.clear(); if (_debugHitboxes) { const rx = PLAYER_HITBOX_RADIUS * getTw() / 2; const ry = PLAYER_HITBOX_RADIUS * getTh() / 2; hg.ellipse(0, 0, rx, ry).stroke({ color: 0x00ffff, width: 1.5, alpha: 0.9 }); } } _prevPos[p.id] = { x: p.x, y: p.y }; // Teinte selon buff let tint = 0xffffff; let alpha = 1.0; for (const b of (p.buffs ?? [])) { if (BUFF_TINTS[b.type]) { tint = BUFF_TINTS[b.type]; break; } } if (p.buffs?.some(b => b.type === 'intangible')) alpha = 0.5; pool[p.id].x = pos.x; pool[p.id].y = pos.y; pool[p.id].tint = tint; pool[p.id].alpha = p.alive ? alpha : 0.2; } } // Logique de sprite Kael function _updateKaelSprite(sprite, anim, moving) { // Priorité : dash > tempête (sk3) > frappe lourde (sk2) > attaque > run > idle if (anim.dashing) { anim.dashTimer += TICK_DT; if (anim.dashTimer >= DASH_TICK) { anim.dashTimer -= DASH_TICK; anim.dashFrame++; } if (anim.dashFrame >= DASH_FRAMES) { anim.dashing = false; } else { sprite.texture = _kt[`dash_${anim.dashDir}_${anim.dashFrame}`]; return; } } if (anim.sk3ing) { anim.sk3Timer += TICK_DT; if (anim.sk3Timer >= SK3_TICK) { anim.sk3Timer -= SK3_TICK; anim.sk3Frame++; } if (anim.sk3Frame >= SK3_FRAMES) { anim.sk3ing = false; } else { sprite.texture = _kt[`sk3_south_${anim.sk3Frame}`]; return; } } if (anim.sk2ing) { anim.sk2Timer += TICK_DT; if (anim.sk2Timer >= SK2_TICK) { anim.sk2Timer -= SK2_TICK; anim.sk2Frame++; } if (anim.sk2Frame >= SK2_FRAMES) { anim.sk2ing = false; } else { sprite.texture = _kt[`sk2_south_${anim.sk2Frame}`]; return; } } if (anim.attacking) { anim.atkTimer += TICK_DT; if (anim.atkTimer >= ATK_TICK) { anim.atkTimer -= ATK_TICK; anim.atkFrame++; } if (anim.atkFrame >= ATK_FRAMES) { anim.attacking = false; } else { sprite.texture = _kt[`atk_${anim.facing}_${anim.atkFrame}`]; return; } } if (moving) { anim.timer += TICK_DT; if (anim.timer >= RUN_TICK) { anim.timer -= RUN_TICK; anim.frame = (anim.frame + 1) % 4; } sprite.texture = _kt[`run_${anim.facing}_${anim.frame}`]; } else { anim.frame = 0; anim.timer = 0; sprite.texture = _kt[anim.facing]; } } // Logique de sprite Seris function _updateSerisSprite(sprite, anim, moving) { // Priorité : téléport > vide (sk2) > éventail (sk1) > attaque > run > idle if (anim.steling) { anim.steleTimer += TICK_DT; if (anim.steleTimer >= S_TELE_TICK) { anim.steleTimer -= S_TELE_TICK; anim.steleFrame++; } if (anim.steleFrame >= S_TELE_FRAMES) { anim.steling = false; } else { sprite.texture = _st[`stele_south_${anim.steleFrame}`]; return; } } if (anim.ssk2ing) { anim.ssk2Timer += TICK_DT; if (anim.ssk2Timer >= S_SK2_TICK) { anim.ssk2Timer -= S_SK2_TICK; anim.ssk2Frame++; } if (anim.ssk2Frame >= S_SK2_FRAMES) { anim.ssk2ing = false; } else { sprite.texture = _st[`ssk2_south_${anim.ssk2Frame}`]; return; } } if (anim.ssk1ing) { anim.ssk1Timer += TICK_DT; if (anim.ssk1Timer >= S_SK1_TICK) { anim.ssk1Timer -= S_SK1_TICK; anim.ssk1Frame++; } if (anim.ssk1Frame >= S_SK1_FRAMES) { anim.ssk1ing = false; } else { sprite.texture = _st[`ssk1_south_${anim.ssk1Frame}`]; return; } } if (anim.attacking) { anim.atkTimer += TICK_DT; if (anim.atkTimer >= S_ATK_TICK) { anim.atkTimer -= S_ATK_TICK; anim.atkFrame++; } if (anim.atkFrame >= S_ATK_FRAMES) { anim.attacking = false; } else { sprite.texture = _st[`satk_${anim.facing}_${anim.atkFrame}`]; return; } } if (moving) { anim.timer += TICK_DT; if (anim.timer >= S_RUN_TICK) { anim.timer -= S_RUN_TICK; anim.frame = (anim.frame + 1) % S_RUN_FRAMES; } sprite.texture = _st[`srun_${anim.facing}_${anim.frame}`]; } else { anim.frame = 0; anim.timer = 0; sprite.texture = _st[`s_${anim.facing}`]; } } // Logique de sprite Aldric function _updateAldricSprite(sprite, anim, moving) { // Priorité : envol > vague (sk2) > halo (sk1) > attaque > marche > idle if (anim.aflying) { anim.aflyTimer += TICK_DT; if (anim.aflyTimer >= A_FLY_TICK) { anim.aflyTimer -= A_FLY_TICK; anim.aflyFrame++; } if (anim.aflyFrame >= A_FLY_FRAMES) { anim.aflying = false; } else { sprite.texture = _at[`afly_south_${anim.aflyFrame}`]; return; } } if (anim.ask2ing) { anim.ask2Timer += TICK_DT; if (anim.ask2Timer >= A_SK2_TICK) { anim.ask2Timer -= A_SK2_TICK; anim.ask2Frame++; } if (anim.ask2Frame >= A_SK2_FRAMES) { anim.ask2ing = false; } else { sprite.texture = _at[`ask2_south_${anim.ask2Frame}`]; return; } } if (anim.ask1ing) { anim.ask1Timer += TICK_DT; if (anim.ask1Timer >= A_SK1_TICK) { anim.ask1Timer -= A_SK1_TICK; anim.ask1Frame++; } if (anim.ask1Frame >= A_SK1_FRAMES) { anim.ask1ing = false; } else { sprite.texture = _at[`ask1_south_${anim.ask1Frame}`]; return; } } if (anim.attacking) { anim.atkTimer += TICK_DT; if (anim.atkTimer >= A_ATK_TICK) { anim.atkTimer -= A_ATK_TICK; anim.atkFrame++; } if (anim.atkFrame >= A_ATK_FRAMES) { anim.attacking = false; } else { sprite.texture = _at[`aatk_${anim.facing}_${anim.atkFrame}`]; return; } } if (moving) { anim.timer += TICK_DT; if (anim.timer >= A_WK_TICK) { anim.timer -= A_WK_TICK; anim.frame = (anim.frame + 1) % A_WK_FRAMES; } sprite.texture = _at[`awk_${anim.facing}_${anim.frame}`]; } else { anim.frame = 0; anim.timer = 0; sprite.texture = _at[`a_${anim.facing}`]; } } // Helpers // Renvoie la direction dominante ('south'|'north'|'east'|'west') ou null si stationnaire function _detectDir(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'; } // Création du sprite function _createPlayerGraphic(player) { const th = getTh(); const container = new Container(); const r = Math.max(10, Math.min(40, th * 0.4)); const hboxG = new Graphics(); container._hboxG = hboxG; if (player.class === 'kael' && _kaelReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.3, r * 0.5, r * 0.13).fill({ color: 0x000000, alpha: 0.3 }); const scale = (th * 1.2) / 48; const sprite = new Sprite(_kt.south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); container._sprite = sprite; const rangeG = new Graphics(); container._rangeGraphic = rangeG; container.addChild(rangeG, shadow, sprite, hboxG); } else if (player.class === 'seris' && _serisReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.3, r * 0.5, r * 0.13).fill({ color: 0x000000, alpha: 0.3 }); const scale = (th * 1.2) / 48; const sprite = new Sprite(_st.s_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); container._sprite = sprite; container.addChild(shadow, sprite, hboxG); } else if (player.class === 'aldric' && _aldricReady) { const shadow = new Graphics(); shadow.ellipse(0, r * 0.3, r * 0.5, r * 0.13).fill({ color: 0x000000, alpha: 0.3 }); const scale = (th * 1.2) / 48; const sprite = new Sprite(_at.a_south); sprite.anchor.set(0.5, 0.75); sprite.scale.set(scale); container._sprite = sprite; container.addChild(shadow, sprite, hboxG); } else { const color = CLASS_COLORS[player.class] ?? 0xffffff; const body = new Graphics(); body.ellipse(0, r * 0.5, r * 0.6, r * 0.15).fill({ color: 0x000000, alpha: 0.25 }); body.circle(0, 0, r).fill({ color }); body.circle(0, 0, r).stroke({ color: 0xffffff, width: 1.5, alpha: 0.3 }); const label = new Text({ text: player.username, style: { fontSize: Math.max(9, Math.min(14, th * 0.15)), fill: 0xdddddd, fontFamily: 'Courier New', }, }); label.anchor.set(0.5, 1); label.y = -(r * 1.05 + 8); container.addChild(body, label, hboxG); } return container; }