import { Graphics } from 'pixi.js'; import { ELEMENT_ICE_TEMPLATE_SLUG_SET, isKnownEnemyTemplateSlug } from './enemyTemplateSlugs'; import { EnemyType } from './types'; export type BodyShape = 'diamond' | 'round' | 'wide' | 'tall' | 'spiky'; export type HeadShape = 'circle' | 'horns' | 'crown' | 'none' | 'fangs' | 'helmet'; export interface EnemyVisualConfig { bodyColor: number; strokeColor: number; headColor: number; headStrokeColor: number; size: number; bodyShape: BodyShape; headShape: HeadShape; glowColor?: number; isElite: boolean; drawExtras?: (gfx: Graphics, cx: number, cy: number, size: number, now: number) => void; } function getBodyTopY(cy: number, size: number, shape: BodyShape): number { switch (shape) { case 'diamond': return cy - size; case 'round': return cy - size * 0.75; case 'wide': return cy - size * 0.6; case 'tall': return cy - size * 1.1; case 'spiky': return cy - size * 1.1; } } function drawBody( gfx: Graphics, cx: number, cy: number, size: number, shape: BodyShape, color: number, strokeColor: number, ): void { switch (shape) { case 'diamond': gfx.poly([cx, cy - size, cx + size * 0.7, cy, cx, cy + size * 0.6, cx - size * 0.7, cy]); break; case 'round': gfx.ellipse(cx, cy - size * 0.2, size * 0.65, size * 0.55); break; case 'wide': gfx.poly([cx, cy - size * 0.6, cx + size, cy, cx, cy + size * 0.4, cx - size, cy]); break; case 'tall': gfx.poly([cx, cy - size * 1.1, cx + size * 0.45, cy, cx, cy + size * 0.5, cx - size * 0.45, cy]); break; case 'spiky': gfx.poly([ cx, cy - size * 1.1, cx + size * 0.35, cy - size * 0.4, cx + size * 0.8, cy - size * 0.15, cx + size * 0.4, cy + size * 0.15, cx, cy + size * 0.6, cx - size * 0.4, cy + size * 0.15, cx - size * 0.8, cy - size * 0.15, cx - size * 0.35, cy - size * 0.4, ]); break; } gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 2 }); } function drawHead( gfx: Graphics, cx: number, bodyTop: number, shape: HeadShape, color: number, strokeColor: number, ): void { if (shape === 'none') return; const headY = bodyTop - 5; const r = 5; switch (shape) { case 'circle': gfx.circle(cx, headY, r); gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 1.5 }); break; case 'horns': gfx.circle(cx, headY, r); gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 1.5 }); gfx.poly([cx - 4, headY - 2, cx - 7, headY - 10, cx - 1, headY - 4]); gfx.fill({ color: strokeColor }); gfx.poly([cx + 4, headY - 2, cx + 7, headY - 10, cx + 1, headY - 4]); gfx.fill({ color: strokeColor }); break; case 'crown': gfx.circle(cx, headY, r); gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 1.5 }); gfx.poly([ cx - 6, headY - 3, cx - 4, headY - 10, cx - 1, headY - 6, cx, headY - 12, cx + 1, headY - 6, cx + 4, headY - 10, cx + 6, headY - 3, ]); gfx.fill({ color: 0xFFDD44 }); gfx.stroke({ color: 0xCCAA22, width: 1 }); break; case 'fangs': gfx.circle(cx, headY, r); gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 1.5 }); gfx.poly([cx - 3, headY + 3, cx - 1.5, headY + 8, cx, headY + 3]); gfx.fill({ color: 0xEEEEDD }); gfx.poly([cx, headY + 3, cx + 1.5, headY + 8, cx + 3, headY + 3]); gfx.fill({ color: 0xEEEEDD }); break; case 'helmet': gfx.rect(cx - 6, headY - 6, 12, 10); gfx.fill({ color }); gfx.stroke({ color: strokeColor, width: 1.5 }); gfx.rect(cx - 4, headY - 1, 8, 2.5); gfx.fill({ color: 0x222233 }); gfx.rect(cx - 1.5, headY - 8, 3, 4); gfx.fill({ color: strokeColor }); break; } } // --------------------------------------------------------------------------- // Visual configs for all 13 enemy types // --------------------------------------------------------------------------- export const ENEMY_VISUALS: Record = { // ======================================================================= // BASE ENEMIES // ======================================================================= [EnemyType.Wolf]: { bodyColor: 0x808075, strokeColor: 0x5A5A50, headColor: 0x909085, headStrokeColor: 0x6A6A60, size: 12, bodyShape: 'diamond', headShape: 'fangs', isElite: false, drawExtras(gfx, cx, cy, size, now) { const headY = cy - size - 5; // Pointed ears gfx.poly([cx - 3, headY - 2, cx - 5, headY - 7, cx - 1, headY - 3]); gfx.fill({ color: 0x707065 }); gfx.poly([cx + 3, headY - 2, cx + 5, headY - 7, cx + 1, headY - 3]); gfx.fill({ color: 0x707065 }); // Wagging tail const wag = Math.sin(now * 0.01) * 3; gfx.poly([ cx - size * 0.5, cy + size * 0.3, cx - size * 0.9 + wag, cy + size * 0.1, cx - size * 0.5, cy + size * 0.45, ]); gfx.fill({ color: 0x6A6A5A }); }, }, [EnemyType.Boar]: { bodyColor: 0x8B6544, strokeColor: 0x6A4C33, headColor: 0x9B7554, headStrokeColor: 0x7A5C43, size: 16, bodyShape: 'wide', headShape: 'circle', isElite: false, drawExtras(gfx, cx, cy, size) { const headY = cy - size * 0.6 - 5; // Upward-curving tusks gfx.poly([cx - 4, headY + 1, cx - 6, headY - 6, cx - 2, headY]); gfx.fill({ color: 0xEEEEDD }); gfx.poly([cx + 4, headY + 1, cx + 6, headY - 6, cx + 2, headY]); gfx.fill({ color: 0xEEEEDD }); // Muscular back hump gfx.ellipse(cx, cy - size * 0.45, size * 0.4, size * 0.18); gfx.fill({ color: 0x7A5534, alpha: 0.7 }); }, }, [EnemyType.Zombie]: { bodyColor: 0x5A7A4A, strokeColor: 0x3A5A2A, headColor: 0x6A8A5A, headStrokeColor: 0x4A6A3A, size: 14, bodyShape: 'diamond', headShape: 'circle', isElite: false, drawExtras(gfx, cx, cy, size, now) { // Green poison fog at feet const pulse = Math.sin(now * 0.003) * 0.15 + 0.3; gfx.ellipse(cx - 3, cy + size * 0.5, size * 0.8, 3); gfx.fill({ color: 0x44AA33, alpha: pulse }); gfx.ellipse(cx + 4, cy + size * 0.55, size * 0.6, 2.5); gfx.fill({ color: 0x33BB22, alpha: pulse * 0.8 }); }, }, [EnemyType.Spider]: { bodyColor: 0x6B3A7A, strokeColor: 0x4A2A5A, headColor: 0x6B3A7A, headStrokeColor: 0x4A2A5A, size: 11, bodyShape: 'round', headShape: 'none', isElite: false, drawExtras(gfx, cx, cy, size, now) { const wiggle = Math.sin(now * 0.008) * 1.5; // 3 legs per side (6 total, thin triangles) for (let i = 0; i < 3; i++) { const yOff = (i - 1) * 4; const baseY = cy - size * 0.2 + yOff; const spread = size * 0.6 + i * 2; const tipY = baseY - 2 + wiggle * (i % 2 === 0 ? 1 : -1); // Left leg gfx.poly([ cx - size * 0.5, baseY, cx - size * 0.5 - spread, tipY, cx - size * 0.5 - spread + 0.8, tipY + 1.5, ]); gfx.fill({ color: 0x5A2A6A, alpha: 0.9 }); // Right leg gfx.poly([ cx + size * 0.5, baseY, cx + size * 0.5 + spread, tipY, cx + size * 0.5 + spread - 0.8, tipY + 1.5, ]); gfx.fill({ color: 0x5A2A6A, alpha: 0.9 }); } // Red eyes gfx.circle(cx - 2.5, cy - size * 0.4, 1.5); gfx.fill({ color: 0xFF2222 }); gfx.circle(cx + 2.5, cy - size * 0.4, 1.5); gfx.fill({ color: 0xFF2222 }); }, }, [EnemyType.Orc]: { bodyColor: 0x4A7A3A, strokeColor: 0x2A5A1A, headColor: 0x888899, headStrokeColor: 0x666677, size: 15, bodyShape: 'wide', headShape: 'helmet', isElite: false, drawExtras(gfx, cx, cy, size) { // Shoulder armor plates gfx.rect(cx - size - 2, cy - size * 0.3, 5, 6); gfx.fill({ color: 0x666677 }); gfx.stroke({ color: 0x555566, width: 1 }); gfx.rect(cx + size - 3, cy - size * 0.3, 5, 6); gfx.fill({ color: 0x666677 }); gfx.stroke({ color: 0x555566, width: 1 }); }, }, [EnemyType.SkeletonArcher]: { bodyColor: 0xD4C8AA, strokeColor: 0xAA9E80, headColor: 0xE0D8BB, headStrokeColor: 0xBBB099, size: 13, bodyShape: 'tall', headShape: 'circle', isElite: false, drawExtras(gfx, cx, cy, size) { // Bow on right side const bowX = cx + size * 0.6; const bowMid = cy - size * 0.3; gfx.poly([bowX, bowMid - 8, bowX + 3, bowMid, bowX, bowMid + 8, bowX + 2, bowMid]); gfx.stroke({ color: 0x8B7355, width: 1.5 }); // Bowstring gfx.poly([bowX, bowMid - 8, bowX - 1, bowMid, bowX, bowMid + 8]); gfx.stroke({ color: 0xCCCCBB, width: 0.7, alpha: 0.6 }); // Rib lines for skeletal look const ribs: [number, number][] = [[-0.5, 4], [-0.2, 3.5], [0.1, 3]]; for (const [yFrac, w] of ribs) { gfx.rect(cx - w / 2, cy + size * yFrac, w, 1); gfx.fill({ color: 0xBBB099, alpha: 0.5 }); } }, }, [EnemyType.BattleLizard]: { bodyColor: 0x6B7A3A, strokeColor: 0x4B5A1A, headColor: 0x6B7A3A, headStrokeColor: 0x4B5A1A, size: 16, bodyShape: 'wide', headShape: 'none', isElite: false, drawExtras(gfx, cx, cy, size) { // Scale pattern (small diamonds across body) const sc = 0x5A6A2A; for (let row = 0; row < 2; row++) { for (let col = -1; col <= 1; col++) { const sx = cx + col * 6; const sy = cy - size * 0.15 + row * 5; gfx.poly([sx, sy - 2, sx + 2.5, sy, sx, sy + 2, sx - 2.5, sy]); gfx.fill({ color: sc, alpha: 0.5 }); } } // Snout bump (head substitute) const topY = cy - size * 0.6; gfx.ellipse(cx, topY - 4, 4.5, 3); gfx.fill({ color: 0x7B8A4A }); gfx.stroke({ color: 0x5B6A2A, width: 1 }); // Yellow reptile eyes gfx.circle(cx - 2, topY - 5, 1.2); gfx.fill({ color: 0xDDAA22 }); gfx.circle(cx + 2, topY - 5, 1.2); gfx.fill({ color: 0xDDAA22 }); // Tail extending behind gfx.poly([ cx - size * 0.7, cy + size * 0.2, cx - size - 5, cy + size * 0.45, cx - size * 0.6, cy + size * 0.35, ]); gfx.fill({ color: 0x5A6A2A }); }, }, // ======================================================================= // ELITE ENEMIES // ======================================================================= [EnemyType.FireDemon]: { bodyColor: 0xCC4422, strokeColor: 0xAA2211, headColor: 0xDD5533, headStrokeColor: 0xBB3322, size: 15, bodyShape: 'spiky', headShape: 'horns', glowColor: 0xFF6600, isElite: true, drawExtras(gfx, cx, cy, size, now) { const headY = cy - size * 1.1 - 5; const f1 = Math.sin(now * 0.008) * 3; const f2 = Math.cos(now * 0.011) * 2; const f3 = Math.sin(now * 0.014 + 1) * 2.5; // Main flame gfx.poly([cx - 3, headY - 6, cx, headY - 14 + f1, cx + 3, headY - 6]); gfx.fill({ color: 0xFF6600, alpha: 0.85 }); // Side flames gfx.poly([cx + 3, headY - 4, cx + 5, headY - 11 + f2, cx + 7, headY - 4]); gfx.fill({ color: 0xFFAA00, alpha: 0.7 }); gfx.poly([cx - 7, headY - 4, cx - 5, headY - 10 + f3, cx - 3, headY - 4]); gfx.fill({ color: 0xFF4400, alpha: 0.65 }); // Bright inner core gfx.poly([cx - 1.5, headY - 4, cx, headY - 9 + f2, cx + 1.5, headY - 4]); gfx.fill({ color: 0xFFDD44, alpha: 0.9 }); }, }, [EnemyType.IceGuardian]: { bodyColor: 0x44AADD, strokeColor: 0x2288BB, headColor: 0x99CCDD, headStrokeColor: 0x77AABB, size: 16, bodyShape: 'diamond', headShape: 'helmet', glowColor: 0x44CCFF, isElite: true, drawExtras(gfx, cx, cy, size, now) { const pulse = Math.sin(now * 0.003) * 0.2 + 0.8; // Left ice crystal gfx.poly([ cx - size * 0.7 - 1, cy - 2, cx - size * 0.7 - 6, cy - 10, cx - size * 0.7 - 3, cy, ]); gfx.fill({ color: 0xAADDFF, alpha: pulse }); gfx.stroke({ color: 0x88CCFF, width: 0.8, alpha: pulse }); // Right ice crystal gfx.poly([ cx + size * 0.7 + 1, cy - 3, cx + size * 0.7 + 7, cy - 9, cx + size * 0.7 + 3, cy, ]); gfx.fill({ color: 0x99CCEE, alpha: pulse }); gfx.stroke({ color: 0x77AADD, width: 0.8, alpha: pulse }); // Top crystal spike gfx.poly([cx - 2, cy - size - 12, cx, cy - size - 18, cx + 2, cy - size - 12]); gfx.fill({ color: 0xBBEEFF, alpha: pulse * 0.9 }); // Frost sparkles (blink on/off) if (Math.sin(now * 0.006) > 0.5) { gfx.circle(cx - size * 0.5, cy - size * 0.7, 1); gfx.fill({ color: 0xFFFFFF, alpha: 0.8 }); gfx.circle(cx + size * 0.6, cy - size * 0.3, 1); gfx.fill({ color: 0xFFFFFF, alpha: 0.7 }); } }, }, [EnemyType.SkeletonKing]: { bodyColor: 0xCCBB77, strokeColor: 0xAA9955, headColor: 0xDDCC88, headStrokeColor: 0xBBAA66, size: 16, bodyShape: 'tall', headShape: 'crown', glowColor: 0xAA44FF, isElite: true, drawExtras(gfx, cx, cy, size, now) { // Orbiting bone fragments const angle = now * 0.002; const orbitR = size + 10; for (let i = 0; i < 3; i++) { const a = angle + (i * Math.PI * 2) / 3; const bx = cx + Math.cos(a) * orbitR; const by = cy - size * 0.3 + Math.sin(a) * orbitR * 0.4; gfx.rect(bx - 1, by - 2.5, 2, 5); gfx.fill({ color: 0xDDCCAA, alpha: 0.75 }); } // Rib lines for skeletal body gfx.rect(cx - 2.5, cy - size * 0.5, 5, 1); gfx.fill({ color: 0xAA9955, alpha: 0.5 }); gfx.rect(cx - 2, cy - size * 0.15, 4, 1); gfx.fill({ color: 0xAA9955, alpha: 0.5 }); }, }, [EnemyType.WaterElement]: { bodyColor: 0x33AACC, strokeColor: 0x1188AA, headColor: 0x33AACC, headStrokeColor: 0x1188AA, size: 14, bodyShape: 'round', headShape: 'none', glowColor: 0x33DDFF, isElite: true, drawExtras(gfx, cx, cy, size, now) { // Translucent highlight for watery look gfx.ellipse(cx, cy - size * 0.25, size * 0.4, size * 0.35); gfx.fill({ color: 0x77DDFF, alpha: 0.3 }); // Animated ripple rings at base const ripple = (now * 0.003) % (Math.PI * 2); const r1 = size * 0.5 + Math.sin(ripple) * 4; gfx.ellipse(cx, cy + size * 0.3, r1 + 4, (r1 + 4) * 0.35); gfx.stroke({ color: 0x44DDFF, width: 1, alpha: 0.35 + Math.sin(ripple) * 0.15 }); const r2 = size * 0.5 + Math.sin(ripple + 1.5) * 3; gfx.ellipse(cx, cy + size * 0.4, r2 + 7, (r2 + 7) * 0.3); gfx.stroke({ color: 0x33CCEE, width: 0.8, alpha: 0.25 + Math.sin(ripple + 1.5) * 0.1 }); // Water droplets floating upward const d1 = cy - size * 0.5 - ((now * 0.02) % 12); const d2 = cy - size * 0.3 - ((now * 0.018 + 6) % 10); gfx.circle(cx - 3, d1, 1.2); gfx.fill({ color: 0x88EEFF, alpha: 0.6 }); gfx.circle(cx + 4, d2, 1); gfx.fill({ color: 0x77DDFF, alpha: 0.5 }); }, }, [EnemyType.ForestWarden]: { bodyColor: 0x3A6A2A, strokeColor: 0x1A4A0A, headColor: 0x3A6A2A, headStrokeColor: 0x1A4A0A, size: 18, bodyShape: 'wide', headShape: 'none', glowColor: 0x44CC44, isElite: true, drawExtras(gfx, cx, cy, size, now) { // Bark texture (horizontal grooves) for (let i = 0; i < 3; i++) { const ly = cy - size * 0.25 + i * 5; const lw = size * (0.5 - i * 0.08); gfx.rect(cx - lw, ly, lw * 2, 1.5); gfx.fill({ color: 0x2A4A1A, alpha: 0.4 }); } // Tree canopy crown on top const topY = cy - size * 0.6; gfx.ellipse(cx, topY - 7, 9, 5.5); gfx.fill({ color: 0x2D7A1D, alpha: 0.9 }); gfx.ellipse(cx - 4, topY - 4, 6, 4); gfx.fill({ color: 0x358A25, alpha: 0.85 }); gfx.ellipse(cx + 4, topY - 4, 6, 4); gfx.fill({ color: 0x358A25, alpha: 0.85 }); // Glowing eyes peering from canopy gfx.circle(cx - 3, topY - 6, 1.5); gfx.fill({ color: 0x88FF44 }); gfx.circle(cx + 3, topY - 6, 1.5); gfx.fill({ color: 0x88FF44 }); // Root tendrils at base const sway = Math.sin(now * 0.002) * 1.5; gfx.poly([ cx - size * 0.6, cy + size * 0.3, cx - size - 3 + sway, cy + size * 0.5, cx - size * 0.5, cy + size * 0.4, ]); gfx.fill({ color: 0x4A5A2A, alpha: 0.7 }); gfx.poly([ cx + size * 0.5, cy + size * 0.3, cx + size + 2 - sway, cy + size * 0.5, cx + size * 0.6, cy + size * 0.4, ]); gfx.fill({ color: 0x4A5A2A, alpha: 0.7 }); }, }, [EnemyType.LightningTitan]: { bodyColor: 0x5577CC, strokeColor: 0x3355AA, headColor: 0x6688DD, headStrokeColor: 0x4466BB, size: 20, bodyShape: 'tall', headShape: 'horns', glowColor: 0xFFDD44, isElite: true, drawExtras(gfx, cx, cy, size, now) { // Lightning bolt zigzag (intermittent) const strike = Math.sin(now * 0.015); if (strike > 0.2) { const alpha = Math.min(1, (strike - 0.2) * 1.25); const lx = cx + Math.sin(now * 0.02) * 5; const topY = cy - size * 1.1 - 5; gfx.poly([ lx - 1, topY - 8, lx + 4, topY - 2, lx, topY - 1, lx + 3, topY + 6, lx - 2, topY + 7, lx + 1, topY + 14, lx - 1, topY + 7, lx + 1, topY + 6, lx - 3, topY, lx + 1, topY - 1, lx - 2, topY - 2, ]); gfx.fill({ color: 0xFFFF44, alpha: alpha * 0.7 }); gfx.stroke({ color: 0xFFFFAA, width: 1, alpha }); } // Static sparks orbiting the body const sparkPhase = (now * 0.01) % (Math.PI * 2); for (let i = 0; i < 4; i++) { const a = sparkPhase + (i * Math.PI) / 2; if (Math.sin(a + now * 0.005) > 0.6) { const sx = cx + Math.cos(a) * (size * 0.8); const sy = cy + Math.sin(a) * (size * 0.4) - size * 0.3; gfx.circle(sx, sy, 1.5); gfx.fill({ color: 0xFFFF88, alpha: 0.85 }); } } }, }, }; /** Maps server `archetype` (snake_case) to one of the 13 legacy art presets. */ const ARCHETYPE_VISUAL_KEY: Record = { wolf: EnemyType.Wolf, boar: EnemyType.Boar, zombie: EnemyType.Zombie, spider: EnemyType.Spider, orc: EnemyType.Orc, skeleton: EnemyType.SkeletonArcher, battle_lizard: EnemyType.BattleLizard, demon: EnemyType.FireDemon, skeleton_king: EnemyType.SkeletonKing, forest_warden: EnemyType.ForestWarden, titan: EnemyType.LightningTitan, golem: EnemyType.Orc, wraith: EnemyType.Zombie, bandit: EnemyType.Orc, cultist: EnemyType.SkeletonArcher, treant: EnemyType.ForestWarden, basilisk: EnemyType.BattleLizard, wyvern: EnemyType.FireDemon, harpy: EnemyType.Spider, manticore: EnemyType.Boar, shade: EnemyType.Zombie, }; function hashString(s: string): number { let h = 5381; for (let i = 0; i < s.length; i++) { h = ((h << 5) + h) ^ s.charCodeAt(i); } return h >>> 0; } function nudgeColor(base: number, h: number, salt: number): number { const x = Math.imul(h ^ salt, 0x9e3779b1); const dr = (x % 41) - 20; const dg = ((x >> 8) % 41) - 20; const db = ((x >> 16) % 41) - 20; const r = Math.max(0, Math.min(255, ((base >> 16) & 0xff) + dr)); const g = Math.max(0, Math.min(255, ((base >> 8) & 0xff) + dg)); const b = Math.max(0, Math.min(255, (base & 0xff) + db)); return (r << 16) | (g << 8) | b; } const BODY_SHAPE_ORDER: BodyShape[] = ['diamond', 'round', 'wide', 'tall', 'spiky']; const HEAD_SHAPE_ORDER: HeadShape[] = ['circle', 'horns', 'crown', 'none', 'fangs', 'helmet']; function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualConfig { const h = hashString(slug); const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length; const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape; const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape; const sizeMul = 1.5 + ((h ^ h2) % 29) / 100; return { ...base, bodyShape, headShape, size: base.size * sizeMul, bodyColor: nudgeColor(base.bodyColor, h, 1), strokeColor: nudgeColor(base.strokeColor, h, 2), headColor: nudgeColor(base.headColor, h, 3), headStrokeColor: nudgeColor(base.headStrokeColor, h, 4), glowColor: base.glowColor != null ? nudgeColor(base.glowColor, h, 5) : base.glowColor, }; } /** * Resolves drawing config: each slug gets a deterministic variant of an archetype-appropriate preset. */ export function resolveEnemyVisual(slug: string, archetype?: string): EnemyVisualConfig { const slugLower = slug.toLowerCase(); const arch = (archetype || '').toLowerCase(); let key: EnemyType = EnemyType.Wolf; if (arch === 'element' || arch.startsWith('element')) { key = ELEMENT_ICE_TEMPLATE_SLUG_SET.has(slugLower) ? EnemyType.IceGuardian : EnemyType.WaterElement; } else if (arch && ARCHETYPE_VISUAL_KEY[arch]) { key = ARCHETYPE_VISUAL_KEY[arch]; } else { const first = slugLower.split('_')[0] ?? ''; if (first && ARCHETYPE_VISUAL_KEY[first]) { key = ARCHETYPE_VISUAL_KEY[first]; } } if ( import.meta.env.DEV && slug !== 'unknown' && slug !== '' && !isKnownEnemyTemplateSlug(slug) ) { console.warn(`[enemyVisuals] unknown enemy template slug (not in 000006b): ${slug}`); } const base = ENEMY_VISUALS[key] ?? ENEMY_VISUALS[EnemyType.Wolf]; return tweakVisualForSlug(base, slug); } // --------------------------------------------------------------------------- // HP bar only (body drawn as texture) // --------------------------------------------------------------------------- export function drawEnemyHpBarOnly( gfx: Graphics, enemySlug: string, enemyArchetype: string | undefined, cx: number, cy: number, hp: number, maxHp: number, ): void { gfx.clear(); const config = resolveEnemyVisual(enemySlug, enemyArchetype); const { size } = config; const bodyTop = getBodyTopY(cy, size, config.bodyShape); const barWidth = 30; const barHeight = 4; const headHeight = config.headShape === 'none' ? config.isElite ? 12 : 6 : config.headShape === 'crown' ? 22 : config.headShape === 'horns' ? 18 : config.headShape === 'helmet' ? 16 : 14; const hpBarY = bodyTop - headHeight - 4; gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight); gfx.fill({ color: 0x000000, alpha: 0.6 }); const hpRatio = maxHp > 0 ? Math.max(0, hp / maxHp) : 0; if (hpRatio > 0) { gfx.rect(cx - barWidth / 2, hpBarY, barWidth * hpRatio, barHeight); const hpColor = hpRatio > 0.5 ? 0xcc3333 : hpRatio > 0.25 ? 0xccaa22 : 0xff2222; gfx.fill({ color: hpColor }); } if (config.isElite) { gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight); gfx.stroke({ color: config.glowColor ?? 0xffaa00, width: 1, alpha: 0.8 }); } gfx.zIndex = cy + 101; } // --------------------------------------------------------------------------- // Main draw function — replaces the generic red-diamond drawEnemy // --------------------------------------------------------------------------- export function drawEnemyBySlug( gfx: Graphics, wx: number, wy: number, hp: number, maxHp: number, enemySlug: string, enemyArchetype: string | undefined, now: number, worldToScreenFn: (wx: number, wy: number) => { x: number; y: number }, ): void { gfx.clear(); const config = resolveEnemyVisual(enemySlug, enemyArchetype); const iso = worldToScreenFn(wx, wy); const sway = Math.sin(now * 0.004) * 2; const cx = iso.x; const cy = iso.y + sway; const { size } = config; // 1. Shadow gfx.ellipse(cx, cy + 8, size + 2, 5); gfx.fill({ color: 0x000000, alpha: 0.3 }); // 2. Elite glow (behind body) if (config.isElite && config.glowColor != null) { const pulse = Math.sin(now * 0.003) * 0.12 + 0.22; gfx.circle(cx, cy - size * 0.2, size + 8); gfx.fill({ color: config.glowColor, alpha: pulse }); } // 3. Body drawBody(gfx, cx, cy, size, config.bodyShape, config.bodyColor, config.strokeColor); // 4. Head const bodyTop = getBodyTopY(cy, size, config.bodyShape); drawHead(gfx, cx, bodyTop, config.headShape, config.headColor, config.headStrokeColor); // 5. Type-specific decorations config.drawExtras?.(gfx, cx, cy, size, now); // 6. HP bar const barWidth = 30; const barHeight = 4; const headHeight = config.headShape === 'none' ? (config.isElite ? 12 : 6) : config.headShape === 'crown' ? 22 : config.headShape === 'horns' ? 18 : config.headShape === 'helmet' ? 16 : 14; const hpBarY = bodyTop - headHeight - 4; gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight); gfx.fill({ color: 0x000000, alpha: 0.6 }); const hpRatio = maxHp > 0 ? Math.max(0, hp / maxHp) : 0; if (hpRatio > 0) { gfx.rect(cx - barWidth / 2, hpBarY, barWidth * hpRatio, barHeight); const hpColor = hpRatio > 0.5 ? 0xcc3333 : hpRatio > 0.25 ? 0xccaa22 : 0xff2222; gfx.fill({ color: hpColor }); } // Elite HP bar border if (config.isElite) { gfx.rect(cx - barWidth / 2, hpBarY, barWidth, barHeight); gfx.stroke({ color: config.glowColor ?? 0xFFAA00, width: 1, alpha: 0.8 }); } // Depth sorting gfx.zIndex = cy + 100; }