You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

791 lines
25 KiB
TypeScript

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<EnemyType, EnemyVisualConfig> = {
// =======================================================================
// 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<string, EnemyType> = {
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;
}