801 lines
24 KiB
TypeScript
801 lines
24 KiB
TypeScript
import type { EnemyCombatDefinition } from "./enemyAttacks";
|
|
|
|
export type MobVector3 = readonly [x: number, y: number, z: number];
|
|
export type MutableMobVector3 = [x: number, y: number, z: number];
|
|
export type MobArchetype =
|
|
| "humanoid"
|
|
| "raptor"
|
|
| "crocolisk"
|
|
| "ooze"
|
|
| "plant"
|
|
| "serpent"
|
|
| "turtle"
|
|
| "lizard"
|
|
| "murloc"
|
|
| "winged";
|
|
|
|
export interface MobModelStyle {
|
|
readonly url: string;
|
|
readonly rotationY: number;
|
|
readonly groundOffset: number;
|
|
readonly labelHeight: number;
|
|
readonly markerRadius?: number;
|
|
/** Static source models use the renderer's archetype gait instead of native clips. */
|
|
readonly animationMode?: "native" | "procedural";
|
|
}
|
|
|
|
export interface MobVisualStyle {
|
|
readonly primaryColor: string;
|
|
readonly accentColor: string;
|
|
readonly scale: number;
|
|
readonly archetype: MobArchetype;
|
|
readonly model?: MobModelStyle;
|
|
}
|
|
|
|
interface PopulationEntityBase {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly combat?: EnemyCombatDefinition;
|
|
/** Optional override; bosses normally derive lootability from the SQL catalog. */
|
|
readonly hasLoot?: boolean;
|
|
readonly visual: MobVisualStyle;
|
|
}
|
|
|
|
export interface MobDefinition extends PopulationEntityBase {
|
|
readonly kind: "mob";
|
|
}
|
|
|
|
export interface BossDefinition extends PopulationEntityBase {
|
|
readonly kind: "boss";
|
|
readonly title: string;
|
|
}
|
|
|
|
export type PopulationEntityDefinition = MobDefinition | BossDefinition;
|
|
export type PopulationDefinitionMap = Readonly<Record<string, PopulationEntityDefinition>>;
|
|
|
|
/** Formation values are lateral offset, vertical offset, and trailing distance. */
|
|
export type PackFormationOffset = readonly [lateral: number, vertical: number, trailing: number];
|
|
|
|
export interface MobPackMemberDefinition {
|
|
readonly id: string;
|
|
readonly entityId: string;
|
|
readonly role: "leader" | "follower";
|
|
readonly formationOffset: PackFormationOffset;
|
|
}
|
|
|
|
export interface MobPackDefinition {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly speed: number;
|
|
readonly startDistance?: number;
|
|
readonly pathId?: number;
|
|
readonly formationSource: "database-solo" | "inferred-followers";
|
|
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
|
|
readonly spawnMask?: number;
|
|
readonly waypoints: readonly MobVector3[];
|
|
readonly members: readonly MobPackMemberDefinition[];
|
|
}
|
|
|
|
export interface StaticMobSpawnDefinition {
|
|
readonly id: string;
|
|
readonly entityId: string;
|
|
readonly position: MobVector3;
|
|
readonly yaw?: number;
|
|
/** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */
|
|
readonly spawnMask?: number;
|
|
}
|
|
|
|
export interface StaticMobSpawnCluster {
|
|
readonly id: string;
|
|
readonly points: readonly MobVector3[];
|
|
readonly spawns: readonly StaticMobSpawnDefinition[];
|
|
}
|
|
|
|
export interface NormalizedMobPopulationPaths {
|
|
readonly roamingPacks: readonly MobPackDefinition[];
|
|
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
|
}
|
|
|
|
/**
|
|
* Partitions large imported populations into bounded activation cells. Actors
|
|
* in distant cells are not mounted or registered with combat until approached.
|
|
*/
|
|
export function clusterStaticMobSpawns(
|
|
spawns: readonly StaticMobSpawnDefinition[],
|
|
horizontalSize = 72,
|
|
verticalSize = 36,
|
|
): readonly StaticMobSpawnCluster[] {
|
|
if (horizontalSize <= 0 || verticalSize <= 0) {
|
|
throw new Error("Static population cluster dimensions must be positive.");
|
|
}
|
|
const clusters = new Map<string, StaticMobSpawnDefinition[]>();
|
|
for (const spawn of spawns) {
|
|
const cell = [
|
|
Math.floor(spawn.position[0] / horizontalSize),
|
|
Math.floor(spawn.position[1] / verticalSize),
|
|
Math.floor(spawn.position[2] / horizontalSize),
|
|
];
|
|
const id = cell.join(":");
|
|
const members = clusters.get(id) ?? [];
|
|
members.push(spawn);
|
|
clusters.set(id, members);
|
|
}
|
|
return Object.freeze([...clusters.entries()].map(([id, members]) => Object.freeze({
|
|
id,
|
|
spawns: Object.freeze(members),
|
|
points: Object.freeze(members.map((spawn) => spawn.position)),
|
|
})));
|
|
}
|
|
|
|
export function spawnMatchesDifficulty(spawnMask: number | undefined, difficultyMask: number): boolean {
|
|
return ((spawnMask ?? 1) & difficultyMask) !== 0;
|
|
}
|
|
|
|
export interface LoopedPathSegment {
|
|
readonly startIndex: number;
|
|
readonly endIndex: number;
|
|
readonly startDistance: number;
|
|
readonly length: number;
|
|
}
|
|
|
|
export interface LoopedPath {
|
|
readonly waypoints: readonly MobVector3[];
|
|
readonly segments: readonly LoopedPathSegment[];
|
|
readonly totalLength: number;
|
|
}
|
|
|
|
const EPSILON = 1e-6;
|
|
|
|
export function hasUsableLoopedMobPath(waypoints: readonly MobVector3[]): boolean {
|
|
if (waypoints.length < 2 || !waypoints.every((point) => point.every(Number.isFinite))) {
|
|
return false;
|
|
}
|
|
const origin = waypoints[0];
|
|
return waypoints.some((point) => Math.hypot(
|
|
point[0] - origin[0],
|
|
point[1] - origin[1],
|
|
point[2] - origin[2],
|
|
) > EPSILON);
|
|
}
|
|
|
|
/**
|
|
* Imported databases occasionally label a stationary spawn as a patrol by
|
|
* repeating one waypoint. Keep those actors, but mount them through the static
|
|
* path so malformed content can never take down the scene.
|
|
*/
|
|
export function normalizeMobPopulationPaths(
|
|
roamingPacks: readonly MobPackDefinition[],
|
|
staticSpawns: readonly StaticMobSpawnDefinition[],
|
|
): NormalizedMobPopulationPaths {
|
|
const invalidPacks = roamingPacks.filter((pack) => !hasUsableLoopedMobPath(pack.waypoints));
|
|
if (!invalidPacks.length) return { roamingPacks, staticSpawns };
|
|
|
|
const invalidIds = new Set(invalidPacks.map((pack) => pack.id));
|
|
const stationarySpawns = invalidPacks.flatMap((pack) => {
|
|
const anchor = pack.waypoints.find((point) => point.every(Number.isFinite));
|
|
if (!anchor) return [];
|
|
return pack.members.map((member): StaticMobSpawnDefinition => ({
|
|
id: `${pack.id}:${member.id}`,
|
|
entityId: member.entityId,
|
|
position: [
|
|
anchor[0] + member.formationOffset[0],
|
|
anchor[1] + member.formationOffset[1],
|
|
anchor[2] - member.formationOffset[2],
|
|
],
|
|
spawnMask: pack.spawnMask,
|
|
}));
|
|
});
|
|
return {
|
|
roamingPacks: roamingPacks.filter((pack) => !invalidIds.has(pack.id)),
|
|
staticSpawns: [...staticSpawns, ...stationarySpawns],
|
|
};
|
|
}
|
|
|
|
function assertFiniteWaypoint(point: MobVector3, index: number): void {
|
|
if (!point.every(Number.isFinite)) {
|
|
throw new Error(`Waypoint ${index} contains a non-finite coordinate.`);
|
|
}
|
|
}
|
|
|
|
/** Precomputes immutable segment lengths once so frame sampling stays allocation-free. */
|
|
export function createLoopedPath(waypoints: readonly MobVector3[]): LoopedPath {
|
|
if (waypoints.length < 2) {
|
|
throw new Error("A looped mob path requires at least two waypoints.");
|
|
}
|
|
|
|
waypoints.forEach(assertFiniteWaypoint);
|
|
|
|
const segments: LoopedPathSegment[] = [];
|
|
let totalLength = 0;
|
|
|
|
for (let startIndex = 0; startIndex < waypoints.length; startIndex += 1) {
|
|
const endIndex = (startIndex + 1) % waypoints.length;
|
|
const start = waypoints[startIndex];
|
|
const end = waypoints[endIndex];
|
|
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]);
|
|
|
|
if (length <= EPSILON) continue;
|
|
|
|
segments.push({ startIndex, endIndex, startDistance: totalLength, length });
|
|
totalLength += length;
|
|
}
|
|
|
|
if (totalLength <= EPSILON) {
|
|
throw new Error("A looped mob path requires at least two distinct waypoints.");
|
|
}
|
|
|
|
return { waypoints, segments, totalLength };
|
|
}
|
|
|
|
function wrapDistance(distance: number, totalLength: number): number {
|
|
const wrapped = distance % totalLength;
|
|
return wrapped < 0 ? wrapped + totalLength : wrapped;
|
|
}
|
|
|
|
/**
|
|
* Samples a closed path into caller-owned tuples. Returns the sampled segment index.
|
|
* Supplying the output tuples avoids transient Vector3 or array allocations in useFrame.
|
|
*/
|
|
export function sampleLoopedPath(
|
|
path: LoopedPath,
|
|
distance: number,
|
|
outPosition: MutableMobVector3,
|
|
outForward: MutableMobVector3,
|
|
): number {
|
|
const loopDistance = wrapDistance(distance, path.totalLength);
|
|
let segmentIndex = path.segments.length - 1;
|
|
|
|
for (let index = 0; index < path.segments.length; index += 1) {
|
|
const segment = path.segments[index];
|
|
if (loopDistance < segment.startDistance + segment.length) {
|
|
segmentIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const segment = path.segments[segmentIndex];
|
|
const start = path.waypoints[segment.startIndex];
|
|
const end = path.waypoints[segment.endIndex];
|
|
const segmentDistance = loopDistance - segment.startDistance;
|
|
const alpha = segmentDistance / segment.length;
|
|
|
|
outPosition[0] = start[0] + (end[0] - start[0]) * alpha;
|
|
outPosition[1] = start[1] + (end[1] - start[1]) * alpha;
|
|
outPosition[2] = start[2] + (end[2] - start[2]) * alpha;
|
|
|
|
outForward[0] = (end[0] - start[0]) / segment.length;
|
|
outForward[1] = (end[1] - start[1]) / segment.length;
|
|
outForward[2] = (end[2] - start[2]) / segment.length;
|
|
|
|
return segmentIndex;
|
|
}
|
|
|
|
/** Samples a leader or follower, keeping trailing members on the path around corners. */
|
|
export function samplePackMember(
|
|
path: LoopedPath,
|
|
leaderDistance: number,
|
|
formationOffset: PackFormationOffset,
|
|
outPosition: MutableMobVector3,
|
|
outForward: MutableMobVector3,
|
|
): void {
|
|
const [lateral, vertical, trailing] = formationOffset;
|
|
sampleLoopedPath(path, leaderDistance - trailing, outPosition, outForward);
|
|
|
|
const horizontalLength = Math.hypot(outForward[0], outForward[2]);
|
|
const rightX = horizontalLength > EPSILON ? outForward[2] / horizontalLength : 1;
|
|
const rightZ = horizontalLength > EPSILON ? -outForward[0] / horizontalLength : 0;
|
|
|
|
outPosition[0] += rightX * lateral;
|
|
outPosition[1] += vertical;
|
|
outPosition[2] += rightZ * lateral;
|
|
}
|
|
|
|
/** Samples every member into stable caller-owned buffers for render and proximity checks. */
|
|
export function samplePackFormation(
|
|
path: LoopedPath,
|
|
leaderDistance: number,
|
|
members: readonly MobPackMemberDefinition[],
|
|
outPositions: readonly MutableMobVector3[],
|
|
outForwards: readonly MutableMobVector3[],
|
|
): void {
|
|
if (outPositions.length < members.length || outForwards.length < members.length) {
|
|
throw new Error("Pack formation output buffers must include every member.");
|
|
}
|
|
|
|
for (let index = 0; index < members.length; index += 1) {
|
|
samplePackMember(
|
|
path,
|
|
leaderDistance,
|
|
members[index].formationOffset,
|
|
outPositions[index],
|
|
outForwards[index],
|
|
);
|
|
}
|
|
}
|
|
|
|
export const WAILING_CAVERNS_ENTITIES = {
|
|
"deviate-guardian": {
|
|
id: "deviate-guardian",
|
|
kind: "mob",
|
|
name: "Deviate Guardian",
|
|
visual: {
|
|
primaryColor: "#356f4f",
|
|
accentColor: "#8fcf70",
|
|
scale: 1,
|
|
archetype: "raptor",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-guardian-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.025,
|
|
labelHeight: 3.6,
|
|
},
|
|
},
|
|
},
|
|
"druid-of-the-fang": {
|
|
id: "druid-of-the-fang",
|
|
kind: "mob",
|
|
name: "Druid of the Fang",
|
|
visual: {
|
|
primaryColor: "#6b4f86",
|
|
accentColor: "#b9d46a",
|
|
scale: 1,
|
|
archetype: "humanoid",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/druid-of-the-fang-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.015,
|
|
labelHeight: 3.15,
|
|
},
|
|
},
|
|
},
|
|
"deviate-ravager": {
|
|
id: "deviate-ravager",
|
|
kind: "mob",
|
|
name: "Deviate Ravager",
|
|
visual: {
|
|
primaryColor: "#8a472f",
|
|
accentColor: "#e0a45b",
|
|
scale: 1.08,
|
|
archetype: "raptor",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-ravager-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.02,
|
|
labelHeight: 3.2,
|
|
},
|
|
},
|
|
},
|
|
"deviate-crocolisk": {
|
|
id: "deviate-crocolisk",
|
|
kind: "mob",
|
|
name: "Deviate Crocolisk",
|
|
visual: {
|
|
primaryColor: "#375f43",
|
|
accentColor: "#8fb968",
|
|
scale: 1.15,
|
|
archetype: "crocolisk",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-crocolisk-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: -0.005,
|
|
labelHeight: 1.35,
|
|
},
|
|
},
|
|
},
|
|
"evolving-ectoplasm": {
|
|
id: "evolving-ectoplasm",
|
|
kind: "mob",
|
|
name: "Evolving Ectoplasm",
|
|
visual: {
|
|
primaryColor: "#467d70",
|
|
accentColor: "#8fe2c2",
|
|
scale: 0.92,
|
|
archetype: "ooze",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/evolving-ectoplasm-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.607,
|
|
labelHeight: 2.45,
|
|
},
|
|
},
|
|
},
|
|
"deviate-shambler": {
|
|
id: "deviate-shambler",
|
|
kind: "mob",
|
|
name: "Deviate Shambler",
|
|
visual: {
|
|
primaryColor: "#4b6331",
|
|
accentColor: "#9ec96a",
|
|
scale: 1.18,
|
|
archetype: "plant",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-shambler-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.116,
|
|
labelHeight: 3.8,
|
|
},
|
|
},
|
|
},
|
|
"deviate-lasher": {
|
|
id: "deviate-lasher",
|
|
kind: "mob",
|
|
name: "Deviate Lasher",
|
|
visual: {
|
|
primaryColor: "#705329",
|
|
accentColor: "#d29a4b",
|
|
scale: 0.78,
|
|
archetype: "plant",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-lasher-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.019,
|
|
labelHeight: 1.65,
|
|
},
|
|
},
|
|
},
|
|
"deviate-faerie-dragon": {
|
|
id: "deviate-faerie-dragon",
|
|
kind: "boss",
|
|
name: "Deviate Faerie Dragon",
|
|
title: "Rare",
|
|
visual: {
|
|
primaryColor: "#5562a1",
|
|
accentColor: "#d4a8ef",
|
|
scale: 1.05,
|
|
archetype: "winged",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/deviate-faerie-dragon-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.95,
|
|
labelHeight: 5.25,
|
|
markerRadius: 1.35,
|
|
},
|
|
},
|
|
},
|
|
"disciple-of-naralex": {
|
|
id: "disciple-of-naralex",
|
|
kind: "mob",
|
|
name: "Disciple of Naralex",
|
|
visual: {
|
|
primaryColor: "#526d3d",
|
|
accentColor: "#b9d67a",
|
|
scale: 1.05,
|
|
archetype: "humanoid",
|
|
},
|
|
},
|
|
"nightmare-ectoplasm": {
|
|
id: "nightmare-ectoplasm",
|
|
kind: "mob",
|
|
name: "Nightmare Ectoplasm",
|
|
visual: {
|
|
primaryColor: "#422c62",
|
|
accentColor: "#b879e5",
|
|
scale: 1,
|
|
archetype: "ooze",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/evolving-ectoplasm-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.607,
|
|
labelHeight: 2.45,
|
|
},
|
|
},
|
|
},
|
|
"deviate-viper": {
|
|
id: "deviate-viper",
|
|
kind: "mob",
|
|
name: "Deviate Viper",
|
|
visual: {
|
|
primaryColor: "#365d39",
|
|
accentColor: "#91c95c",
|
|
scale: 0.78,
|
|
archetype: "serpent",
|
|
},
|
|
},
|
|
"deviate-adder": {
|
|
id: "deviate-adder",
|
|
kind: "mob",
|
|
name: "Deviate Adder",
|
|
visual: {
|
|
primaryColor: "#56482c",
|
|
accentColor: "#c9aa58",
|
|
scale: 0.72,
|
|
archetype: "serpent",
|
|
},
|
|
},
|
|
"lady-anacondra": {
|
|
id: "lady-anacondra",
|
|
kind: "boss",
|
|
name: "Lady Anacondra",
|
|
title: "Fanglord",
|
|
visual: {
|
|
primaryColor: "#5b3f78",
|
|
accentColor: "#d5ee72",
|
|
scale: 1.42,
|
|
archetype: "humanoid",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/lady-anacondra-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.015,
|
|
labelHeight: 4.15,
|
|
markerRadius: 1.55,
|
|
},
|
|
},
|
|
},
|
|
kresh: {
|
|
id: "kresh",
|
|
kind: "boss",
|
|
name: "Kresh",
|
|
title: "Ancient Turtle",
|
|
visual: {
|
|
primaryColor: "#315c58",
|
|
accentColor: "#6fd1ad",
|
|
scale: 1.55,
|
|
archetype: "turtle",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/kresh-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.016,
|
|
labelHeight: 3.05,
|
|
markerRadius: 2.45,
|
|
},
|
|
},
|
|
},
|
|
"lord-cobrahn": {
|
|
id: "lord-cobrahn",
|
|
kind: "boss",
|
|
name: "Lord Cobrahn",
|
|
title: "Fanglord",
|
|
visual: {
|
|
primaryColor: "#465c30",
|
|
accentColor: "#d1d86b",
|
|
scale: 1.46,
|
|
archetype: "humanoid",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/lord-cobrahn-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.015,
|
|
labelHeight: 4.3,
|
|
markerRadius: 1.6,
|
|
},
|
|
},
|
|
},
|
|
"lord-pythas": {
|
|
id: "lord-pythas",
|
|
kind: "boss",
|
|
name: "Lord Pythas",
|
|
title: "Fanglord",
|
|
visual: {
|
|
primaryColor: "#355f64",
|
|
accentColor: "#76d5bf",
|
|
scale: 1.46,
|
|
archetype: "humanoid",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/lord-pythas-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.015,
|
|
labelHeight: 4.3,
|
|
markerRadius: 1.6,
|
|
},
|
|
},
|
|
},
|
|
skum: {
|
|
id: "skum",
|
|
kind: "boss",
|
|
name: "Skum",
|
|
title: "Thunder Lizard",
|
|
visual: {
|
|
primaryColor: "#745027",
|
|
accentColor: "#f0bd52",
|
|
scale: 1.58,
|
|
archetype: "lizard",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/skum-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.013,
|
|
labelHeight: 6.65,
|
|
markerRadius: 4.2,
|
|
},
|
|
},
|
|
},
|
|
"lord-serpentis": {
|
|
id: "lord-serpentis",
|
|
kind: "boss",
|
|
name: "Lord Serpentis",
|
|
title: "Fanglord",
|
|
visual: {
|
|
primaryColor: "#304d39",
|
|
accentColor: "#9edb63",
|
|
scale: 1.5,
|
|
archetype: "humanoid",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/lord-serpentis-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.015,
|
|
labelHeight: 4.4,
|
|
markerRadius: 1.65,
|
|
},
|
|
},
|
|
},
|
|
"verdan-the-everliving": {
|
|
id: "verdan-the-everliving",
|
|
kind: "boss",
|
|
name: "Verdan the Everliving",
|
|
title: "Ancient Guardian",
|
|
visual: {
|
|
primaryColor: "#315f35",
|
|
accentColor: "#78e17f",
|
|
scale: 1.75,
|
|
archetype: "plant",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/verdan-the-everliving-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.523,
|
|
labelHeight: 15.85,
|
|
markerRadius: 5.3,
|
|
},
|
|
},
|
|
},
|
|
"mutanus-the-devourer": {
|
|
id: "mutanus-the-devourer",
|
|
kind: "boss",
|
|
name: "Mutanus the Devourer",
|
|
title: "Nightmare Incarnate",
|
|
visual: {
|
|
primaryColor: "#285d55",
|
|
accentColor: "#63e4c2",
|
|
scale: 1.72,
|
|
archetype: "murloc",
|
|
model: {
|
|
url: "/assets/creatures/wailing-caverns/mutanus-the-devourer-animated.glb",
|
|
rotationY: -Math.PI / 2,
|
|
groundOffset: 0.022,
|
|
labelHeight: 6.15,
|
|
markerRadius: 2.7,
|
|
},
|
|
},
|
|
},
|
|
} as const satisfies PopulationDefinitionMap;
|
|
|
|
export const WAILING_CAVERNS_ROAMING_PACKS: readonly MobPackDefinition[] = [
|
|
{
|
|
id: "crocolisk-patrol",
|
|
name: "Deviate Crocolisk Patrol",
|
|
speed: 1.2,
|
|
pathId: 261970,
|
|
formationSource: "database-solo",
|
|
waypoints: [
|
|
[143.996, -106.276, 68.703],
|
|
[99.107, -106.211, 68.703],
|
|
[61.185, -106.161, 72.827],
|
|
[99.107, -106.211, 68.703],
|
|
],
|
|
members: [
|
|
{ id: "crocolisk-leader", entityId: "deviate-crocolisk", role: "leader", formationOffset: [0, 0, 0] },
|
|
],
|
|
},
|
|
{
|
|
id: "ectoplasm-ravager-patrol",
|
|
name: "Ectoplasm Ravager Pack",
|
|
speed: 1.35,
|
|
pathId: 262290,
|
|
formationSource: "inferred-followers",
|
|
waypoints: [
|
|
[34.646, -96.066, 211.374],
|
|
[65.58, -93.561, 204.714],
|
|
[81.57, -93.455, 223.608],
|
|
[65.58, -93.561, 204.714],
|
|
[34.646, -96.066, 211.374],
|
|
[36.726, -93.306, 249.309],
|
|
[30.924, -95.137, 227.555],
|
|
],
|
|
members: [
|
|
{ id: "ectoplasm-leader", entityId: "evolving-ectoplasm", role: "leader", formationOffset: [0, 0, 0] },
|
|
{ id: "ectoplasm-ravager", entityId: "deviate-ravager", role: "follower", formationOffset: [-1.55, 0, 2.1] },
|
|
{ id: "ectoplasm-guardian", entityId: "deviate-guardian", role: "follower", formationOffset: [1.55, 0, 2.7] },
|
|
],
|
|
},
|
|
{
|
|
id: "fang-ravager-patrol",
|
|
name: "Fang Ravager Pack",
|
|
speed: 1.55,
|
|
pathId: 262390,
|
|
formationSource: "inferred-followers",
|
|
waypoints: [
|
|
[50.911, -92.844, 263.772],
|
|
[40.989, -92.442, 280.014],
|
|
[62.635, -90.968, 288.42],
|
|
[64.729, -92.844, 265.895],
|
|
],
|
|
members: [
|
|
{ id: "fang-leader", entityId: "druid-of-the-fang", role: "leader", formationOffset: [0, 0, 0] },
|
|
{ id: "fang-ravager", entityId: "deviate-ravager", role: "follower", formationOffset: [-1.65, 0, 2.2] },
|
|
{ id: "fang-adept", entityId: "druid-of-the-fang", role: "follower", formationOffset: [1.65, 0, 2.8] },
|
|
],
|
|
},
|
|
{
|
|
id: "kresh-patrol",
|
|
name: "Kresh's Pool Route",
|
|
speed: 0.9,
|
|
pathId: 273680,
|
|
formationSource: "database-solo",
|
|
waypoints: [
|
|
[64.408, -106.699, 319.084],
|
|
[33.344, -106.417, 299.292],
|
|
[3.641, -106.189, 229.268],
|
|
[16.936, -106.162, 143.141],
|
|
[55.788, -106.162, 93.616],
|
|
[17.62, -106.162, 142.151],
|
|
[3.641, -106.189, 229.268],
|
|
[33.344, -106.417, 299.292],
|
|
],
|
|
members: [
|
|
{ id: "kresh-roaming", entityId: "kresh", role: "leader", formationOffset: [0, 0, 0] },
|
|
],
|
|
},
|
|
{
|
|
id: "shambler-druid-patrol",
|
|
name: "Shambler Druid Pack",
|
|
speed: 1.25,
|
|
pathId: 381160,
|
|
formationSource: "inferred-followers",
|
|
waypoints: [
|
|
[218.221, -82.34, 51.42],
|
|
[198.702, -82.477, 69.771],
|
|
[180.173, -79.124, 87.059],
|
|
[164.926, -75.992, 82.552],
|
|
[180.173, -79.124, 87.059],
|
|
[198.702, -82.477, 69.771],
|
|
],
|
|
members: [
|
|
{ id: "shambler-leader", entityId: "deviate-shambler", role: "leader", formationOffset: [0, 0, 0] },
|
|
{ id: "shambler-follower", entityId: "deviate-shambler", role: "follower", formationOffset: [-1.7, 0, 2.1] },
|
|
{ id: "shambler-druid", entityId: "druid-of-the-fang", role: "follower", formationOffset: [1.7, 0, 2.8] },
|
|
],
|
|
},
|
|
{
|
|
id: "lower-shambler-patrol",
|
|
name: "Lower Cavern Shambler Pack",
|
|
speed: 1.3,
|
|
pathId: 859630,
|
|
formationSource: "inferred-followers",
|
|
waypoints: [
|
|
[-22.765, -75.547, -150.782],
|
|
[1.649, -76.225, -136.536],
|
|
[32.186, -72.013, -123.083],
|
|
[1.649, -76.225, -136.536],
|
|
],
|
|
members: [
|
|
{ id: "lower-shambler-leader", entityId: "deviate-shambler", role: "leader", formationOffset: [0, 0, 0] },
|
|
{ id: "lower-shambler-follower", entityId: "deviate-shambler", role: "follower", formationOffset: [0, 0, 2.2] },
|
|
{ id: "lower-lasher", entityId: "deviate-lasher", role: "follower", formationOffset: [0, 0, 4.4] },
|
|
],
|
|
},
|
|
] as const;
|
|
|
|
export const WAILING_CAVERNS_BOSS_SPAWNS: readonly StaticMobSpawnDefinition[] = [
|
|
{ id: "faerie-dragon-spawn", entityId: "deviate-faerie-dragon", position: [-0.008, -67.031, -55.934], yaw: 2.446 },
|
|
{ id: "lady-anacondra-spawn", entityId: "lady-anacondra", position: [-15.34, -87.7, 297.18], yaw: 1.239 },
|
|
{ id: "lord-cobrahn-spawn", entityId: "lord-cobrahn", position: [151.14, -72.63, 414.37], yaw: -1.292 },
|
|
{ id: "lord-pythas-spawn", entityId: "lord-pythas", position: [-36.81, -79.5, -241.06], yaw: 1.693 },
|
|
{ id: "skum-spawn", entityId: "skum", position: [285.58, -69.19, -312.97], yaw: 1.816 },
|
|
{ id: "lord-serpentis-spawn", entityId: "lord-serpentis", position: [120.16, -28.58, -24.62], yaw: 1.26 },
|
|
{ id: "verdan-spawn", entityId: "verdan-the-everliving", position: [81.86, -30.99, 32.26], yaw: 2.147 },
|
|
{ id: "mutanus-spawn", entityId: "mutanus-the-devourer", position: [-151.27, -102.82, 252.26], yaw: 1.809 },
|
|
] as const;
|
|
|
|
/**
|
|
* Small entrance encounter used as the population's visual smoke test. These
|
|
* points were floor-snapped and line-of-sight checked against the shipped cave
|
|
* collider; the original database has no authored formation for this group.
|
|
*/
|
|
export const WAILING_CAVERNS_ENTRANCE_SPAWNS: readonly StaticMobSpawnDefinition[] = [
|
|
{ id: "entrance-ravager", entityId: "deviate-ravager", position: [153.45, -75.42, 126.12], yaw: 1.118 },
|
|
{ id: "entrance-druid", entityId: "druid-of-the-fang", position: [150.9, -75.81, 126.77], yaw: 1.118 },
|
|
{ id: "entrance-guardian", entityId: "deviate-guardian", position: [148.36, -76.37, 127.42], yaw: 1.118 },
|
|
] as const;
|
|
|
|
export const WAILING_CAVERNS_STATIC_SPAWNS: readonly StaticMobSpawnDefinition[] = [
|
|
...WAILING_CAVERNS_ENTRANCE_SPAWNS,
|
|
...WAILING_CAVERNS_BOSS_SPAWNS,
|
|
] as const;
|