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
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;
|
|
}
|