Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+106 -2
View File
@@ -17,6 +17,21 @@ export type EnemyDamageSchool =
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
/** World-space warning geometry shared by simulation and Three presentation. */
export interface EnemyAttackTelegraph {
readonly shape: EnemyTelegraphShape;
readonly anchor: EnemyTelegraphAnchor;
/** Minimum warning time before the mechanic resolves. */
readonly warningMs: number;
readonly radius?: number;
readonly length?: number;
readonly width?: number;
readonly angleDegrees?: number;
}
export type EnemyAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "heal"; readonly multiplier: number }
@@ -26,6 +41,11 @@ export type EnemyAbilityEffect =
readonly durationMs: number;
readonly magnitude?: number;
}
| {
readonly kind: "target-aura";
readonly aura: string;
readonly durationMs: number;
}
| {
readonly kind: "control";
readonly mechanic: EnemyControlMechanic;
@@ -83,6 +103,7 @@ export interface EnemyAttackDefinition {
readonly maximumCasts?: number;
readonly damageMultiplier: number;
readonly radius?: number;
readonly telegraph?: EnemyAttackTelegraph;
readonly effect?: EnemyAbilityEffect;
readonly condition?: EnemyAbilityCondition;
readonly source?: EnemyAbilitySource;
@@ -96,6 +117,8 @@ export interface EnemyCombatDefinition {
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
/** Stable special-attack ids available for future encounter loadout randomization. */
readonly mechanicIds?: readonly string[];
readonly attacks: readonly EnemyAttackDefinition[];
}
@@ -125,14 +148,94 @@ function normalizeCooldownRange(
return Object.freeze([Math.round(lower), Math.round(upper)]);
}
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
return !value.effect
|| value.effect.kind === "damage"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura";
}
/**
* Legacy/imported encounters receive safe defaults while authored mechanics can
* override every dimension in the modular mechanic catalog.
*/
export function inferEnemyAttackTelegraph(
value: EnemyAttackDefinition,
): EnemyAttackTelegraph | undefined {
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
const radius = value.radius ?? Math.max(3, value.range * 0.35);
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
return {
shape: "cone",
anchor: "source",
warningMs: 1_650,
length: Math.max(5.5, value.range),
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
return {
shape: "line",
anchor: "source",
warningMs: 1_650,
length: Math.max(7, value.range),
width: Math.max(2, radius * 0.45),
};
}
if (value.delivery === "area" || value.radius !== undefined) {
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
.test(value.name) && value.target !== "self";
return {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1_650,
radius,
};
}
return undefined;
}
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
if (value.shape === "circle") {
return Object.freeze({
shape: value.shape,
anchor: value.anchor,
warningMs,
radius: finiteAtLeast(value.radius, 4, 0.5),
});
}
if (value.shape === "cone") {
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
});
}
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
width: finiteAtLeast(value.width, 3, 0.5),
});
}
export function normalizeEnemyAttack(
value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
? (fallback.castTimeMs ?? 1_500)
: (fallback.castTimeMs ?? 0);
: telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
const castTimeMs = Math.max(
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
telegraph?.warningMs ?? 0,
);
return Object.freeze({
id: value.id.trim() || fallback.id,
name: value.name.trim() || fallback.name,
@@ -143,7 +246,7 @@ export function normalizeEnemyAttack(
target: value.target,
school: value.school,
animation: value.animation,
castTimeMs: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
castTimeMs,
interruptible: value.interruptible ?? fallback.interruptible ?? true,
range: finiteAtLeast(value.range, fallback.range, 0),
cooldownMs,
@@ -160,6 +263,7 @@ export function normalizeEnemyAttack(
...(value.radius !== undefined
? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}),
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
...(value.effect ? { effect: value.effect } : {}),
...(value.condition ? { condition: value.condition } : {}),
...(value.source ? { source: value.source } : {}),