updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen

This commit is contained in:
phenom committed 2026-08-16 11:16:51 -04:00
1 parent b5c9bce1aa
commit c00f3211ea
39 files changed
+2037 -208

No files matched your search

+27
View File
@@ -5,6 +5,7 @@ import {
parseWoWAnimationName,
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
selectWoWAnimationVariation,
} from "./combatAnimation";
@@ -20,6 +21,10 @@ const clips = [
{ name: "Death (ID 1 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 1)" },
{ name: "JumpStart (ID 37 variation 0)" },
{ name: "Fall (ID 40 variation 0)" },
{ name: "JumpEnd (ID 39 variation 0)" },
{ name: "JumpLandRun (ID 133 variation 0)" },
];
describe("WoW character animation selection", () => {
@@ -45,6 +50,19 @@ describe("WoW character animation selection", () => {
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
});
it("selects native rising, falling, and landing locomotion", () => {
expect(selectCharacterVerticalClip(clips, "rising", false)).toMatchObject({
clip: clips[11],
loop: false,
});
expect(selectCharacterVerticalClip(clips, "falling", false)).toMatchObject({
clip: clips[12],
loop: true,
});
expect(selectCharacterVerticalClip(clips, "landing", false)?.clip).toBe(clips[13]);
expect(selectCharacterVerticalClip(clips, "landing", true)?.clip).toBe(clips[14]);
});
it("maps hard-cast lifecycle events to ready and release clips", () => {
expect(selectCharacterEventClip(clips, "priest", {
revision: 1,
@@ -79,6 +97,9 @@ describe("WoW character animation selection", () => {
{ name: "Action - buff01" },
{ name: "Wound - hurt" },
{ name: "Death - death" },
{ name: "Action - jump_up" },
{ name: "Action - jump_down" },
{ name: "Action - jump_end_run" },
];
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
@@ -114,5 +135,11 @@ describe("WoW character animation selection", () => {
revision: 6,
kind: "death",
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
expect(selectCharacterVerticalClip(runewakerClips, "rising", false)?.clip)
.toBe(runewakerClips[10]);
expect(selectCharacterVerticalClip(runewakerClips, "falling", false)?.clip)
.toBe(runewakerClips[11]);
expect(selectCharacterVerticalClip(runewakerClips, "landing", true)?.clip)
.toBe(runewakerClips[12]);
});
});
+30
View File
@@ -1,6 +1,7 @@
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { abilityAnimationById } from "./abilityAnimationLookup";
import type { CharacterVerticalMotion } from "./playerJump";
export type CharacterCombatAnimationKind =
| "ability-start"
@@ -112,6 +113,35 @@ export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
return selectWoWAnimationVariation(clips, ids);
}
export function selectCharacterVerticalClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
motion: Exclude<CharacterVerticalMotion, "grounded">,
moving: boolean,
): CharacterClipSelection<TClip> | null {
const ids = motion === "rising"
? [37, 38, 40]
: motion === "falling"
? [40, 38, 37]
: moving
? [133, 39, 38]
: [39, 133, 38];
const runewakerNames = motion === "rising"
? ["Action - jump_up", "Action - jump_loop"]
: motion === "falling"
? ["Action - jump_down", "Action - jump_loop"]
: moving
? ["Action - jump_end_run", "Action - jump_end_back"]
: ["Action - jump_end_back", "Action - jump_end_run"];
const clip = selectWoWAnimationVariation(clips, ids)
?? selectRunewakerClipVariation(clips, runewakerNames);
return clip ? {
clip,
loop: motion === "falling",
terminal: false,
native: true,
} : null;
}
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
if (ability.range.max <= 4) return true;
+12 -1
View File
@@ -38,7 +38,13 @@ describe("named aura combat runtime", () => {
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
expiresAt: now + 1_800_000,
definition: { disposition: "buff", dispelCategory: "magic" },
definition: {
disposition: "buff",
dispelCategory: "magic",
icon: useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-fortitude"
))?.icon,
},
});
useCombatStore.getState().tick(0, now + 1_800_000);
@@ -57,6 +63,11 @@ describe("named aura combat runtime", () => {
.flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield"
))?.icon,
);
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
const afterAbsorb = useCombatStore.getState().auras
+4
View File
@@ -133,6 +133,10 @@ export interface AuraDefinition {
readonly id: string;
readonly name: string;
readonly disposition: AuraDisposition;
/** Spell artwork used by every generic aura HUD surface. */
readonly icon?: string;
/** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */
readonly hideFromAuraStrip?: boolean;
/** Null means the aura persists until explicitly removed. */
readonly durationMs: number | null;
readonly maxStacks?: number;
+19 -8
View File
@@ -83,6 +83,7 @@ import {
resolveResourceValue as resolveAuraResourceValue,
resolveStatValue as resolveAuraStatValue,
type ActiveAura,
type AuraDefinition,
type ProcEvent,
type TriggeredAuraProc,
} from "./combatAuras";
@@ -1464,6 +1465,18 @@ function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveA
return auras.filter((aura) => aura.targetId === entityId);
}
function auraDefinitionForAbility(
definition: AuraDefinition,
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
return {
...definition,
icon: definition.icon ?? ability.icon,
durationMs,
};
}
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
const stat = (value: number, ...names: string[]) => names.reduce(
(current, name) => resolveAuraStatValue(current, auras, name),
@@ -1534,7 +1547,7 @@ function syncPassiveAuras(
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
return ability.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.map((effect) => effect.aura);
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
});
const passiveIds = new Set(definitions.map((definition) => definition.id));
let next = current.filter((aura) => (
@@ -2125,7 +2138,7 @@ function executeTriggeredSpellInWork(
? sourceId
: targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, {
definition: effect.aura,
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
@@ -3156,12 +3169,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const auraDefinition = effect.aura.durationMs === null
? effect.aura
: {
...effect.aura,
durationMs: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers),
};
const durationMs = effect.aura.durationMs === null
? null
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.recipient === "caster"
? [PLAYER_AURA_ENTITY_ID]
: ability.target === "hostile"
+7 -7
View File
@@ -1,7 +1,8 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
export type DungeonCategoryId = ContentCategoryId;
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
export interface DungeonCategory {
readonly id: DungeonCategoryId;
@@ -10,11 +11,10 @@ export interface DungeonCategory {
}
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
...CONTENT_CATEGORIES.map((category) => ({
id: category.id,
label: category.label,
fullName: category.name,
})),
...DUNGEON_CATEGORY_IDS.map((id) => {
const category = contentCategoryById(id);
return { id, label: category.label, fullName: category.name };
}),
]);
export interface DungeonCatalogEntry {
+30 -1
View File
@@ -10,6 +10,8 @@ import {
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
controllerCommandForToken,
createMouseLookBuffer,
@@ -19,6 +21,7 @@ import {
isDesktopCameraLookPointer,
keyboardAxesForKeys,
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
type InputActions,
} from "./inputManager";
@@ -54,6 +57,7 @@ function inputActionSpies(blocked = false): InputActions {
afterEach(() => {
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
@@ -89,7 +93,7 @@ describe("gameplay input bindings", () => {
it("maps Thor controls without stealing Select from display routing", () => {
expect(controllerCommandForToken("Button9")).toBe("pause");
expect(controllerCommandForToken("Button11")).toBe("target-next");
expect(controllerCommandForToken("Button10")).toBe("target-clear");
expect(controllerCommandForToken("Button10")).toBe("jump");
expect(controllerCommandForToken("Button12")).toBe("party-previous");
expect(controllerCommandForToken("Button13")).toBe("party-next");
expect(controllerCommandForToken("Button4")).toBeNull();
@@ -145,6 +149,31 @@ describe("gameplay input bindings", () => {
expect(keyboardCommandForCode("Digit8")).toBeNull();
});
it("queues Space and L3 jump presses once and blocks them behind menus", () => {
expect(keyboardCommandForCode("Space")).toBe("jump");
queueJumpRequest(25);
expect(consumeJumpRequest()).toBe(25);
expect(consumeJumpRequest()).toBeNull();
stubInputEventTargets();
const nextActions = inputActionSpies();
const blocked = nextActions.gameplayActionsBlocked as ReturnType<typeof vi.fn>;
const dispose = installInput(nextActions);
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
emitControllerToken({ token: "Button10", repeat: true, pressed: true });
expect(consumeJumpRequest()).toBeNull();
blocked.mockReturnValue(true);
emitControllerToken({ token: "Button10", repeat: false, pressed: false });
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).toBeNull();
dispose();
});
it("uses WASD for movement and arrow keys for camera look", () => {
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
moveX: 1,
+21 -1
View File
@@ -42,6 +42,7 @@ export interface InputActions {
const keys = new Set<string>();
let actions: InputActions | null = null;
let jumpRequestAt: number | null = null;
// Pointer-lock implementations can emit a short burst of stale, very large
// movement events while the cursor is being recentered. Some Android WebViews have also been
@@ -69,6 +70,20 @@ function monotonicNow(): number {
return Date.now();
}
export function queueJumpRequest(nowMs = monotonicNow()): void {
if (Number.isFinite(nowMs)) jumpRequestAt = nowMs;
}
export function consumeJumpRequest(): number | null {
const requestedAt = jumpRequestAt;
jumpRequestAt = null;
return requestedAt;
}
export function clearJumpRequest(): void {
jumpRequestAt = null;
}
export function createMouseLookBuffer(now: () => number = monotonicNow) {
let x = 0;
let y = 0;
@@ -161,12 +176,13 @@ export type GameplayInputCommand =
| "party-defend"
| "party-stop"
| "party-recall"
| "jump"
| "target-clear";
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
if (token === "Button11") return "target-next";
if (token === "Button10") return "target-clear";
if (token === "Button10") return "jump";
if (token === "Button12") return "party-previous";
if (token === "Button13") return "party-next";
return null;
@@ -183,6 +199,7 @@ export function keyboardCommandForCode(code: string, shiftKey = false): Gameplay
if (code === "F2") return "party-defend";
if (code === "F3") return "party-stop";
if (code === "F4") return "party-recall";
if (code === "Space") return "jump";
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
return null;
}
@@ -202,6 +219,7 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
else if (command === "party-defend") nextActions.setPartyCommand("defend");
else if (command === "party-stop") nextActions.setPartyCommand("stop");
else if (command === "party-recall") nextActions.recallParty();
else if (command === "jump") queueJumpRequest();
else if (command === "target-clear") nextActions.clearTarget();
}
@@ -303,6 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
return;
}
if (nextActions.gameplayActionsBlocked()) return;
if (command === "jump") event.preventDefault();
if (event.code === "Tab") event.preventDefault();
runCommand(command, nextActions);
};
@@ -339,6 +358,7 @@ export function installInput(nextActions: InputActions): () => void {
keyboardModifiers.clear();
controllerL1 = false;
controllerL2 = false;
clearJumpRequest();
mouseLookDragActive = false;
previousMousePosition = null;
nextActions.setActionLayer("primary");
+98
View File
@@ -8,10 +8,17 @@ import {
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
} from "./partyMovement";
describe("party world movement", () => {
@@ -38,6 +45,89 @@ describe("party world movement", () => {
expect(out).toEqual([12, 2, 17]);
});
it("builds stable and distinct bounded movement personalities", () => {
const first = partyMovementProfile("member-a", 0);
const repeat = partyMovementProfile("member-a", 0);
const second = partyMovementProfile("member-b", 1);
expect(first).toEqual(repeat);
expect(second).not.toEqual(first);
for (const profile of [first, second]) {
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
}
});
it("places natural followers inside personal spacing and movement bands", () => {
const profile = partyMovementProfile("member-c", 2);
const out: [number, number, number] = [0, 0, 0];
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
expect(out[1]).toBe(2);
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
});
it("keeps personalized combat bands inside role-safe bounds", () => {
const profiles = [
partyMovementProfile("ranged-a", 1),
partyMovementProfile("ranged-b", 2),
];
for (const profile of profiles) {
const ranged = personalizedPartyCombatRangeBand({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
}, profile);
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
expect(ranged.preferred).toBeLessThan(ranged.maximum);
const melee = personalizedPartyCombatRangeBand({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
}, profile);
expect(melee.minimum).toBe(0);
expect(melee.maximum).toBeLessThanOrEqual(3.55);
expect(melee.preferred).toBeLessThan(melee.maximum);
}
});
it("uses a stable combat bearing and separates overlapping allies", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0],
[0, 0, 0],
true,
{ minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2,
false,
out,
)).toBe("approach");
expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0];
expect(partySeparationGoal(
[1, 0, 1],
[2, 0, 2],
[[1, 0, 1]],
Math.PI / 2,
separated,
)).toBe(true);
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
});
it("recalls only distant allies that should be moving and stopped making progress", () => {
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
@@ -168,6 +258,14 @@ describe("party world movement", () => {
expect(out).toEqual([20, 3, 4]);
});
it("synchronizes direct followers to the matching safe trail point", () => {
const trail = createPartyBreadcrumbTrail();
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
const cursor = createPartyBreadcrumbCursor();
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
});
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
const trail = createPartyBreadcrumbTrail();
const cursor = createPartyBreadcrumbCursor();
+221 -1
View File
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null;
}
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
export interface PartyMovementProfile {
readonly followDistanceJitter: number;
readonly lateralOffset: number;
readonly comfortSlack: number;
readonly speedMultiplier: number;
readonly swayAmplitude: number;
readonly swayPeriodMs: number;
readonly swayPhase: number;
readonly combatAngleOffset: number;
readonly combatRangeBias: number;
}
export interface PartyCombatRangeLike {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
function stableHash(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(value: string): number {
return stableHash(value) / 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
/** Stable per-character preferences keep motion reproducible without looking cloned. */
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
const prefix = `${memberId}|${rank}`;
const lateralMagnitude = rank === 0
? hashUnit(`${prefix}|leader-lateral`) * 0.3
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
const lateralSign = rank === 0
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
: rank % 2 === 1 ? -1 : 1;
const angleSlots = [0, -35, 35, -50, 50];
const angleDegrees = clamp(
(angleSlots[rank % angleSlots.length] ?? 0)
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
-50,
50,
);
return {
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
lateralOffset: lateralSign * lateralMagnitude,
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
combatAngleOffset: angleDegrees * Math.PI / 180,
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
};
}
export const PARTY_STUCK_RECALL_DISTANCE = 12;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
return { breadcrumbId: null };
}
export function synchronizePartyBreadcrumbCursor(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
trailingDistance: number,
): void {
const latest = trail.points[trail.points.length - 1];
if (!latest) {
cursor.breadcrumbId = null;
return;
}
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
let target = trail.points[0];
for (const point of trail.points) {
if (point.distance > maximumDistance + EPSILON) break;
target = point;
}
cursor.breadcrumbId = target.id;
}
export function resetPartyBreadcrumbTrail(
trail: PartyBreadcrumbTrail,
position?: PartyWorldPosition,
@@ -271,6 +355,114 @@ export function partyFormationPosition(
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
}
export function partyNaturalFollowPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
baseTrailingDistance: number,
profile: PartyMovementProfile,
nowMs: number,
out: MutablePartyWorldPosition,
): void {
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
const sway = Math.sin(
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
) * profile.swayAmplitude;
partyFormationPosition(
anchor,
forwardX,
forwardZ,
profile.lateralOffset + sway,
trailing,
out,
);
}
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
export function partyShouldMoveToComfortTarget(
current: PartyWorldPosition,
target: PartyWorldPosition,
wasMoving: boolean,
comfortSlack: number,
): boolean {
const distance = Math.hypot(
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
);
if (!Number.isFinite(distance)) return false;
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
return distance > (wasMoving ? 0.18 : startDistance);
}
export function personalizedPartyCombatRangeBand(
base: PartyCombatRangeLike,
profile: PartyMovementProfile,
): PartyCombatRangeLike {
if (base.minimum > 0) {
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
return {
minimum,
preferred: clamp(
base.preferred + profile.combatRangeBias * 1.25,
minimum + 0.75,
maximum - 0.5,
),
maximum,
};
}
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
return {
minimum: 0,
preferred: clamp(
base.preferred + profile.combatRangeBias * 0.3,
2.35,
maximum - 0.12,
),
maximum,
};
}
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/**
* Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band.
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
out[2] = target[2] + awayZ / planarLength * preferred;
return "retreat";
}
export function partyPersonalizedCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
rangeBand: PartyCombatRangeLike,
preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition,
): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal(
current,
target,
targetVisible,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
out,
Math.sin(preferredBearing),
Math.cos(preferredBearing),
);
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
PLAYER_GROUND_MIN_NORMAL_Y,
PLAYER_JUMP_BUFFER_MS,
PLAYER_JUMP_COYOTE_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
} from "./playerJump";
describe("player jump timing", () => {
it("accepts only a nearby upward-facing ground hit", () => {
expect(isWalkableGroundHit({
timeOfImpact: 0.9,
normal: { y: PLAYER_GROUND_MIN_NORMAL_Y },
}, 0.95)).toBe(true);
expect(isWalkableGroundHit({ timeOfImpact: 0.96, normal: { y: 1 } }, 0.95)).toBe(false);
expect(isWalkableGroundHit({ timeOfImpact: 0.4, normal: { y: 0.2 } }, 0.95)).toBe(false);
});
it("buffers a press shortly before landing", () => {
const timing = createPlayerJumpTiming();
bufferPlayerJump(timing, 1_000);
expect(updatePlayerJumpTiming(timing, 1_000 + PLAYER_JUMP_BUFFER_MS - 1, true, -2)).toBe(true);
});
it("allows coyote time after leaving a ledge", () => {
const timing = createPlayerJumpTiming(2_000);
bufferPlayerJump(timing, 2_000 + PLAYER_JUMP_COYOTE_MS - 1);
expect(updatePlayerJumpTiming(
timing,
2_000 + PLAYER_JUMP_COYOTE_MS - 1,
false,
-0.2,
)).toBe(true);
});
it("cancels buffered input while gameplay is blocked", () => {
const timing = createPlayerJumpTiming(2_500);
bufferPlayerJump(timing, 2_500);
cancelBufferedPlayerJump(timing);
expect(updatePlayerJumpTiming(timing, 2_500, true, 0)).toBe(false);
});
it("does not double jump until a real landing", () => {
const timing = createPlayerJumpTiming(3_000);
bufferPlayerJump(timing, 3_000);
expect(updatePlayerJumpTiming(timing, 3_000, true, 0)).toBe(true);
bufferPlayerJump(timing, 3_040);
expect(updatePlayerJumpTiming(timing, 3_040, true, 6.2)).toBe(false);
expect(updatePlayerJumpTiming(timing, 3_300, false, -2)).toBe(false);
bufferPlayerJump(timing, 3_550);
expect(updatePlayerJumpTiming(timing, 3_600, true, -1)).toBe(true);
});
it("reports rising, falling, landing, and grounded presentation phases", () => {
expect(characterVerticalMotion(false, 2, 100, 0)).toBe("rising");
expect(characterVerticalMotion(false, -0.1, 100, 0)).toBe("falling");
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded");
});
});
+94
View File
@@ -0,0 +1,94 @@
export const PLAYER_JUMP_VELOCITY = 7.2;
export const PLAYER_JUMP_COYOTE_MS = 100;
export const PLAYER_JUMP_BUFFER_MS = 120;
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
export const PLAYER_LANDING_PRESENTATION_MS = 180;
export const PLAYER_GROUND_PROBE_DISTANCE = 0.14;
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
export interface PlayerJumpTiming {
lastGroundedAtMs: number;
bufferedUntilMs: number;
jumpedAtMs: number;
lockedUntilLanding: boolean;
}
export interface GroundProbeHit {
readonly timeOfImpact: number;
readonly normal: { readonly y: number };
}
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
return {
lastGroundedAtMs: nowMs,
bufferedUntilMs: Number.NEGATIVE_INFINITY,
jumpedAtMs: Number.NEGATIVE_INFINITY,
lockedUntilLanding: false,
};
}
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
if (!Number.isFinite(requestedAtMs)) return;
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
}
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
}
export function isWalkableGroundHit(
hit: GroundProbeHit | null | undefined,
maximumTimeOfImpact: number,
): boolean {
return Boolean(
hit
&& Number.isFinite(hit.timeOfImpact)
&& hit.timeOfImpact >= 0
&& hit.timeOfImpact <= maximumTimeOfImpact
&& Number.isFinite(hit.normal.y)
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
);
}
/**
* Updates the timing latch and consumes a buffered jump exactly once. The
* upward-velocity guard prevents the ground probe from unlocking the latch on
* the first few frames after launch while the capsule is still near the floor.
*/
export function updatePlayerJumpTiming(
timing: PlayerJumpTiming,
nowMs: number,
grounded: boolean,
verticalVelocity: number,
): boolean {
if (!Number.isFinite(nowMs)) return false;
if (grounded && verticalVelocity <= 0.5) {
timing.lastGroundedAtMs = nowMs;
if (
timing.lockedUntilLanding
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
) timing.lockedUntilLanding = false;
}
const buffered = timing.bufferedUntilMs >= nowMs;
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
timing.lockedUntilLanding = true;
timing.jumpedAtMs = nowMs;
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
return true;
}
export function characterVerticalMotion(
grounded: boolean,
verticalVelocity: number,
nowMs: number,
landingUntilMs: number,
): CharacterVerticalMotion {
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
return verticalVelocity > 0.15 ? "rising" : "falling";
}
+2 -2
View File
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
}
}
}
}, 30_000);
}, 60_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(
@@ -305,7 +305,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
poseOnly: 1,
proxy: 0,
});
}, 30_000);
}, 60_000);
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
+31
View File
@@ -57,12 +57,43 @@ describe("game store", () => {
expect(state.areaName).toContain("Entrance");
});
it("keeps a grounded navigation anchor while the physical player is airborne", () => {
useGameStore.getState().updatePlayerSnapshot({
position: [164, -73.66, 132],
yaw: -2,
grounded: true,
});
useGameStore.getState().updatePlayerSnapshot({
position: [166, -72.2, 133],
yaw: -2,
grounded: false,
});
expect(useGameStore.getState()).toMatchObject({
playerPosition: [166, -72.2, 133],
playerNavigationPosition: [164, -73.66, 132],
playerGrounded: false,
});
useGameStore.getState().updatePlayerSnapshot({
position: [167, -73.66, 134],
yaw: -2,
grounded: true,
});
expect(useGameStore.getState()).toMatchObject({
playerNavigationPosition: [167, -73.66, 134],
playerGrounded: true,
});
});
it("reset restores the authoritative transformed entrance", () => {
useGameStore.getState().setActionLayer("tertiary");
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
useGameStore.getState().resetAtEntrance();
const state = useGameStore.getState();
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerNavigationPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerGrounded).toBe(true);
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
expect(state.distanceTravelled).toBe(0);
expect(state.actionLayer).toBe("primary");
+14 -1
View File
@@ -13,6 +13,7 @@ interface PlayerSnapshot {
position: Vector3Tuple;
yaw: number;
distanceDelta?: number;
grounded?: boolean;
}
export interface GameState {
@@ -31,6 +32,8 @@ export interface GameState {
assetStatus: AssetStatus;
assetMessage: string | null;
playerPosition: Vector3Tuple;
playerNavigationPosition: Vector3Tuple;
playerGrounded: boolean;
cameraYaw: number;
areaName: string;
distanceTravelled: number;
@@ -77,6 +80,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: entrance.footPosition,
playerNavigationPosition: entrance.footPosition,
playerGrounded: true,
cameraYaw: entrance.yaw,
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
distanceTravelled: 0,
@@ -106,6 +111,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: activeSpawn.footPosition,
playerNavigationPosition: activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
distanceTravelled: 0,
@@ -175,10 +182,12 @@ export const useGameStore = create<GameState>((set) => ({
if (isStaleLoadingWrite) return state;
return { assetStatus, assetMessage };
}),
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0 }) => set((state) => {
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
playerPosition: position,
playerNavigationPosition: grounded ? position : state.playerNavigationPosition,
playerGrounded: grounded,
cameraYaw: yaw,
areaName: resolveAreaNameForDungeon(definition, position),
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
@@ -193,6 +202,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: state.activeSpawn.footPosition,
playerNavigationPosition: state.activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: state.activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
distanceTravelled: 0,
@@ -213,6 +224,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: activeEntrance.footPosition,
playerNavigationPosition: activeEntrance.footPosition,
playerGrounded: true,
cameraYaw: activeEntrance.yaw,
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
distanceTravelled: 0,
+20
View File
@@ -77,6 +77,26 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
.find((effect) => effect.sourceAuraType === 3);
const renew = byId("wow335-priest-renew");
const renewAura = effectsOfKind(renew, "apply-aura")
.find((effect) => effect.sourceAuraType === 8);
expect(painAura?.aura).toMatchObject({
disposition: "debuff",
hideFromAuraStrip: true,
});
expect(renewAura?.aura).toMatchObject({
disposition: "buff",
hideFromAuraStrip: true,
});
expect(effectsOfKind(pain, "dot")).toHaveLength(1);
expect(effectsOfKind(renew, "hot")).toHaveLength(1);
});
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
+3
View File
@@ -367,6 +367,9 @@ function namedAuraForEffect(
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
? { hideFromAuraStrip: true }
: {}),
durationMs,
maxStacks: 1,
stackBehavior: "refresh",