forked from phenom/healer-man
updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen
This commit is contained in:
1 parent
b5c9bce1aa
commit
c00f3211ea
39 files changed
+2037
-208
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user