forked from phenom/healer-man
Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -948,9 +948,12 @@ export function abilityAtLevel(
|
||||
learnedTalentRank?: number,
|
||||
): AbilityDefinition {
|
||||
const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank);
|
||||
if (!rank) return ability;
|
||||
const effects = rank?.effects ?? ability.effects;
|
||||
const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target;
|
||||
if (!rank) return target === ability.target ? ability : { ...ability, target };
|
||||
return {
|
||||
...ability,
|
||||
target,
|
||||
dbcSpellId: rank.spellId,
|
||||
description: rank.description || ability.description,
|
||||
castMode: rank.castMode,
|
||||
|
||||
@@ -59,7 +59,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar
|
||||
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
|
||||
...(ability.radius ? { radius: ability.radius } : {}),
|
||||
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
|
||||
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
|
||||
...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arenaCameraPose } from "./arenaCamera";
|
||||
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "./arenaCamera";
|
||||
import { ARENA_STAGES } from "./arenaStages";
|
||||
|
||||
describe("arena camera placement", () => {
|
||||
it("uses a finite overview anchor aimed at the playable center of every arena", () => {
|
||||
describe("arena player camera", () => {
|
||||
it("starts behind each character at the playable floor on all eight maps", () => {
|
||||
for (const stage of ARENA_STAGES) {
|
||||
for (const team of [0, 1] as const) {
|
||||
const { position, target } = arenaCameraPose(stage, team);
|
||||
expect(position).toEqual(stage.spectatorPositions[team]);
|
||||
expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]);
|
||||
expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]);
|
||||
expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]);
|
||||
expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]);
|
||||
expect(target.every(Number.isFinite)).toBe(true);
|
||||
const foot = stage.teamSpawns[team];
|
||||
const { position, target } = arenaCameraPose(foot, arenaSpawnYaw(stage, team));
|
||||
expect(target[0]).toBe(foot[0]);
|
||||
expect(target[2]).toBe(foot[2]);
|
||||
expect(target[1] - foot[1]).toBeCloseTo(1.35);
|
||||
expect(new Vector3(...position).distanceTo(new Vector3(...target))).toBeCloseTo(ARENA_CAMERA_DISTANCE);
|
||||
expect(position[1] - foot[1]).toBeLessThan(4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("follows player translation and responds to orbit input", () => {
|
||||
const first = arenaCameraPose([1, -22, 3], 0);
|
||||
const moved = arenaCameraPose([11, -20, 5], 0);
|
||||
expect(moved.position.map((value, axis) => value - first.position[axis])).toEqual([10, 2, 2]);
|
||||
expect(arenaCameraPose([1, -22, 3], Math.PI / 2).position).not.toEqual(first.position);
|
||||
expect(arenaCameraPose([0, 0, 0], NaN, Infinity).position.every(Number.isFinite)).toBe(true);
|
||||
});
|
||||
|
||||
it("pulls in before a wall and restores full distance in open space", () => {
|
||||
const wall = new Mesh(new BoxGeometry(8, 8, 0.2), new MeshBasicMaterial());
|
||||
wall.position.set(0, 1.5, -2);
|
||||
wall.updateMatrixWorld(true);
|
||||
const target = new Vector3(0, 1.5, 0);
|
||||
const ray = new Raycaster();
|
||||
expect(arenaCameraClearDistance(target, new Vector3(0, 1.5, -6), wall, ray)).toBeCloseTo(1.7);
|
||||
expect(arenaCameraClearDistance(target, new Vector3(6, 1.5, 0), wall, ray)).toBe(6);
|
||||
wall.geometry.dispose();
|
||||
wall.material.dispose();
|
||||
});
|
||||
});
|
||||
+47
-7
@@ -1,16 +1,56 @@
|
||||
import type { ArenaStageManifest } from "./arenaTypes";
|
||||
import { Raycaster, Vector3, type Object3D } from "three";
|
||||
import { clampCameraPitch, DEFAULT_CAMERA_PITCH } from "./inputMath";
|
||||
import type { ArenaStageManifest, Vec3Tuple } from "./arenaTypes";
|
||||
|
||||
export const ARENA_CAMERA_DISTANCE = 5.8;
|
||||
export const ARENA_CAMERA_FOCUS_HEIGHT = 1.35;
|
||||
|
||||
export interface ArenaCameraPose {
|
||||
readonly position: [number, number, number];
|
||||
readonly target: [number, number, number];
|
||||
}
|
||||
|
||||
export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose {
|
||||
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
|
||||
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
|
||||
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1]));
|
||||
export function arenaSpawnYaw(stage: ArenaStageManifest, team: 0 | 1): number {
|
||||
const from = stage.teamSpawns[team];
|
||||
const to = stage.preparationGates[team];
|
||||
return Math.atan2(to[0] - from[0], to[2] - from[2]);
|
||||
}
|
||||
|
||||
/** Follow the controlled character at their elevation, including underground arenas. */
|
||||
export function arenaCameraPose(
|
||||
footPosition: Vec3Tuple,
|
||||
yaw: number,
|
||||
pitch = DEFAULT_CAMERA_PITCH,
|
||||
distance = ARENA_CAMERA_DISTANCE,
|
||||
): ArenaCameraPose {
|
||||
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
|
||||
const safePitch = clampCameraPitch(pitch);
|
||||
const radius = Number.isFinite(distance) ? Math.max(0.25, Math.min(12, distance)) : ARENA_CAMERA_DISTANCE;
|
||||
const target: [number, number, number] = [footPosition[0], footPosition[1] + ARENA_CAMERA_FOCUS_HEIGHT, footPosition[2]];
|
||||
return {
|
||||
position: [...stage.spectatorPositions[team]],
|
||||
target: [centerX, floorY + 1.2, centerZ],
|
||||
target,
|
||||
position: [
|
||||
target[0] - Math.sin(safeYaw) * Math.cos(safePitch) * radius,
|
||||
target[1] + Math.sin(safePitch) * radius,
|
||||
target[2] - Math.cos(safeYaw) * Math.cos(safePitch) * radius,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Probe both directions so one-sided imported walls also obstruct the camera. */
|
||||
export function arenaCameraClearDistance(target: Vector3, desired: Vector3, environment: Object3D, ray: Raycaster): number {
|
||||
const length = target.distanceTo(desired);
|
||||
if (length < 0.001) return 0;
|
||||
const direction = desired.clone().sub(target).normalize();
|
||||
let distance = length;
|
||||
for (const offset of [[0, 0, 0], [0.18, 0, 0], [-0.18, 0, 0], [0, 0.18, 0], [0, -0.18, 0]]) {
|
||||
const origin = target.clone().add(new Vector3(...offset));
|
||||
ray.near = 0.03;
|
||||
ray.far = length;
|
||||
ray.set(origin, direction);
|
||||
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, hit.distance - 0.2);
|
||||
ray.set(origin.addScaledVector(direction, length), direction.clone().negate());
|
||||
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, length - hit.distance - 0.2);
|
||||
}
|
||||
return Math.max(0.15, distance);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { createArenaCharacterKit } from "./arenaCharacter";
|
||||
import { controlledArenaActor } from "./arenaPlayer";
|
||||
import type { ArenaAuthoritativeSnapshot } from "./arenaTypes";
|
||||
|
||||
describe("arena character selection and saved actions", () => {
|
||||
it("keeps selected character bindings and enough mana to use learned spells", async () => {
|
||||
await preloadAbilityCatalogsForClasses(["priest"]);
|
||||
const character = { id: "mine", name: "My Priest", classId: "priest", raceId: "human", level: 80,
|
||||
equipment: {}, inventory: [], talentRanks: {}, actionBindings: { "primary:face-left": "priest-flash-heal", "primary:r1": null } } as unknown as CharacterProfile;
|
||||
const kit = createArenaCharacterKit(character);
|
||||
expect(kit.actionBindings["primary:face-left"]).toBe("priest-flash-heal");
|
||||
expect(kit.actionBindings["primary:r1"]).toBeNull();
|
||||
expect(kit.resource.maximum).toBeGreaterThan(kit.abilities["priest-flash-heal"].cost.amount);
|
||||
expect(kit.abilities.heal).toBeUndefined();
|
||||
expect(kit.stats?.health).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses server control ownership instead of selecting an arbitrary friendly bot", () => {
|
||||
const actors = [{ id: "bot", team: 0, isBot: true }, { id: "mine", team: 1, isBot: false, characterId: "saved" }];
|
||||
const snapshot = { mode: "ranked-pvp", controlledActorId: "mine", actors } as unknown as ArenaAuthoritativeSnapshot;
|
||||
expect(controlledArenaActor(snapshot, { id: "saved" })?.id).toBe("mine");
|
||||
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "saved" })?.id).toBe("mine");
|
||||
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "missing" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import {
|
||||
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass,
|
||||
type AbilityDefinition, type ClassResourceProfile,
|
||||
} from "./abilityCatalog";
|
||||
import { normalizeActionBindings } from "./actionBindings";
|
||||
import type { ArenaCharacterKit } from "./arenaTypes";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
|
||||
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
|
||||
return abilitiesForCharacter(character.classId, character.secondaryClassId)
|
||||
.filter((ability) => !ability.passive)
|
||||
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined
|
||||
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`]));
|
||||
}
|
||||
|
||||
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
|
||||
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined
|
||||
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
|
||||
const stats = deriveCharacterStats(character.classId, level, activeEquipmentStatsForClass(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID, character.classId), character.raceId, {}, character.secondaryClassId);
|
||||
const profile = resourceProfileForClass(character.classId);
|
||||
const resource = profile.type === "mana" ? { ...profile, maximum: stats.mana, initial: stats.mana, regenerationPerSecond: stats.mana * 0.004 } : profile;
|
||||
const abilities = arenaCharacterAbilities(character, level).filter((ability) => arenaAbilityIsLearned(ability, character, level));
|
||||
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
|
||||
const actionBindings = normalizeActionBindings(character.actionBindings, [
|
||||
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
|
||||
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id),
|
||||
]);
|
||||
return {
|
||||
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
|
||||
classId: character.classId,
|
||||
level,
|
||||
resource: resource as ClassResourceProfile,
|
||||
stats: { health: stats.health, spellPower: stats.spellPower },
|
||||
abilities: Object.fromEntries(abilities.map((ability) => [ability.id, ability])),
|
||||
actionBindings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot } from "./arenaTypes";
|
||||
|
||||
export function controlledArenaActor(snapshot: ArenaAuthoritativeSnapshot | null, character: Pick<CharacterProfile, "id"> | null): ArenaActorSnapshot | null {
|
||||
if (!snapshot) return null;
|
||||
return snapshot.actors.find((actor) => actor.id === snapshot.controlledActorId)
|
||||
?? snapshot.actors.find((actor) => character && actor.characterId === character.id && !actor.isBot)
|
||||
?? (snapshot.mode === "practice" ? snapshot.actors.find((actor) => actor.id === "practice-player") : null)
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function arenaTargetActor(snapshot: ArenaAuthoritativeSnapshot | null, id: string | null): ArenaActorSnapshot | null {
|
||||
const player = snapshot?.actors.find((actor) => actor.id === id);
|
||||
if (player) return player;
|
||||
for (const owner of snapshot?.actors ?? []) {
|
||||
const summon = owner.combat?.summons.find((entry) => entry.id === id);
|
||||
if (summon) return { ...owner, id: summon.id, name: summon.name, position: summon.position,
|
||||
health: summon.health, maxHealth: summon.maxHealth, alive: summon.health > 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function arenaIntentFeedback(reason?: string): string {
|
||||
return ({
|
||||
"preparation": "Wait for the gates to open.", "invalid-target": "Select a living target.",
|
||||
"out-of-range": "Target is out of range.", "line-of-sight": "Target is out of sight.",
|
||||
"resource": "Not enough resource.", "cooldown": "Ability is not ready yet.",
|
||||
"already-casting": "Already casting.", "moving": "Stop moving to cast.",
|
||||
"controlled": "You cannot cast while controlled or silenced.", "not-learned": "That ability is not learned by this character.",
|
||||
"not-facing": "Face your target.", "must-be-behind": "Move behind your target.",
|
||||
"invalid-destination": "Choose a reachable location in range.", "insufficient-resource": "Not enough resource.",
|
||||
"school-locked": "That spell school is locked.", "player-controlled": "You cannot act while controlled.",
|
||||
"actor-unavailable": "You cannot act right now.",
|
||||
} as Record<string, string>)[reason ?? ""] ?? "Unable to use that ability.";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
|
||||
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
|
||||
import { createArenaCharacterKit } from "./arenaCharacter";
|
||||
|
||||
interface PracticeOptions {
|
||||
character: CharacterProfile;
|
||||
@@ -33,6 +34,7 @@ export function createArenaPractice(options: PracticeOptions) {
|
||||
role,
|
||||
isBot: index !== 0,
|
||||
rating: options.difficulty,
|
||||
characterKit: index === 0 ? createArenaCharacterKit(options.character) : undefined,
|
||||
})),
|
||||
...teamRoles(teamSize).map((role, index) => ({
|
||||
actorId: `practice-enemy-${index}`,
|
||||
|
||||
+26
-11
@@ -13,9 +13,11 @@ import type { CharacterProfile, PlayerSession } from "../app/types";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { arenaAiProfile } from "./arenaRules";
|
||||
import { ARENA_STAGES } from "./arenaStages";
|
||||
import { createArenaPractice } from "./arenaPractice";
|
||||
import type { createArenaPractice } from "./arenaPractice";
|
||||
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
|
||||
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
|
||||
import { arenaIntentFeedback } from "./arenaPlayer";
|
||||
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
|
||||
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
|
||||
|
||||
@@ -74,6 +76,7 @@ function handleSocketMessage(raw: unknown) {
|
||||
}
|
||||
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
|
||||
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
|
||||
if (message.type === "intent-rejected") useArenaStore.setState({ error: message.message || arenaIntentFeedback((raw as { reason?: string }).reason) });
|
||||
}
|
||||
|
||||
async function connectArenaSocket(session: PlayerSession) {
|
||||
@@ -180,15 +183,23 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
if (state.mode === "offline-practice") {
|
||||
const practice = createArenaPractice({
|
||||
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
|
||||
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
|
||||
onSnapshot: (snapshot) => set({ snapshot }),
|
||||
onResult: (result) => set({ result }),
|
||||
});
|
||||
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
|
||||
useShellStore.setState({ phase: "arena-game" });
|
||||
return true;
|
||||
set({ loading: true, error: "" });
|
||||
try {
|
||||
await preloadAbilityCatalogsForClasses([character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])]);
|
||||
const { createArenaPractice } = await import("./arenaPractice");
|
||||
const practice = createArenaPractice({
|
||||
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
|
||||
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
|
||||
onSnapshot: (snapshot) => set({ snapshot }),
|
||||
onResult: (result) => set({ result }),
|
||||
});
|
||||
set({ practice, loading: false, error: "", result: null, queue: { status: "matched", matchId: `practice-${Date.now()}` } });
|
||||
useShellStore.setState({ phase: "arena-game" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ loading: false, error: messageFor(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!session?.accessToken) {
|
||||
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
|
||||
@@ -235,7 +246,11 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
|
||||
const sequence = get().sequence + 1;
|
||||
const message = { ...intent, sequence } as ArenaCombatIntent;
|
||||
set({ sequence });
|
||||
get().practice?.intent(message);
|
||||
if (intent.type === "target") set((state) => ({ snapshot: state.snapshot ? { ...state.snapshot,
|
||||
actors: state.snapshot.actors.map((actor) => actor.id === state.snapshot!.controlledActorId
|
||||
? { ...actor, targetActorId: intent.targetActorId || null } : actor) } : null }));
|
||||
const response = get().practice?.intent(message);
|
||||
if (intent.type === "ability") set({ error: response && !response.accepted ? response.message ?? arenaIntentFeedback(response.reason) : "" });
|
||||
const socket = get().socket;
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
},
|
||||
|
||||
+46
-2
@@ -1,4 +1,15 @@
|
||||
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
|
||||
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
|
||||
|
||||
export interface ArenaCharacterKit {
|
||||
readonly seed?: import("./combatStore").CharacterCombatSeed;
|
||||
readonly stats?: { readonly health: number; readonly spellPower: number };
|
||||
classId: string;
|
||||
level: number;
|
||||
resource: ClassResourceProfile;
|
||||
abilities: Readonly<Record<string, AbilityDefinition>>;
|
||||
actionBindings: Readonly<Record<string, string | null>>;
|
||||
}
|
||||
|
||||
export type Vec3Tuple = readonly [number, number, number];
|
||||
export type ArenaRole = "healer" | "damage";
|
||||
@@ -95,7 +106,10 @@ export interface ArenaQueueState {
|
||||
|
||||
export interface ArenaCombatIntent {
|
||||
sequence: number;
|
||||
type: "movement" | "facing" | "target" | "ability";
|
||||
type: "movement" | "facing" | "target" | "ability" | "command" | "jump" | "bindings";
|
||||
command?: "attack" | "defend" | "stop" | "recall" | "cancel-cast";
|
||||
actionBindings?: Readonly<Record<string, string | null>>;
|
||||
groundPosition?: Vec3Tuple;
|
||||
movement?: { x: number; z: number };
|
||||
facing?: number;
|
||||
targetActorId?: string;
|
||||
@@ -104,7 +118,35 @@ export interface ArenaCombatIntent {
|
||||
}
|
||||
|
||||
export interface ArenaActorSnapshot {
|
||||
avatar?: import("../avatar/CharacterModel").AvatarIdentity;
|
||||
visualEquipment?: readonly import("./lootTypes").InventoryItem[];
|
||||
combat?: {
|
||||
resourcePools: import("./combatStore").CombatState["resourcePools"];
|
||||
maxResourcePools: import("./combatStore").CombatState["maxResourcePools"];
|
||||
runes: import("./combatStore").CombatState["deathKnightRunes"];
|
||||
comboPoints: number;
|
||||
comboTargetId: string | null;
|
||||
shield: number;
|
||||
activeCast: import("./combatStore").ActiveCast | null;
|
||||
summons: readonly import("./combatStore").CombatSummon[];
|
||||
areaEffects: readonly import("./combatStore").CombatAreaEffect[];
|
||||
controlledUntil: number;
|
||||
schoolLockedUntil: import("./combatStore").CombatState["schoolLockedUntil"];
|
||||
talentRanks: import("./combatStore").CombatState["talentRanks"];
|
||||
gearStats: import("./combatStore").CombatState["gearStats"];
|
||||
specializationId: string | null;
|
||||
playerAnimationEvent: import("./combatAnimation").CharacterCombatAnimationEvent | null;
|
||||
};
|
||||
id: string;
|
||||
characterId?: string | null;
|
||||
classId?: string;
|
||||
level?: number;
|
||||
actionBindings?: Readonly<Record<string, string | null>>;
|
||||
abilityIds?: readonly string[];
|
||||
resourceType?: string;
|
||||
gcdRemainingMs?: number;
|
||||
lastAbilityId?: string;
|
||||
abilityRevision?: number;
|
||||
name: string;
|
||||
team: 0 | 1;
|
||||
role: ArenaRole;
|
||||
@@ -119,7 +161,7 @@ export interface ArenaActorSnapshot {
|
||||
maxResource: number;
|
||||
targetActorId: string | null;
|
||||
cooldowns: Record<string, number>;
|
||||
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
|
||||
effects: readonly { id: string; remainingMs: number; stacks?: number; icon?: string; disposition?: "buff" | "debuff" }[];
|
||||
}
|
||||
|
||||
export interface ArenaDynamicObjectSnapshot {
|
||||
@@ -130,6 +172,8 @@ export interface ArenaDynamicObjectSnapshot {
|
||||
}
|
||||
|
||||
export interface ArenaAuthoritativeSnapshot {
|
||||
combatPresentation?: readonly import("./combatPresentation").CombatPresentationEvent[];
|
||||
combatEvents?: readonly import("./combatEvents").StructuredCombatEvent[];
|
||||
matchId: string;
|
||||
tick: number;
|
||||
serverTime: number;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const combatMedia = vi.hoisted(() => ({ ready: false }));
|
||||
const combatMedia = vi.hoisted(() => ({
|
||||
ready: false,
|
||||
fetch: vi.fn(),
|
||||
entry: vi.fn(() => null as import("./combatPresentation").CombatEffectManifestEntry | null),
|
||||
}));
|
||||
|
||||
vi.mock("./combatMediaPack", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./combatMediaPack")>()),
|
||||
combatMediaPackReady: () => combatMedia.ready,
|
||||
fetchOptionalMediaAsset: combatMedia.fetch,
|
||||
}));
|
||||
|
||||
vi.mock("./combatPresentation", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./combatPresentation")>()),
|
||||
combatEffectManifestEntry: combatMedia.entry,
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -40,6 +50,8 @@ const fixtureEvent: CombatPresentationEvent = {
|
||||
|
||||
afterEach(() => {
|
||||
combatMedia.ready = false;
|
||||
combatMedia.fetch.mockReset();
|
||||
combatMedia.entry.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -69,7 +81,7 @@ describe("combat audio output policy", () => {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
})).toBe(true);
|
||||
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
|
||||
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
|
||||
expect(combatMediaPreloadEnabled({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: true,
|
||||
@@ -104,4 +116,56 @@ describe("combat audio output policy", () => {
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays silent without an original sound, even with combat audio enabled", () => {
|
||||
combatMedia.ready = true;
|
||||
const AudioContextConstructor = vi.fn();
|
||||
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
|
||||
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||
|
||||
playCombatAudio(fixtureEvent);
|
||||
|
||||
expect(AudioContextConstructor).not.toHaveBeenCalled();
|
||||
expect(combatMedia.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["original", "missing-file", "bad-audio"])("uses original audio or silence for %s", async (scenario) => {
|
||||
vi.resetModules();
|
||||
const audio = await import("./combatAudio");
|
||||
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
|
||||
const audioContext = {
|
||||
currentTime: 0,
|
||||
state: "running",
|
||||
destination: {},
|
||||
createGain: () => ({
|
||||
connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 },
|
||||
}),
|
||||
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
|
||||
createBufferSource: vi.fn(() => source),
|
||||
createOscillator: vi.fn(),
|
||||
decodeAudioData: vi.fn(async () => {
|
||||
if (scenario === "bad-audio") throw new Error("Invalid audio");
|
||||
return { duration: 1 };
|
||||
}),
|
||||
resume: vi.fn(async () => undefined),
|
||||
};
|
||||
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
|
||||
combatMedia.ready = true;
|
||||
combatMedia.entry.mockReturnValue({
|
||||
key: "wow335:2061", source: "wow335", spellId: 2061,
|
||||
sounds: [{ url: "/assets/game/combat-effects/audio/flash-heal.wav" }],
|
||||
});
|
||||
combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: scenario === "missing-file" ? 404 : 200,
|
||||
}));
|
||||
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
||||
audio.playCombatAudio({ ...fixtureEvent, source: "wow335", sourceSpellId: 2061 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav");
|
||||
expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
|
||||
expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
|
||||
expect(audioContext.createOscillator).not.toHaveBeenCalled();
|
||||
audio.resetCombatAudio();
|
||||
});
|
||||
});
|
||||
+1
-62
@@ -4,7 +4,6 @@ import {
|
||||
type CombatEffectSoundAsset,
|
||||
type CombatPresentationEvent,
|
||||
type CombatPresentationSettings,
|
||||
type CombatPresentationSchool,
|
||||
} from "./combatPresentation";
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
@@ -147,60 +146,6 @@ function registerVoice(voice: ActiveVoice): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
|
||||
physical: 115,
|
||||
arcane: 520,
|
||||
fire: 190,
|
||||
frost: 720,
|
||||
nature: 360,
|
||||
shadow: 145,
|
||||
holy: 660,
|
||||
};
|
||||
|
||||
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const audioContext = ensureContext();
|
||||
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
|
||||
void audioContext.resume().catch(() => undefined);
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const envelope = audioContext.createGain();
|
||||
const panner = positionPanner(audioContext, event);
|
||||
const start = audioContext.currentTime;
|
||||
const isImpact = event.phase === "impact" || event.delivery === "melee";
|
||||
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
|
||||
const base = SCHOOL_FREQUENCY[event.school];
|
||||
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
|
||||
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
|
||||
envelope.gain.setValueAtTime(0.0001, start);
|
||||
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
|
||||
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
oscillator.connect(envelope);
|
||||
envelope.connect(panner);
|
||||
panner.connect(groupGains[event.actorGroup]);
|
||||
let stopped = false;
|
||||
const voice: ActiveVoice = {
|
||||
group: event.actorGroup,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try { oscillator.stop(); } catch { /* already stopped */ }
|
||||
},
|
||||
};
|
||||
const unregister = registerVoice(voice);
|
||||
oscillator.onended = () => {
|
||||
unregister();
|
||||
oscillator.disconnect();
|
||||
envelope.disconnect();
|
||||
panner.disconnect();
|
||||
};
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + duration + 0.02);
|
||||
}
|
||||
|
||||
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
|
||||
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
|
||||
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
|
||||
@@ -321,13 +266,7 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
) return;
|
||||
const sound = weightedSound(event);
|
||||
if (!sound) {
|
||||
playSynthesizedCombatCue(event);
|
||||
return;
|
||||
}
|
||||
void playBufferedCombatCue(event, sound).then((played) => {
|
||||
if (!played) playSynthesizedCombatCue(event);
|
||||
});
|
||||
if (sound) void playBufferedCombatCue(event, sound).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function stopCombatAudioGroup(group: CombatActorGroup): void {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const content = vi.hoisted(() => ({
|
||||
usable: false,
|
||||
install: vi.fn(async () => undefined),
|
||||
install: vi.fn(async (): Promise<void> => undefined),
|
||||
remove: vi.fn(async () => undefined),
|
||||
listener: null as (() => void) | null,
|
||||
snapshot: {
|
||||
@@ -29,25 +29,53 @@ vi.mock("../content/contentManager", () => ({
|
||||
import {
|
||||
combatMediaPackReady,
|
||||
downloadCombatMediaPack,
|
||||
ensureCombatMediaPack,
|
||||
fetchCombatMediaAsset,
|
||||
removeCombatMediaPack,
|
||||
} from "./combatMediaPack";
|
||||
|
||||
beforeEach(() => {
|
||||
content.usable = false;
|
||||
content.install.mockClear();
|
||||
content.install.mockReset();
|
||||
content.remove.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("optional combat media pack", () => {
|
||||
it("uses the shared verified content-pack installer only after an explicit request", async () => {
|
||||
describe("original combat media pack", () => {
|
||||
it("uses the shared verified content-pack installer", async () => {
|
||||
const progress = vi.fn();
|
||||
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
|
||||
expect(content.install).toHaveBeenCalledWith("core:combat-media");
|
||||
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
|
||||
});
|
||||
|
||||
it("automatically installs missing default media and reuses installed media", async () => {
|
||||
content.install.mockImplementationOnce(async () => { content.usable = true; });
|
||||
await expect(ensureCombatMediaPack()).resolves.toBe(true);
|
||||
await expect(ensureCombatMediaPack()).resolves.toBe(true);
|
||||
expect(content.install).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows visual fallback after a failed download and retries on the next preparation", async () => {
|
||||
content.install.mockRejectedValueOnce(new Error("Offline"));
|
||||
await expect(ensureCombatMediaPack()).resolves.toBe(false);
|
||||
content.install.mockImplementationOnce(async () => { content.usable = true; });
|
||||
await expect(ensureCombatMediaPack()).resolves.toBe(true);
|
||||
expect(content.install).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shares an in-flight installation between automatic preparation and a download request", async () => {
|
||||
let finish!: () => void;
|
||||
content.install.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }));
|
||||
const preparation = ensureCombatMediaPack();
|
||||
const download = downloadCombatMediaPack();
|
||||
expect(content.install).toHaveBeenCalledTimes(1);
|
||||
content.usable = true;
|
||||
finish();
|
||||
await expect(preparation).resolves.toBe(true);
|
||||
await expect(download).resolves.toEqual({ fileCount: 12 });
|
||||
});
|
||||
|
||||
it("does not fetch source media while its pack is offloaded", async () => {
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
@@ -20,12 +20,13 @@ export interface CombatMediaDownloadResult {
|
||||
}
|
||||
|
||||
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
|
||||
let installRequest: Promise<void> | null = null;
|
||||
|
||||
function isCombatMediaUrl(value: unknown): value is string {
|
||||
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
|
||||
}
|
||||
|
||||
/** Callers must enforce the player's original-media opt-in before fetching. */
|
||||
/** Fetch original media after the enabled pack is ready. */
|
||||
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
|
||||
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
|
||||
return fetchOptionalMediaAsset(url);
|
||||
@@ -50,7 +51,9 @@ export async function downloadCombatMediaPack(
|
||||
};
|
||||
const unsubscribe = subscribeContentManager(publishProgress);
|
||||
try {
|
||||
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
installRequest ??= installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID)
|
||||
.finally(() => { installRequest = null; });
|
||||
await installRequest;
|
||||
publishProgress();
|
||||
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
|
||||
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
|
||||
@@ -61,6 +64,17 @@ export async function downloadCombatMediaPack(
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare the default media without blocking gameplay when it is unavailable. */
|
||||
export async function ensureCombatMediaPack(): Promise<boolean> {
|
||||
if (combatMediaPackReady()) return true;
|
||||
try {
|
||||
await downloadCombatMediaPack();
|
||||
return combatMediaPackReady();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCombatMediaPack(): Promise<void> {
|
||||
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ describe("combat presentation", () => {
|
||||
audioEnabled: { enemy: false },
|
||||
audioVolume: { player: 2, party: -1, enemy: 0.35 },
|
||||
})).toEqual({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: false, party: true, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
@@ -37,12 +37,13 @@ describe("combat presentation", () => {
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "original",
|
||||
}).vfxMode).toBe("generated");
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings({
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "hybrid",
|
||||
}).vfxMode).toBe("generated");
|
||||
}).vfxMode).toBe("original");
|
||||
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
});
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ export interface CombatPresentationSettings {
|
||||
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
|
||||
|
||||
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
|
||||
originalMediaEnabled: false,
|
||||
vfxMode: "generated",
|
||||
originalMediaEnabled: true,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "high",
|
||||
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
|
||||
@@ -90,12 +90,9 @@ export function normalizeCombatPresentationSettings(
|
||||
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
|
||||
? record.originalMediaEnabled
|
||||
: fallback.originalMediaEnabled;
|
||||
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
|
||||
? record.vfxMode
|
||||
: fallback.vfxMode;
|
||||
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
|
||||
? "generated"
|
||||
: requestedVfxMode;
|
||||
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
|
||||
? record.vfxQuality
|
||||
: fallback.vfxQuality;
|
||||
@@ -219,11 +216,11 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
|
||||
|
||||
export function preloadCombatEffectManifest(): Promise<void> {
|
||||
if (manifestRequest) return manifestRequest;
|
||||
if (typeof fetch === "undefined") return Promise.resolve();
|
||||
if (typeof fetch === "undefined" || !combatMediaPackReady()) return Promise.resolve();
|
||||
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
|
||||
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
|
||||
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
|
||||
.catch(() => undefined);
|
||||
.catch(() => { manifestRequest = null; });
|
||||
return manifestRequest;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const media = vi.hoisted(() => ({ ready: false, fetch: vi.fn() }));
|
||||
vi.mock("./combatMediaPack", () => ({
|
||||
COMBAT_MEDIA_MANIFEST_URL: "/assets/game/combat-effects/manifest.json",
|
||||
combatMediaPackReady: () => media.ready,
|
||||
fetchCombatMediaAsset: media.fetch,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
media.ready = false;
|
||||
media.fetch.mockReset();
|
||||
});
|
||||
|
||||
describe("original media loading", () => {
|
||||
it("uses the original default once the pack is ready without changing the saved preference", async () => {
|
||||
const { DEFAULT_COMBAT_PRESENTATION_SETTINGS: settings, effectiveCombatVfxMode } = await import("./combatPresentation");
|
||||
expect(effectiveCombatVfxMode(settings)).toBe("generated");
|
||||
media.ready = true;
|
||||
expect(effectiveCombatVfxMode(settings)).toBe("original");
|
||||
expect(effectiveCombatVfxMode({ ...settings, originalMediaEnabled: false })).toBe("generated");
|
||||
expect(settings.vfxMode).toBe("original");
|
||||
});
|
||||
|
||||
it("does not poison manifest loading before installation or after a failed fetch", async () => {
|
||||
const { preloadCombatEffectManifest, combatEffectManifestEntry } = await import("./combatPresentation");
|
||||
await preloadCombatEffectManifest();
|
||||
expect(media.fetch).not.toHaveBeenCalled();
|
||||
|
||||
media.ready = true;
|
||||
media.fetch.mockRejectedValueOnce(new Error("Offline"));
|
||||
await preloadCombatEffectManifest();
|
||||
expect(combatEffectManifestEntry(2061, "wow335")).toBeNull();
|
||||
|
||||
const entry = { key: "wow335:2061", source: "wow335", spellId: 2061 };
|
||||
media.fetch.mockResolvedValueOnce(new Response(JSON.stringify({ schemaVersion: 1, entries: [entry] })));
|
||||
await preloadCombatEffectManifest();
|
||||
expect(combatEffectManifestEntry(2061, "wow335")).toEqual(entry);
|
||||
expect(media.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+50
-11
@@ -330,6 +330,10 @@ export interface DiminishingReturnState {
|
||||
}
|
||||
|
||||
export interface MobCombatState {
|
||||
/** When the hostile actor is a player, preserve its actual defense and resources. */
|
||||
readonly characterDefender?: CombatDefenderStats;
|
||||
readonly characterShield?: number;
|
||||
readonly characterResources?: Readonly<Partial<Record<ResourceType, number>>>;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly serverEntry: number | null;
|
||||
@@ -1176,7 +1180,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
|
||||
return "spell";
|
||||
}
|
||||
|
||||
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
||||
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
|
||||
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
|
||||
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
|
||||
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
|
||||
@@ -1328,7 +1332,7 @@ function resolvePlayerDamageResult(
|
||||
attackKind,
|
||||
damageSchool: school,
|
||||
attacker,
|
||||
defender: npcCombatDefender(mob, stats.level),
|
||||
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level),
|
||||
rules: immune
|
||||
? {
|
||||
...rules,
|
||||
@@ -1710,6 +1714,15 @@ function talentAdjustedIncomingDamage(amount: number, modifiers: readonly Talent
|
||||
));
|
||||
}
|
||||
|
||||
/** Renderer-independent player defenses for hostile-player combat adapters. */
|
||||
export function characterCombatDefender(state: CombatState): CombatDefenderStats {
|
||||
const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats,
|
||||
state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID));
|
||||
const defender = combatDefenderFromCharacterStats(stats);
|
||||
const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000;
|
||||
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
|
||||
}
|
||||
|
||||
function normalizedSettings(
|
||||
current: GameplaySettings,
|
||||
patch: Partial<GameplaySettings>,
|
||||
@@ -2534,11 +2547,13 @@ function damageMobInWork(
|
||||
now,
|
||||
});
|
||||
work.auras = [...auraAbsorb.auras];
|
||||
const characterAbsorbed = Math.min(mob.characterShield ?? 0, auraAbsorb.remainingDamage);
|
||||
const remainingDamage = Math.max(0, auraAbsorb.remainingDamage - characterAbsorbed);
|
||||
const adjustedCombatResult = combatResult
|
||||
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
|
||||
? { ...combatResult, finalDamage: Math.max(0, Math.round(remainingDamage)) }
|
||||
: undefined;
|
||||
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
|
||||
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.remainingDamage)));
|
||||
const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage)));
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
kind: "damage",
|
||||
@@ -2549,7 +2564,7 @@ function damageMobInWork(
|
||||
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
|
||||
effectiveAmount: applied,
|
||||
overheal: 0,
|
||||
absorbed: auraAbsorb.absorbed,
|
||||
absorbed: auraAbsorb.absorbed + characterAbsorbed,
|
||||
critical: combatResult?.critical ?? false,
|
||||
outcome: combatResult?.outcome ?? "hit",
|
||||
});
|
||||
@@ -2586,6 +2601,7 @@ function damageMobInWork(
|
||||
);
|
||||
work.mobs[id] = {
|
||||
...engagedMob,
|
||||
...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}),
|
||||
health,
|
||||
dead: killed,
|
||||
hasLoot,
|
||||
@@ -2609,7 +2625,9 @@ function damageMobInWork(
|
||||
combatPhase: killed ? "idle" : "chasing",
|
||||
homePosition: engagedMob.homePosition ?? getMobPosition(id),
|
||||
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
|
||||
activeCast: killed ? null : engagedMob.activeCast,
|
||||
activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast
|
||||
? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) }
|
||||
: engagedMob.activeCast,
|
||||
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
|
||||
nextWoundAnimationAt: canPlayWoundAnimation
|
||||
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
|
||||
@@ -2737,19 +2755,19 @@ function applyStatus(
|
||||
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
|
||||
]);
|
||||
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
|
||||
const applications = mob.boss && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
|
||||
const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
|
||||
? previousDiminishing.applications
|
||||
: 0;
|
||||
const multiplier = mob.boss && diminishingStatuses.has(effect.status)
|
||||
const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
|
||||
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
|
||||
: 1;
|
||||
const durationMs = Math.round(baseDurationMs * multiplier);
|
||||
const controlDiminishingReturns = mob.boss && diminishingStatuses.has(effect.status)
|
||||
const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
|
||||
? {
|
||||
...mob.controlDiminishingReturns,
|
||||
[effect.status]: {
|
||||
applications: Math.min(3, applications + 1),
|
||||
resetAt: now + 15_000,
|
||||
resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000,
|
||||
},
|
||||
}
|
||||
: mob.controlDiminishingReturns;
|
||||
@@ -4986,6 +5004,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
|
||||
for (const id of candidates) {
|
||||
const mob = work.mobs[id];
|
||||
if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue;
|
||||
if (mob) work.mobs[id] = applyStatus(mob, {
|
||||
...effect,
|
||||
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
|
||||
@@ -5010,6 +5029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
||||
for (const recipientId of recipients) {
|
||||
const previousAuras = work.auras;
|
||||
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
||||
work.auras = [...applyCombatAura(previousAuras, {
|
||||
definition: auraDefinition,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
@@ -5032,12 +5052,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
if (effect.control && work.mobs[recipientId]) {
|
||||
const durationMs = auraDefinition.durationMs ?? 1_000;
|
||||
const previousStatuses = work.mobs[recipientId].statuses;
|
||||
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
|
||||
kind: "status",
|
||||
status: effect.control.kind as MobStatusKind,
|
||||
durationMs,
|
||||
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
|
||||
}, `aura:${auraDefinition.id}`, now);
|
||||
if (work.mobs[recipientId].characterDefender) {
|
||||
const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`);
|
||||
if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras];
|
||||
else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "dispel") {
|
||||
@@ -5140,7 +5166,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
|
||||
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
|
||||
if (ability.target === "hostile") {
|
||||
const returned = effect.burn ? 0 : drainAmount;
|
||||
let drainedFromPlayers = 0;
|
||||
let hasPlayerTarget = false;
|
||||
for (const id of affectedMobIds) {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob?.characterResources) continue;
|
||||
hasPlayerTarget = true;
|
||||
const current = mob.characterResources[resource] ?? 0;
|
||||
const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount;
|
||||
const drained = Math.min(current, requested);
|
||||
drainedFromPlayers += drained;
|
||||
work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } };
|
||||
if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool);
|
||||
}
|
||||
const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount;
|
||||
if (returned > 0) {
|
||||
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
|
||||
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
emitControllerToken,
|
||||
orderedControllerTokenTransitions,
|
||||
resetControllerState,
|
||||
setExternalControllerMovement,
|
||||
interceptControllerInput,
|
||||
} from "../input/controller";
|
||||
import {
|
||||
MAX_MOUSE_DELTA_PER_EVENT,
|
||||
@@ -23,8 +25,11 @@ import {
|
||||
keyboardCommandForCode,
|
||||
queueJumpRequest,
|
||||
sanitizeMouseDelta,
|
||||
readInputSnapshot,
|
||||
type InputActions,
|
||||
} from "./inputManager";
|
||||
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
|
||||
import { setCapturingInput } from "../input/keybindRuntime";
|
||||
|
||||
function stubInputEventTargets(): void {
|
||||
const windowTarget = new EventTarget();
|
||||
@@ -59,12 +64,117 @@ function inputActionSpies(blocked = false): InputActions {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCapturingInput(false);
|
||||
endMouseLookDrag();
|
||||
clearJumpRequest();
|
||||
resetControllerState();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Partial<KeyboardEvent> = {}) {
|
||||
const event = new Event(type, { cancelable: true });
|
||||
Object.defineProperties(event, Object.fromEntries(Object.entries({ code, target: null, ...modifiers }).map(([key, value]) => [key, { value }])));
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("remappable gameplay controls", () => {
|
||||
it("keeps moving forward when a legacy WoW skill also owns W", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
|
||||
preferences.pc["bar:0:0"] = "KeyW";
|
||||
next.getBindings = (device) => preferences[device];
|
||||
next.desktopActionBarsActive = () => true;
|
||||
next.castDesktopSlot = vi.fn();
|
||||
const dispose = installInput(next);
|
||||
keyboardEvent("keydown", "KeyW");
|
||||
expect(readInputSnapshot().moveForward).toBe(1);
|
||||
expect(next.castDesktopSlot).not.toHaveBeenCalled();
|
||||
keyboardEvent("keyup", "KeyW");
|
||||
expect(readInputSnapshot().moveForward).toBe(0);
|
||||
dispose();
|
||||
});
|
||||
it("honors remapped movement and clears held input when a menu opens", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
|
||||
preferences.pc = replaceInputBinding(preferences.pc, "pc", "move-forward", "ArrowUp");
|
||||
next.getBindings = (device) => preferences[device];
|
||||
const dispose = installInput(next);
|
||||
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(0);
|
||||
keyboardEvent("keydown", "ArrowUp");
|
||||
expect(readInputSnapshot()).toMatchObject({ moveForward: 1, lookY: 0 });
|
||||
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
expect(readInputSnapshot().moveForward).toBe(0);
|
||||
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
expect(readInputSnapshot().moveForward).toBe(0);
|
||||
dispose();
|
||||
});
|
||||
it("casts remapped bar chords once and gives ground confirmation its context", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
|
||||
preferences.pc["bar:4:11"] = "Ctrl+Shift+KeyQ";
|
||||
preferences.pc["ground-confirm"] = "Ctrl+Shift+KeyQ";
|
||||
next.getBindings = (device) => preferences[device]; next.desktopActionBarsActive = () => true; next.castDesktopSlot = vi.fn();
|
||||
const dispose = installInput(next);
|
||||
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
|
||||
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true, repeat: true });
|
||||
expect(next.castDesktopSlot).toHaveBeenCalledExactlyOnceWith(4, 11);
|
||||
(next.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
|
||||
expect(next.confirmGroundTarget).toHaveBeenCalledTimes(1);
|
||||
expect(next.castDesktopSlot).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
it("remaps controller axes and buttons with analog movement preserved", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
|
||||
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-forward", "Axis3-");
|
||||
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-right", "Button3");
|
||||
next.getBindings = (device) => preferences[device];
|
||||
const dispose = installInput(next);
|
||||
setExternalControllerMovement({ moveX: 0, moveY: -1, lookX: 0, lookY: 0 });
|
||||
expect(readInputSnapshot().moveForward).toBe(0);
|
||||
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: -.7 });
|
||||
expect(readInputSnapshot().moveForward).toBeGreaterThan(.3);
|
||||
expect(readInputSnapshot().moveForward).toBeLessThan(1);
|
||||
expect(readInputSnapshot().lookY).toBe(0);
|
||||
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
|
||||
emitControllerToken({ token: "Button3", repeat: false, pressed: true });
|
||||
expect(readInputSnapshot().moveX).toBe(1);
|
||||
expect(next.castActionBinding).not.toHaveBeenCalled();
|
||||
emitControllerToken({ token: "Button3", repeat: false, pressed: false });
|
||||
expect(readInputSnapshot().moveX).toBe(0);
|
||||
dispose();
|
||||
});
|
||||
it("uses customized controller combinations before their base button", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
|
||||
preferences.controller = replaceInputBinding(preferences.controller, "controller", "jump", "Button8&Button0");
|
||||
next.getBindings = (device) => preferences[device];
|
||||
const dispose = installInput(next);
|
||||
emitControllerToken({ token: "Button8", repeat: false, pressed: true });
|
||||
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
|
||||
expect(consumeJumpRequest()).not.toBeNull();
|
||||
expect(next.castActionBinding).not.toHaveBeenCalled();
|
||||
expect(next.lootActiveBoss).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
it("blocks keyboard actions, controller actions and movement throughout capture", () => {
|
||||
stubInputEventTargets();
|
||||
const next = inputActionSpies(); const dispose = installInput(next);
|
||||
setCapturingInput(true);
|
||||
const captured = vi.fn(() => true); const unsubscribe = interceptControllerInput(captured);
|
||||
keyboardEvent("keydown", "KeyW"); keyboardEvent("keydown", "KeyI"); keyboardEvent("keydown", "Digit1");
|
||||
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
|
||||
emitControllerToken({ token: "Button9", repeat: false, pressed: true });
|
||||
setExternalControllerMovement({ moveX: 1, moveY: -1, lookX: 0, lookY: 0 });
|
||||
expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
|
||||
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.toggleInventory).not.toHaveBeenCalled(); expect(next.togglePause).not.toHaveBeenCalled();
|
||||
expect(captured).toHaveBeenCalledTimes(2);
|
||||
unsubscribe(); dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gameplay input bindings", () => {
|
||||
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
|
||||
stubInputEventTargets();
|
||||
|
||||
+102
-133
@@ -2,12 +2,13 @@ import { applyRadialDeadzone, normalizePlanar } from "./inputMath";
|
||||
import {
|
||||
CONTROLLER_SYSTEM_BACK_TOKEN,
|
||||
getControllerMovement,
|
||||
getControllerHeldTokens,
|
||||
subscribeControllerToken,
|
||||
} from "../input/controller";
|
||||
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent, defaultInputBindings, type InputBindings, type InputDevice } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import type { PartyCommand } from "./partyStore";
|
||||
import {
|
||||
actionControlForControllerToken,
|
||||
actionControlForKeyboardCode,
|
||||
actionLayerForModifiers,
|
||||
type ActionBindingControl,
|
||||
type ActionBindingLayer,
|
||||
@@ -25,6 +26,10 @@ export interface InputActions {
|
||||
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
|
||||
handleCustomKey?: (event: KeyboardEvent) => boolean;
|
||||
desktopActionBarsActive?: () => boolean;
|
||||
getBindings?: (device: InputDevice) => InputBindings;
|
||||
inputSuspended?: () => boolean;
|
||||
castDesktopSlot?: (bar: number, slot: number) => void;
|
||||
togglePanel?: (panel: "party" | "boss-guide" | "bindings" | "options" | "keybinds" | "ui-layout") => void;
|
||||
togglePause: () => void;
|
||||
toggleMap: () => void;
|
||||
toggleInventory: () => void;
|
||||
@@ -150,21 +155,19 @@ export function isMouseLookDragActive(): boolean {
|
||||
return mouseLookDragActive;
|
||||
}
|
||||
|
||||
const GAMEPLAY_AXIS_KEYS = new Set([
|
||||
"KeyW", "KeyA", "KeyS", "KeyD",
|
||||
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
|
||||
]);
|
||||
|
||||
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>): Pick<
|
||||
InputSnapshot,
|
||||
"moveX" | "moveForward" | "lookX" | "lookY"
|
||||
> {
|
||||
return {
|
||||
moveX: Number(activeKeys.has("KeyD")) - Number(activeKeys.has("KeyA")),
|
||||
moveForward: Number(activeKeys.has("KeyW")) - Number(activeKeys.has("KeyS")),
|
||||
lookX: Number(activeKeys.has("ArrowRight")) - Number(activeKeys.has("ArrowLeft")),
|
||||
lookY: Number(activeKeys.has("ArrowDown")) - Number(activeKeys.has("ArrowUp")),
|
||||
};
|
||||
function inputAxes(value: (id: string) => number): Pick<InputSnapshot, "moveX" | "moveForward" | "lookX" | "lookY"> {
|
||||
return { moveX: value("move-right") - value("move-left"), moveForward: value("move-forward") - value("move-backward"),
|
||||
lookX: value("look-right") - value("look-left"), lookY: value("look-down") - value("look-up") };
|
||||
}
|
||||
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>, bindings = defaultInputBindings("pc")) {
|
||||
return inputAxes((id) => {
|
||||
const key = bindings[id];
|
||||
if (!key) return 0;
|
||||
return Number(activeKeys.has(key));
|
||||
});
|
||||
}
|
||||
function bindingsFor(device: InputDevice): InputBindings {
|
||||
return actions?.getBindings?.(device) ?? getInputPreferences()[device];
|
||||
}
|
||||
|
||||
export type GameplayInputCommand =
|
||||
@@ -231,132 +234,88 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
|
||||
|
||||
export function installInput(nextActions: InputActions): () => void {
|
||||
actions = nextActions;
|
||||
let controllerL1 = false;
|
||||
let controllerL2 = false;
|
||||
const keyboardModifiers = new Set<string>();
|
||||
const currentActionLayer = () => actionLayerForModifiers(
|
||||
controllerL1 || keyboardModifiers.has("ShiftLeft") || keyboardModifiers.has("ShiftRight"),
|
||||
controllerL2 || keyboardModifiers.has("AltLeft") || keyboardModifiers.has("AltRight"),
|
||||
);
|
||||
const publishActionLayer = () => nextActions.setActionLayer(currentActionLayer());
|
||||
|
||||
const suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
|
||||
const publishActionLayer = () => {
|
||||
const held = getControllerHeldTokens();
|
||||
const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => {
|
||||
const parts = key?.split("&").slice(0, -1) ?? [];
|
||||
return id.startsWith("ability:" + layer + ":") && parts.length > 0 && parts.every((token) => held.has(token));
|
||||
});
|
||||
nextActions.setActionLayer(actionLayerForModifiers(layerHeld("secondary") || keyboardModifiers.has("Shift"), layerHeld("tertiary") || keyboardModifiers.has("Alt")));
|
||||
};
|
||||
const dispatch = (id: string) => {
|
||||
if (id.startsWith("ability:")) {
|
||||
const [, layer, control] = id.split(":");
|
||||
nextActions.setActionLayer(layer as ActionBindingLayer);
|
||||
nextActions.castActionBinding(layer as ActionBindingLayer, control as ActionBindingControl);
|
||||
} else if (id.startsWith("bar:")) {
|
||||
const [, bar, slot] = id.split(":"); nextActions.castDesktopSlot?.(Number(bar), Number(slot));
|
||||
} else if (id === "ground-confirm") nextActions.confirmGroundTarget();
|
||||
else if (id === "ground-cancel") nextActions.cancelGroundTarget();
|
||||
else if (["party", "boss-guide", "bindings", "options", "keybinds", "ui-layout"].includes(id)) nextActions.togglePanel?.(id as Parameters<NonNullable<InputActions["togglePanel"]>>[0]);
|
||||
else runCommand(id as GameplayInputCommand, nextActions);
|
||||
};
|
||||
const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => {
|
||||
if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false;
|
||||
if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked();
|
||||
if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false;
|
||||
if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false;
|
||||
return !nextActions.gameplayActionsBlocked() || (entry.group === "Panels" && (device === "pc" || entry.id === "pause"));
|
||||
});
|
||||
const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => {
|
||||
const isPressed = pressed !== false;
|
||||
if (token === "Button4" || token === "Button6") {
|
||||
if (token === "Button4") controllerL1 = isPressed;
|
||||
else controllerL2 = isPressed;
|
||||
nextActions.setInputMode("gamepad");
|
||||
publishActionLayer();
|
||||
return;
|
||||
}
|
||||
if (!isPressed) return;
|
||||
if (suspended()) return;
|
||||
publishActionLayer();
|
||||
if (pressed === false || repeat) return;
|
||||
nextActions.setInputMode("gamepad");
|
||||
if (repeat) return;
|
||||
if (nextActions.groundTargetActive()) {
|
||||
if (token === "Button0") {
|
||||
nextActions.confirmGroundTarget();
|
||||
return;
|
||||
if (token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||
if (!nextActions.gameplayActionsBlocked()) {
|
||||
if (nextActions.groundTargetActive()) nextActions.cancelGroundTarget(); else nextActions.togglePause();
|
||||
}
|
||||
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||
nextActions.cancelGroundTarget();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
token === "Button0"
|
||||
&& !nextActions.gameplayActionsBlocked()
|
||||
&& nextActions.lootActiveBoss()
|
||||
) return;
|
||||
const actionControl = actionControlForControllerToken(token);
|
||||
if (actionControl) {
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
const layer = currentActionLayer();
|
||||
nextActions.setActionLayer(layer);
|
||||
nextActions.castActionBinding(layer, actionControl);
|
||||
return;
|
||||
}
|
||||
const command = controllerCommandForToken(token);
|
||||
if (!command) return;
|
||||
// Android's system Back is semantic rather than the Circle face button.
|
||||
// Let the active menu's controller hook own it while a surface is open;
|
||||
// in live gameplay it opens the pause hub.
|
||||
if (token === CONTROLLER_SYSTEM_BACK_TOKEN && nextActions.gameplayActionsBlocked()) return;
|
||||
// Start must remain available in every context so a Thor player can always
|
||||
// reach or leave the pause hub. Menu components own the other buttons while
|
||||
// an overlay is visible, preventing casts from firing behind a panel.
|
||||
if (command === "pause") {
|
||||
runCommand(command, nextActions);
|
||||
return;
|
||||
const bindings = bindingsFor("controller"); const held = getControllerHeldTokens();
|
||||
const matches = candidates("controller").filter((entry) => controllerBindingMatches(bindings[entry.id], token, held))
|
||||
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground")
|
||||
|| (bindings[b.id]?.split("&").length ?? 0) - (bindings[a.id]?.split("&").length ?? 0));
|
||||
for (const entry of matches) {
|
||||
if (entry.continuous) return;
|
||||
if (entry.id === "loot") { if (nextActions.lootActiveBoss()) return; else continue; }
|
||||
dispatch(entry.id); return;
|
||||
}
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
runCommand(command, nextActions);
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
|
||||
if (event.defaultPrevented || target?.closest?.("input, textarea, select, [contenteditable='true']") || suspended()) return;
|
||||
if (nextActions.handleCustomKey?.(event)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
keys.delete(event.code);
|
||||
return;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); keys.delete(event.code); return;
|
||||
}
|
||||
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
|
||||
keyboardModifiers.add(event.code);
|
||||
nextActions.setInputMode("keyboard");
|
||||
publishActionLayer();
|
||||
if (!nextActions.gameplayActionsBlocked()) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!nextActions.gameplayActionsBlocked() && GAMEPLAY_AXIS_KEYS.has(event.code)) {
|
||||
event.preventDefault();
|
||||
keys.add(event.code);
|
||||
actions?.setInputMode("keyboard");
|
||||
}
|
||||
if (event.repeat) return;
|
||||
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
|
||||
event.preventDefault();
|
||||
nextActions.setInputMode("keyboard");
|
||||
if (event.code === "Enter") nextActions.confirmGroundTarget();
|
||||
else nextActions.cancelGroundTarget();
|
||||
return;
|
||||
}
|
||||
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
|
||||
if (actionControl) {
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
event.preventDefault();
|
||||
nextActions.setInputMode("keyboard");
|
||||
const layer = actionLayerForModifiers(event.shiftKey, event.altKey);
|
||||
nextActions.setActionLayer(layer);
|
||||
nextActions.castActionBinding(layer, actionControl);
|
||||
return;
|
||||
}
|
||||
const command = keyboardCommandForCode(event.code, event.shiftKey);
|
||||
if (!command) return;
|
||||
const partyCommand = command === "party-attack"
|
||||
|| command === "party-defend"
|
||||
|| command === "party-stop"
|
||||
|| command === "party-recall";
|
||||
// Function keys are browser-reserved on common PC browsers. Keep party
|
||||
// shortcuts inside the game even while a menu temporarily blocks orders.
|
||||
if (partyCommand) event.preventDefault();
|
||||
const overlayCommand = command === "pause"
|
||||
|| command === "map"
|
||||
|| command === "inventory"
|
||||
|| command === "spellbook"
|
||||
|| command === "talents";
|
||||
if (overlayCommand) {
|
||||
runCommand(command, nextActions);
|
||||
return;
|
||||
}
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
if (command === "jump") event.preventDefault();
|
||||
if (event.code === "Tab") event.preventDefault();
|
||||
runCommand(command, nextActions);
|
||||
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
|
||||
if (modifier) { keyboardModifiers.add(modifier); publishActionLayer(); return; }
|
||||
const key = inputKeyForEvent(event);
|
||||
if (!key) return;
|
||||
const bindings = bindingsFor("pc");
|
||||
if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
|
||||
const matches = candidates("pc").filter((entry) => bindings[entry.id] === key)
|
||||
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground"));
|
||||
// Preserve ordinary movement while a modifier is held (e.g. Shift + W).
|
||||
const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
|
||||
if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); }
|
||||
if (!matches.length) return;
|
||||
const entry = matches[0];
|
||||
event.preventDefault(); nextActions.setInputMode("keyboard");
|
||||
if (entry.continuous || event.repeat) return;
|
||||
dispatch(entry.id);
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
keys.delete(event.code);
|
||||
if (keyboardModifiers.delete(event.code)) publishActionLayer();
|
||||
for (const key of keys) if (key === event.code || key.endsWith("+" + event.code)) keys.delete(key);
|
||||
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
|
||||
if (modifier) {
|
||||
keyboardModifiers.delete(modifier);
|
||||
for (const key of keys) if (key.split("+").includes(modifier)) keys.delete(key);
|
||||
}
|
||||
publishActionLayer();
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (mouseLookDragActive) {
|
||||
@@ -385,8 +344,6 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
const clear = () => {
|
||||
keys.clear();
|
||||
keyboardModifiers.clear();
|
||||
controllerL1 = false;
|
||||
controllerL2 = false;
|
||||
clearJumpRequest();
|
||||
mouseLookDragActive = false;
|
||||
previousMousePosition = null;
|
||||
@@ -418,11 +375,23 @@ export function consumeMouseLook(): readonly [number, number] {
|
||||
}
|
||||
|
||||
export function readInputSnapshot(): InputSnapshot {
|
||||
const keyboard = keyboardAxesForKeys(keys);
|
||||
if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) {
|
||||
keys.clear();
|
||||
return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false };
|
||||
}
|
||||
const keyboard = keyboardAxesForKeys(keys, bindingsFor("pc"));
|
||||
|
||||
const controller = getControllerMovement();
|
||||
const [stickMoveX, stickForward] = applyRadialDeadzone(controller.moveX, -controller.moveY);
|
||||
const [stickLookX, stickLookY] = applyRadialDeadzone(controller.lookX, controller.lookY, 0.16);
|
||||
const axes = [controller.moveX, controller.moveY, controller.lookX, controller.lookY];
|
||||
const held = getControllerHeldTokens(); const bindings = bindingsFor("controller");
|
||||
const remapped = inputAxes((id) => {
|
||||
const parts = bindings[id]?.split("&") ?? []; const token = parts.pop();
|
||||
if (!token || !parts.every((part) => held.has(part))) return 0;
|
||||
const axis = /^Axis([0-3])([+-])$/.exec(token);
|
||||
return axis ? Math.max(0, axes[Number(axis[1])] * (axis[2] === "+" ? 1 : -1)) : Number(held.has(token));
|
||||
});
|
||||
const [stickMoveX, stickForward] = applyRadialDeadzone(remapped.moveX, remapped.moveForward);
|
||||
const [stickLookX, stickLookY] = applyRadialDeadzone(remapped.lookX, remapped.lookY, 0.16);
|
||||
|
||||
const [moveX, moveForward] = normalizePlanar(
|
||||
keyboard.moveX + stickMoveX,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { INPUT_ACTIONS, conflictingInputActions, defaultInputDevice, normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
|
||||
import { exportUiPreferences, importUiPreferences, normalizeUiPreferences } from "./uiLayout";
|
||||
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "./keybindPreferences";
|
||||
|
||||
describe("profile keybinds", () => {
|
||||
it("defaults desktop browsers to PC and mobile/native apps to Controller", () => {
|
||||
expect(defaultInputDevice(false, false)).toBe("pc");
|
||||
expect(defaultInputDevice(false, true)).toBe("controller");
|
||||
expect(defaultInputDevice(true, false)).toBe("controller");
|
||||
expect(defaultInputDevice(true, true)).toBe("controller");
|
||||
});
|
||||
it("migrates older profiles with every action, independent input types and 60 WoW slots", () => {
|
||||
const preferences = normalizeUiPreferences({ version: 1 });
|
||||
expect(preferences.keybinds.pc["move-forward"]).toBe("KeyW");
|
||||
expect(preferences.keybinds.controller["move-forward"]).toBe("Axis1-");
|
||||
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("bar:"))).toHaveLength(60);
|
||||
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("ability:"))).toHaveLength(24);
|
||||
expect(Object.keys(preferences.keybinds.pc)).toHaveLength(INPUT_ACTIONS.length);
|
||||
preferences.keybinds.pc.jump = null;
|
||||
expect(preferences.keybinds.controller.jump).toBe("Button10");
|
||||
});
|
||||
it("finds an existing W skill conflict and repairs movement without losing assigned skills or templates", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
preferences.current.pc.bars[4].slots[11] = { key: "KeyW", abilityId: "heal" };
|
||||
preferences.keybinds.pc["move-left"] = null;
|
||||
preferences.slots.mobile[2] = { name: "Travel", layout: preferences.current.mobile };
|
||||
const bindings = profileInputBindings(preferences, "pc");
|
||||
expect(conflictingInputActions(bindings, "pc", "move-forward", "KeyW")).toEqual(["bar:4:11"]);
|
||||
const repaired = restoreMovementBindings(preferences, "pc");
|
||||
expect(repaired.current.pc.bars[4].slots[11]).toEqual({ key: null, abilityId: "heal" });
|
||||
expect(repaired.keybinds.pc["move-left"]).toBe("KeyA");
|
||||
expect(repaired.slots).toEqual(preferences.slots);
|
||||
expect(preferences.current.pc.bars[4].slots[11].key).toBe("KeyW");
|
||||
});
|
||||
it("supports explicit conflict replacement and persistent unbinding", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
const bindings = profileInputBindings(preferences, "pc");
|
||||
expect(conflictingInputActions(bindings, "pc", "bar:0:0", "KeyW")).toEqual(["move-forward"]);
|
||||
const changed = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:0:0", "KeyW"));
|
||||
const restored = importUiPreferences(exportUiPreferences(changed));
|
||||
expect(restored.keybinds.pc["move-forward"]).toBeNull();
|
||||
expect(restored.current.pc.bars[0].slots[0].key).toBe("KeyW");
|
||||
expect(restoreMovementBindings(restored, "pc").current.pc.bars[0].slots[0].key).toBeNull();
|
||||
});
|
||||
it("uses the loaded template's bar keys and changes only the chosen input type", () => {
|
||||
let preferences = normalizeUiPreferences(null);
|
||||
const bindings = profileInputBindings(preferences, "pc");
|
||||
preferences = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:4:11", "Ctrl+Shift+KeyQ"));
|
||||
preferences.current.pc.bars[4].slots[11].key = "KeyZ";
|
||||
expect(profileInputBindings(preferences, "pc")["bar:4:11"]).toBe("KeyZ");
|
||||
const next = withProfileInputBindings(preferences, "controller", replaceInputBinding(preferences.keybinds.controller, "controller", "jump", "Button4&Button3"));
|
||||
expect(next.current.pc).toEqual(preferences.current.pc);
|
||||
expect(importUiPreferences(exportUiPreferences(next)).keybinds.controller.jump).toBe("Button4&Button3");
|
||||
});
|
||||
it("distinguishes contextual defaults and mutually exclusive PC layouts from real collisions", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
for (const device of ["pc", "controller"] as const) {
|
||||
const bindings = profileInputBindings(preferences, device);
|
||||
expect(INPUT_ACTIONS.flatMap((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]))).toEqual([]);
|
||||
}
|
||||
expect(conflictingInputActions(preferences.keybinds.controller, "controller", "menu-confirm", "Button2")).toContain("editor-clear");
|
||||
expect(conflictingInputActions(preferences.keybinds.pc, "pc", "jump", "KeyC")).toContain("companion");
|
||||
});
|
||||
it("validates imported keybinds, normalizes chords and retains defaults for corrupt profile fields", () => {
|
||||
const normalized = normalizeInputPreferences({ pc: { jump: "Shift+Ctrl+KeyQ", inventory: "invalid", pause: null }, controller: { jump: "Button6&Button4&Button3" } });
|
||||
expect(normalized.pc.jump).toBe("Ctrl+Shift+KeyQ");
|
||||
expect(normalized.pc.inventory).toBe("KeyI");
|
||||
expect(normalized.pc.pause).toBeNull();
|
||||
expect(normalized.controller.jump).toBe("Button4&Button6&Button3");
|
||||
const invalid = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
|
||||
invalid.keybinds.controller.jump = "Axis99+";
|
||||
expect(() => importUiPreferences(JSON.stringify(invalid))).toThrow("Invalid keybind");
|
||||
});
|
||||
it("requires clearing a modifier's standalone action before using it in a controller chord", () => {
|
||||
const bindings = normalizeInputPreferences(null).controller;
|
||||
expect(conflictingInputActions(bindings, "controller", "jump", "Button8&Button0")).toEqual(["companion"]);
|
||||
const changed = replaceInputBinding(bindings, "controller", "jump", "Button8&Button0");
|
||||
expect(changed.companion).toBeNull();
|
||||
expect(changed["ability:primary:face-bottom"]).toBe("Button0");
|
||||
expect(conflictingInputActions(changed, "controller", "companion", "Button8")).toEqual(["jump"]);
|
||||
expect(conflictingInputActions(bindings, "controller", "jump", "Button4")).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type InputBindings, type InputDevice, INPUT_ACTIONS, replaceInputBinding } from "../input/keybinds";
|
||||
import { type UiPreferences } from "./uiLayout";
|
||||
|
||||
/** PC bar keys live with their UI layout so loading a template also loads its keys. */
|
||||
export function profileInputBindings(preferences: UiPreferences, device: InputDevice): InputBindings {
|
||||
if (device === "controller") return { ...preferences.keybinds.controller };
|
||||
const result = { ...preferences.keybinds.pc };
|
||||
preferences.current.pc.bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { result[`bar:${b}:${s}`] = slot.key; }));
|
||||
return result;
|
||||
}
|
||||
export function withProfileInputBindings(preferences: UiPreferences, device: InputDevice, bindings: InputBindings): UiPreferences {
|
||||
const layout = preferences.current.pc;
|
||||
return {
|
||||
...preferences,
|
||||
keybinds: { ...preferences.keybinds, [device]: { ...bindings } },
|
||||
current: device !== "pc" ? preferences.current : { ...preferences.current, pc: {
|
||||
...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, key: bindings[`bar:${b}:${s}`] ?? null })) })),
|
||||
} },
|
||||
};
|
||||
}
|
||||
export function restoreMovementBindings(preferences: UiPreferences, device: InputDevice): UiPreferences {
|
||||
let bindings = profileInputBindings(preferences, device);
|
||||
for (const entry of INPUT_ACTIONS.filter((entry) => entry.id.startsWith("move-") || entry.id === "jump")) {
|
||||
bindings = replaceInputBinding(bindings, device, entry.id, entry[device]);
|
||||
}
|
||||
return withProfileInputBindings(preferences, device, bindings);
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("personal UI layouts", () => {
|
||||
layout = bindDesktopSlot(layout, 4, 11, null);
|
||||
expect(layout.bars[4].slots[11].key).toBeNull();
|
||||
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
|
||||
expect(normalizeActionKey("Escape")).toBeNull();
|
||||
expect(normalizeActionKey("Escape")).toBe("Escape");
|
||||
expect(normalizeActionKey("ShiftLeft")).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
@@ -45,7 +45,7 @@ describe("personal UI layouts", () => {
|
||||
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
|
||||
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
|
||||
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
|
||||
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
|
||||
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
|
||||
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
||||
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
||||
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
||||
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
|
||||
import { normalizeInputBinding, INPUT_ACTIONS, normalizeInputPreferences, type InputPreferences } from "../input/keybinds";
|
||||
|
||||
export type UiDevice = "mobile" | "pc";
|
||||
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
|
||||
@@ -31,6 +32,7 @@ export interface UiLayout {
|
||||
export interface UiTemplate { name: string; layout: UiLayout }
|
||||
export interface UiPreferences {
|
||||
version: 1;
|
||||
keybinds: InputPreferences;
|
||||
current: Record<UiDevice, UiLayout>;
|
||||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||||
}
|
||||
@@ -58,7 +60,7 @@ function record(value: unknown): Record<string, unknown> {
|
||||
function bounded(value: unknown, fallback: number, min: number, max: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
|
||||
}
|
||||
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
|
||||
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
|
||||
export function normalizeActionKey(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const parts = value.split("+");
|
||||
@@ -112,6 +114,7 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const slots = record(source.slots);
|
||||
return {
|
||||
version: 1,
|
||||
keybinds: normalizeInputPreferences(source.keybinds),
|
||||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||||
@@ -163,6 +166,18 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
}
|
||||
}
|
||||
};
|
||||
if (source.keybinds !== undefined) {
|
||||
if (!source.keybinds || typeof source.keybinds !== "object" || Array.isArray(source.keybinds)) throw new Error("Invalid keybind configuration.");
|
||||
for (const device of ["pc", "controller"] as const) {
|
||||
const raw = record(source.keybinds)[device];
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid keybind configuration.");
|
||||
const bindings = record(record(source.keybinds)[device]);
|
||||
for (const entry of INPUT_ACTIONS) {
|
||||
const key = bindings[entry.id];
|
||||
if (key !== undefined && key !== null && !normalizeInputBinding(device, key)) throw new Error("Invalid keybind configuration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const device of UI_DEVICES) {
|
||||
validateLayout(record(source.current)[device]);
|
||||
const slots = record(source.slots)[device];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Capacitor } from "@capacitor/core";
|
||||
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
|
||||
export function currentUiDevice(): UiDevice {
|
||||
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
@@ -49,6 +50,8 @@ function activateOwner() {
|
||||
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
}
|
||||
activateOwner();
|
||||
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
|
||||
useUiLayoutStore.subscribe((state) => publishInputPreferences(state.preferences.keybinds));
|
||||
useShellStore.subscribe((state, previous) => {
|
||||
if (state.session !== previous.session) activateOwner();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useArenaStore } from "./arenaStore";
|
||||
import type { ArenaActorSnapshot } from "./arenaTypes";
|
||||
|
||||
/** Existing character panels read this view; the arena server owns combat. */
|
||||
export function useArenaCombatView(character: CharacterProfile | null, actor: ArenaActorSnapshot | null) {
|
||||
const mirroring = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
const previous = useCombatStore.getState();
|
||||
const previousParty = usePartyStore.getState();
|
||||
const previousGame = useGameStore.getState();
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
usePartyStore.setState({ members: [] });
|
||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||
const unsubscribe = useCombatStore.subscribe((state, before) => {
|
||||
if (mirroring.current) return;
|
||||
if (state.actionBindings !== before.actionBindings) {
|
||||
useArenaStore.getState().intent({ type: "bindings", actionBindings: state.actionBindings });
|
||||
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
|
||||
}
|
||||
if (state.settings !== before.settings) useShellStore.getState().saveActiveSettings(state.settings);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
useCombatStore.setState(previous, true);
|
||||
usePartyStore.setState(previousParty, true);
|
||||
useGameStore.setState(previousGame, true);
|
||||
};
|
||||
}, [character?.id]);
|
||||
useEffect(() => {
|
||||
if (!actor) return;
|
||||
mirroring.current = true;
|
||||
const runtime = actor.combat;
|
||||
useCombatStore.setState({ health: actor.health, maxHealth: actor.maxHealth,
|
||||
resource: actor.resource, maxResource: actor.maxResource, level: actor.level,
|
||||
...(actor.actionBindings ? { actionBindings: actor.actionBindings } : {}),
|
||||
...(runtime ? { resourcePools: runtime.resourcePools, maxResourcePools: runtime.maxResourcePools,
|
||||
deathKnightRunes: runtime.runes, comboPoints: runtime.comboPoints, comboTargetId: runtime.comboTargetId,
|
||||
shield: runtime.shield, activeCast: runtime.activeCast, talentRanks: runtime.talentRanks,
|
||||
gearStats: runtime.gearStats, specializationId: runtime.specializationId } : {}) });
|
||||
mirroring.current = false;
|
||||
}, [actor]);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { controlledArenaActor } from "./arenaPlayer";
|
||||
import { useArenaStore } from "./arenaStore";
|
||||
import { createArenaCharacterKit } from "./arenaCharacter";
|
||||
import { actionBindingId, type ActionBindingLayer } from "./actionBindings";
|
||||
import { activeUiLayout, currentUiDevice } from "./uiLayoutStore";
|
||||
import { actionKeyForEvent, desktopSlotForKey } from "./uiLayout";
|
||||
import { endMouseLookDrag, installInput, isDesktopCameraLookPointer } from "./inputManager";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export function useArenaInput(options: {
|
||||
character: CharacterProfile | null;
|
||||
blocked: boolean;
|
||||
cast: (id: string) => void;
|
||||
togglePause: () => void;
|
||||
setLayer: (layer: ActionBindingLayer) => void;
|
||||
setInputMode: (mode: "keyboard" | "gamepad") => void;
|
||||
groundTargetActive: () => boolean;
|
||||
confirmGroundTarget: () => void;
|
||||
cancelGroundTarget: () => void;
|
||||
}) {
|
||||
const current = useRef(options);
|
||||
current.current = options;
|
||||
useEffect(() => {
|
||||
const cycle = (direction: 1 | -1, friendly: boolean) => {
|
||||
const state = useArenaStore.getState();
|
||||
const actor = controlledArenaActor(state.snapshot, current.current.character);
|
||||
if (!actor) return;
|
||||
const targets = state.snapshot!.actors.filter((candidate) => candidate.alive && (friendly ? candidate.team === actor.team : candidate.team !== actor.team));
|
||||
const index = targets.findIndex((candidate) => candidate.id === actor.targetActorId);
|
||||
const selected = targets[(index + direction + targets.length) % targets.length];
|
||||
if (selected) state.intent({ type: "target", targetActorId: selected.id });
|
||||
};
|
||||
const noop = () => {};
|
||||
const uninstall = installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
castDesktopSlot: (bar, slot) => {
|
||||
const id = activeUiLayout().bars[bar]?.slots[slot]?.abilityId;
|
||||
if (id) current.current.cast(id);
|
||||
},
|
||||
handleCustomKey: (event) => {
|
||||
if (current.current.blocked || currentUiDevice() !== "pc") return false;
|
||||
const slot = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
|
||||
if (!slot) return false;
|
||||
if (!event.repeat && slot.abilityId) current.current.cast(slot.abilityId);
|
||||
current.current.setInputMode("keyboard");
|
||||
return true;
|
||||
},
|
||||
togglePause: () => current.current.togglePause(),
|
||||
toggleMap: () => useGameStore.getState().toggleOverlay("map"),
|
||||
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
|
||||
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
|
||||
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
|
||||
togglePanel: (panel) => useGameStore.getState().toggleOverlay(panel),
|
||||
lootActiveBoss: () => false,
|
||||
castActionBinding: (layer, control) => {
|
||||
const character = current.current.character;
|
||||
if (!character) return;
|
||||
const actor = controlledArenaActor(useArenaStore.getState().snapshot, character);
|
||||
const bindings = actor?.actionBindings ?? createArenaCharacterKit(character).actionBindings;
|
||||
const id = bindings[actionBindingId(layer, control)];
|
||||
if (id) current.current.cast(id);
|
||||
},
|
||||
cycleTarget: (direction) => cycle(direction, false),
|
||||
cyclePartyTarget: (direction) => cycle(direction, true),
|
||||
setPartyCommand: (command) => useArenaStore.getState().intent({ type: "command", command }),
|
||||
recallParty: () => useArenaStore.getState().intent({ type: "command", command: "recall" }),
|
||||
clearTarget: () => useArenaStore.getState().intent({ type: "target", targetActorId: "" }),
|
||||
groundTargetActive: () => current.current.groundTargetActive(),
|
||||
confirmGroundTarget: () => current.current.confirmGroundTarget(),
|
||||
cancelGroundTarget: () => current.current.cancelGroundTarget(),
|
||||
gameplayActionsBlocked: () => current.current.blocked,
|
||||
setCameraLookActive: noop,
|
||||
setInputMode: (mode) => current.current.setInputMode(mode),
|
||||
setActionLayer: (layer) => current.current.setLayer(layer),
|
||||
});
|
||||
const stopMovement = () => {
|
||||
endMouseLookDrag();
|
||||
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => { if (isDesktopCameraLookPointer("mouse", event.button)) endMouseLookDrag(); };
|
||||
window.addEventListener("mouseup", mouseUp, true);
|
||||
window.addEventListener("blur", stopMovement);
|
||||
return () => {
|
||||
window.removeEventListener("mouseup", mouseUp, true);
|
||||
window.removeEventListener("blur", stopMovement);
|
||||
stopMovement();
|
||||
uninstall();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (options.blocked) {
|
||||
endMouseLookDrag();
|
||||
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
|
||||
}
|
||||
}, [options.blocked]);
|
||||
}
|
||||
Reference in new issue
Block a user