Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+128 -121
View File
@@ -1,6 +1,6 @@
import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Component, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Group, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel";
@@ -8,10 +8,27 @@ import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore";
import { arenaCameraPose } from "./game/arenaCamera";
import { arenaCameraPose, arenaSpawnYaw } from "./game/arenaCamera";
import { ArenaPlayerCamera } from "./scene/ArenaPlayerCamera";
import { ArenaActionBar } from "./ui/ArenaActionBar";
import { controlledArenaActor, arenaTargetActor } from "./game/arenaPlayer";
import { abilityById, abilityAtLevel } from "./game/abilityCatalog";
import type { ActionBindingLayer } from "./game/actionBindings";
import { useArenaInput } from "./game/useArenaInput";
import { beginMouseLookDrag, endMouseLookDrag, isDesktopCameraLookPointer } from "./game/inputManager";
import type { CharacterCombatAnimationEvent } from "./game/combatAnimation";
import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
import { ArenaCombatEffects, ArenaGroundTarget } from "./scene/ArenaCombatEffects";
import { ArenaPanels } from "./ui/ArenaPanels";
import { useArenaCombatView } from "./game/useArenaCombatView";
import { useGameStore } from "./game/store";
import { targetingRulesForAbility } from "./game/abilityRules";
import type { Vec3Tuple } from "./game/arenaTypes";
import { ArenaNavigationSurface } from "./scene/ArenaNavigationSurface";
const ArenaFallbackStage = lazy(() => import("./scene/ArenaFallbackStage"));
class ArenaStageErrorBoundary extends Component<{
readonly children: ReactNode;
@@ -37,7 +54,7 @@ function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onRea
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} floorOnly />
<ArenaNavigationSurface stageId={stage.id} />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
@@ -56,53 +73,6 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
return <Clone object={gltf.scene} />;
}
function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return (
<group>
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</mesh>
{!floorOnly && (
<>
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
{[
[centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
[centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
[stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
[stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
<meshStandardMaterial color="#26342d" roughness={0.88} />
</mesh>
))}
{stage.teamSpawns.map((spawn, index) => (
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
</mesh>
))}
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
</mesh>
))}
</>
)}
</group>
);
}
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
@@ -154,17 +124,21 @@ function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; ob
);
}
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
const group = useRef<Group>(null);
function ArenaActor({ actor, local, character, player }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; player: RefObject<Group | null> }) {
const other = useRef<Group>(null);
const group = local ? player : other;
const initial = useRef<[number, number, number]>([...actor.position]);
const target = useMemo(() => new Vector3(), []);
const movingRef = useRef(false);
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(null);
useFrame((_, delta) => {
const node = group.current;
if (!node) return;
if (local) {
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
}
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
node.rotation.y = actor.facing;
target.set(...actor.position);
movingRef.current = node.position.distanceToSquared(target) > 0.0025;
node.position.lerp(target, Math.min(1, delta * 18));
if (Number.isFinite(actor.facing)) node.rotation.y = actor.facing;
animationEventRef.current = actor.combat?.playerAnimationEvent ?? null;
});
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
const cosmeticEquipment = useMemo(
@@ -172,9 +146,10 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
[character],
);
return (
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
{local && character ? (
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
<group ref={group} name={`arena-actor:${actor.id}`} position={initial.current} visible={actor.alive}
onPointerDown={(event) => { if (event.button === 0) useArenaStore.getState().intent({ type: "target", targetActorId: actor.id }); }}>
{(local && character) || actor.avatar ? (
<CharacterModel identity={local && character ? character : actor.avatar} equipment={local ? cosmeticEquipment : actor.visualEquipment} movingRef={movingRef} animationEventRef={animationEventRef} fallback={<ProceduralAvatar accent={color} />} />
) : (
<group>
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
@@ -186,26 +161,16 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
);
}
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => {
if (!actor) return;
target.set(...pose.target);
desired.set(...pose.position);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, blocked, onStageReady, onStageError, groundTarget, groundPosition, controller }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; blocked: boolean; onStageReady: () => void; onStageError: (error: unknown) => void; groundTarget: string | null; groundPosition: RefObject<Vec3Tuple>; controller: boolean }) {
const snapshot = useArenaStore((state) => state.snapshot);
const player = useRef<Group>(null);
const environment = useRef<Group>(null);
const initialCamera = useMemo(() => arenaCameraPose(localActor?.position ?? stage.teamSpawns[0], arenaSpawnYaw(stage, localActor?.team ?? 0)).position, [stage]);
return (
<Canvas
shadows
dpr={[1, 1.35]}
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
camera={{ fov: 62, near: 0.05, far: 1200, position: initialCamera }}
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
>
<color attach="background" args={["#111815"]} />
@@ -213,14 +178,19 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<group ref={environment}>
{detailedAsset ? (
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
<Suspense fallback={<Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
) : <ProceduralStage stage={stage} />}
) : <Suspense fallback={null}><ArenaFallbackStage stageId={stage.id} /></Suspense>}
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} />
</group>
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} player={player} />)}
<ArenaPlayerCamera key={`${stage.id}:${localActor?.id}`} actor={localActor} stage={stage} player={player} environment={environment} movement={movement} blocked={blocked} />
{snapshot && <ArenaCombatEffects snapshot={snapshot} actorId={localActor?.id} />}
{groundTarget && localActor && <ArenaGroundTarget environment={environment} position={groundPosition} origin={localActor.position}
radius={abilityById(groundTarget)?.radius ?? 8} controller={controller} />}
</GameGltfLoaderLifecycle>
</Canvas>
);
@@ -246,13 +216,16 @@ function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: Ar
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
const snapshot = useArenaStore((state) => state.snapshot);
const selected = snapshot?.actors.find((actor) => actor.id === snapshot.controlledActorId)?.targetActorId;
return (
<div className={`arena-team-frames arena-team-frames--${team}`}>
{actors.filter((actor) => actor.team === team).map((actor) => (
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<button key={actor.id} type="button" className={`${!actor.alive ? "is-dead" : ""} ${selected === actor.id ? "is-selected" : ""}`} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
<em>{actor.health}</em>
<em>{Math.ceil(actor.health)} / {actor.maxHealth}</em>
{actor.combat?.activeCast && <small>{abilityById(actor.combat.activeCast.abilityId)?.name ?? "Casting"}</small>}
</button>
))}
</div>
@@ -269,7 +242,20 @@ export default function ArenaRuntime() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const localActor = controlledArenaActor(snapshot, character);
const feedback = useArenaStore((state) => state.error);
const overlay = useGameStore((state) => state.overlay);
const companionOpen = useGameStore((state) => state.companionOpen);
const paused = overlay !== null || companionOpen;
const setPaused = (value: boolean | ((previous: boolean) => boolean)) => {
const next = typeof value === "function" ? value(paused) : value;
if (next) useGameStore.getState().openOverlay("pause"); else useGameStore.getState().closeOverlay();
};
const [groundTarget, setGroundTarget] = useState<string | null>(null);
const groundPosition = useRef<Vec3Tuple>([0, 0, 0]);
useArenaCombatView(character, localActor);
const [actionLayer, setActionLayer] = useState<ActionBindingLayer>("primary");
const [inputMode, setInputMode] = useState<"keyboard" | "gamepad">("keyboard");
const movementRef = useRef<[number, number]>([0, 0]);
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
@@ -308,38 +294,38 @@ export default function ArenaRuntime() {
});
}, [character, load, session, snapshot]);
useEffect(() => {
const pressed = new Set<string>();
const movement = () => {
const next: [number, number] = [
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
];
movementRef.current = next;
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
};
const down = (event: KeyboardEvent) => {
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
pressed.add(event.code);
movement();
}
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
};
const up = (event: KeyboardEvent) => {
if (pressed.delete(event.code)) movement();
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [intent]);
const cast = useCallback((abilityId: string) => {
const state = useArenaStore.getState();
const player = controlledArenaActor(state.snapshot, character);
if (!player || useGameStore.getState().overlay || state.result) return;
const definition = abilityById(abilityId);
const ability = definition ? abilityAtLevel(definition, player.level ?? character?.level ?? 80) : null;
if (ability && targetingRulesForAbility(ability).shape === "ground" && (targetingRulesForAbility(ability).groundRange ?? 0) > 0) {
groundPosition.current = [player.position[0] + Math.sin(player.facing) * 8, player.position[1], player.position[2] + Math.cos(player.facing) * 8];
setGroundTarget(abilityId);
return;
}
let target = arenaTargetActor(state.snapshot, player.targetActorId) ?? undefined;
if (target && !target.alive && !ability?.effects.some((effect) => effect.kind === "resurrection")) target = undefined;
if (ability?.target === "self" || (ability?.target === "friendly" && target?.team !== player.team)) target = player;
if (ability?.target === "hostile" && (!target || target.team === player.team)) {
target = state.snapshot?.actors.filter((actor) => actor.alive && actor.team !== player.team).sort((a, b) => (
Math.hypot(a.position[0] - player.position[0], a.position[2] - player.position[2])
- Math.hypot(b.position[0] - player.position[0], b.position[2] - player.position[2])
))[0];
}
if (target && target.id !== player.id) state.intent({ type: "target", targetActorId: target.id });
state.intent({ type: "ability", abilityId, targetActorId: target?.id });
}, [character]);
const confirmGroundTarget = () => {
if (!groundTarget) return;
intent({ type: "ability", abilityId: groundTarget, groundPosition: groundPosition.current });
setGroundTarget(null);
};
useArenaInput({ character, blocked: paused || Boolean(result) || !localActor?.alive,
cast, togglePause: () => setPaused((value) => !value), setLayer: setActionLayer, setInputMode,
groundTargetActive: () => Boolean(groundTarget), confirmGroundTarget,
cancelGroundTarget: () => { setGroundTarget(null); intent({ type: "command", command: "cancel-cast" }); } });
const exit = async () => {
await leave(session);
@@ -351,19 +337,40 @@ export default function ArenaRuntime() {
const detailedAsset = stageFailure?.stageId !== stage.id;
return (
<main className="arena-runtime">
<div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
<div className="arena-world" aria-label="Arena 3D view" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}
onPointerDown={(event) => {
if (event.button === 0 && groundTarget && !paused) { confirmGroundTarget(); return; }
if (paused || result || !isDesktopCameraLookPointer(event.pointerType, event.button)) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
beginMouseLookDrag(event.clientX, event.clientY);
}}
onPointerUp={(event) => { if (event.button === 2) endMouseLookDrag(); }}
onLostPointerCapture={() => endMouseLookDrag()}
onContextMenu={(event) => event.preventDefault()}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} blocked={paused || Boolean(result)} onStageReady={handleStageReady} onStageError={handleStageError} groundTarget={groundTarget} groundPosition={groundPosition} controller={inputMode === "gamepad"} /></div>
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
<header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header>
<TeamFrames actors={snapshot.actors} team={0} />
<TeamFrames actors={snapshot.actors} team={1} />
<div className="arena-combat-log" role="log" aria-label="Arena combat events">{(snapshot.combatEvents ?? []).filter((event) => snapshot.serverTime - event.occurredAt < 3000 && (event.sourceActorId === localActor?.id || event.targetActorId === localActor?.id) && ["damage", "healing", "interrupt"].includes(event.kind)).slice(-4).map((event) => <div key={event.id}>{abilityById(event.abilityId ?? "")?.name ?? event.kind}: {event.outcome === "miss" ? "Miss" : Math.round(event.effectiveAmount)} {event.absorbed > 0 ? `(${event.absorbed} absorbed)` : ""}</div>)}</div>
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
<nav className="arena-action-bar" aria-label="Arena abilities">
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
))}
{character && <ArenaActionBar character={character} actor={localActor} layer={actionLayer} inputMode={inputMode}
blocked={paused || Boolean(result)} onCast={cast} feedback={feedback} />}
{groundTarget && <div className="arena-ground-hint" role="status">Place {abilityById(groundTarget)?.name} · Click or confirm to cast · Escape to cancel</div>}
{character && <ArenaPanels character={character} actor={localActor} snapshot={snapshot} stage={stage} cast={cast} />}
<nav className="arena-toolbar" aria-label="Arena character menus">
{(["spellbook", "inventory", "talents", "bindings", "keybinds", "map", "options"] as const).map((panel) => <button key={panel} type="button" onClick={() => useGameStore.getState().toggleOverlay(panel)}>{panel === "inventory" ? "Character" : panel}</button>)}
</nav>
{overlay === "pause" && !result && <section className="arena-result-panel" role="dialog" aria-label="Arena menu">
<h1>Arena menu</h1><p>Your character stays in the match while this menu is open.</p>
<button type="button" className="front-button" onClick={() => setPaused(false)}>Resume</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("options")}>Options</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("keybinds")}>Keybinds</button>
<button type="button" className="front-button" onClick={() => useGameStore.getState().openOverlay("bindings")}>Assign abilities</button>
<button type="button" className="front-button" onClick={() => void exit()}>Leave arena</button>
</section>}
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
{result && (
<section className="arena-result-panel" role="dialog" aria-modal="true">
+17 -10
View File
@@ -30,7 +30,9 @@ import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout";
import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
import { KeybindsPanel } from "./ui/KeybindsPanel";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -38,16 +40,18 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
handleCustomKey: (event) => {
const game = useGameStore.getState();
if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code);
if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false;
const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
if (!button) return false;
if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
game.setInputMode("keyboard");
return true;
inputSuspended: () => ["ui-layout", "keybinds"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
for (let b = activeUiLayout().barCount; b < 5; b++) for (let s = 0; s < 12; s++) bindings[`bar:${b}:${s}`] = null;
return bindings;
},
castDesktopSlot: (bar, slot) => {
const game = useGameStore.getState(); const button = activeUiLayout().bars[bar]?.slots[slot];
if (button?.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
},
togglePanel: (panel) => useGameStore.getState().toggleOverlay(panel),
togglePause: () => useGameStore.getState().togglePause(),
toggleMap: () => useGameStore.getState().toggleMap(),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
@@ -93,6 +97,7 @@ function InputBridge() {
}
export default function GameRuntime() {
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
useEffect(() => () => {
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
@@ -102,6 +107,7 @@ export default function GameRuntime() {
return (
<DualDisplayFrame
contextLabel="Tactical"
companionDisabled={editingControls}
onActiveSurfaceChange={(surface) => useGameStore.getState().setCompanionOpen(surface === "bottom")}
top={
<main className="game-shell">
@@ -119,6 +125,7 @@ export default function GameRuntime() {
<GameplayPanels />
<PauseMenu />
<UiLayoutEditor />
<KeybindsPanel />
<GmReviewCard />
<GmWorldEditor />
</main>
+35 -4
View File
@@ -212,7 +212,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
}
const legacyV3Storage = new MemoryStorage();
@@ -231,13 +231,13 @@ describe("local profile repository", () => {
},
}));
expect(loadCombatPresentationSettings("offline-roster", legacyV3Storage)).toMatchObject({
vfxMode: "generated",
vfxMode: "original",
vfxQuality: "low",
});
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("original");
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(false);
.presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(true);
const storage = new MemoryStorage();
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
@@ -258,6 +258,37 @@ describe("local profile repository", () => {
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
it("migrates the previous media default once while preserving combat controls", () => {
const storage = new MemoryStorage();
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 5,
accounts: [],
rosters: { "offline-roster": [] },
presentationSettingsByOwner: {
"offline-roster": {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false,
vfxMode: "generated",
vfxQuality: "low",
audioEnabled: { player: true, party: false, enemy: true },
},
},
}));
expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "low",
audioEnabled: { player: true, party: false, enemy: true },
});
updateCombatPresentationSettings("offline-roster", {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false, vfxMode: "generated",
}, storage);
expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({
originalMediaEnabled: false, vfxMode: "generated",
});
});
it("migrates custom-class profiles into the CoA category", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+15 -6
View File
@@ -61,7 +61,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 5;
version: 6;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
@@ -104,7 +104,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 5, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -206,7 +206,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -232,13 +232,22 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
let presentationSettingsMigrated = false;
const presentationSettingsByOwner = Object.fromEntries(
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => {
const normalized = normalizeCombatPresentationSettings(settings);
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { vfxMode?: unknown }).vfxMode
: undefined;
const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled
: undefined;
// Move saves using the former generated-only default to the new default
// once, retaining their quality, visibility, mute and volume preferences.
const migrateMediaDefault = (parsed.version ?? 0) < 6
&& storedMode === "generated"
&& storedOriginalMediaEnabled !== true;
const normalized = normalizeCombatPresentationSettings(migrateMediaDefault ? {
...(settings as Record<string, unknown>),
originalMediaEnabled: true,
vfxMode: "original",
} : settings);
if (
storedMode !== normalized.vfxMode
|| storedOriginalMediaEnabled !== normalized.originalMediaEnabled
@@ -248,8 +257,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
);
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(5);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+7
View File
@@ -13,9 +13,13 @@ describe("profile UI persistence", () => {
it("isolates owners and persists templates without requiring a character", () => {
const local = storage(); const ui = normalizeUiPreferences(null);
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
ui.keybinds.pc["move-forward"] = "ArrowUp";
ui.keybinds.controller.jump = "Button4&Button3";
saveUiPreferences("one", ui, local);
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
expect(loadUiPreferences("one", local).keybinds).toEqual(ui.keybinds);
expect(loadUiPreferences("two", local).keybinds.pc["move-forward"]).toBe("KeyW");
ui.slots.pc[1].name = "Unsaved change";
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
});
@@ -34,6 +38,9 @@ describe("profile UI persistence", () => {
const ui = normalizeUiPreferences(null);
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
ui.keybinds.pc["move-forward"] = "ArrowUp";
ui.keybinds.pc["look-up"] = null;
ui.keybinds.controller.jump = "Button4&Button3";
saveUiPreferences(session.ownerId, ui);
await vi.advanceTimersByTimeAsync(501);
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
+19 -13
View File
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { CONTROLLER_SYSTEM_BACK_TOKEN, subscribeControllerToken } from "../input/controller";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { isControllerMenuBackToken } from "../input/useMenuController";
import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
@@ -8,11 +12,13 @@ interface DualDisplayFrameProps {
bottom: ReactNode;
contextLabel?: string;
onActiveSurfaceChange?: (surface: DisplaySurface) => void;
companionDisabled?: boolean;
}
const FOCUSABLE = "button:not([disabled]):not([tabindex='-1']), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])";
export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onActiveSurfaceChange }: DualDisplayFrameProps) {
export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onActiveSurfaceChange, companionDisabled = false }: DualDisplayFrameProps) {
const bindingLabel = useInputBindingLabels();
const params = new URLSearchParams(window.location.search);
const dedicatedSurface = params.get("display");
const layout = resolveDisplayLayout({ display: dedicatedSurface, layout: params.get("layout") });
@@ -38,17 +44,17 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
requestDisplaySurface("top");
const unsubscribeSurface = subscribeDisplaySurface(showSurface);
const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false) return;
if (token === "Button8" && !repeat) toggleSurface();
if ((token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) && !repeat && activeRef.current === "bottom") {
if (pressed === false || isCapturingInput() || companionDisabled) return;
if (controllerBindingMatches(getInputPreferences().controller.companion, token, getControllerHeldTokens()) && !repeat) toggleSurface();
if (isControllerMenuBackToken(token) && !repeat && activeRef.current === "bottom") {
requestDisplaySurface("top");
}
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.defaultPrevented) return;
if (event.repeat || event.defaultPrevented || isCapturingInput() || companionDisabled) return;
const target = event.target as HTMLElement | null;
const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false;
if (event.code === "KeyC" && !isEditable) {
if (inputKeyForEvent(event) && inputKeyForEvent(event) === getInputPreferences().pc.companion && !isEditable) {
event.preventDefault();
toggleSurface();
}
@@ -61,7 +67,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
onSurfaceChangeRef.current?.("top");
requestDisplaySurface("top");
};
}, [dedicatedSurface, layout, showSurface, toggleSurface]);
}, [companionDisabled, dedicatedSurface, layout, showSurface, toggleSurface]);
useEffect(() => {
if (layout !== "single" || activeSurface !== "bottom") return;
@@ -73,8 +79,8 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
});
const onModalKeyDown = (event: KeyboardEvent) => {
const dialog = dialogRef.current;
if (!dialog) return;
if (event.key === "Escape") {
if (!dialog || isCapturingInput() || event.defaultPrevented) return;
if (inputKeyForEvent(event) && [getInputPreferences().pc["menu-back"], getInputPreferences().pc["menu-back-alt"]].includes(inputKeyForEvent(event))) {
event.preventDefault();
requestDisplaySurface("top");
return;
@@ -122,9 +128,9 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
<div className="single-context-surface">{bottom}</div>
</div>
)}
<button className="single-context-toggle" type="button" onClick={toggleSurface} aria-expanded={contextOpen}>
<b>{contextOpen ? "Return" : contextLabel}</b><small>Select / C</small>
</button>
{!companionDisabled && <button className="single-context-toggle" type="button" onClick={toggleSurface} aria-expanded={contextOpen}>
<b>{contextOpen ? "Return" : contextLabel}</b><small>{bindingLabel("controller", "companion")} / {bindingLabel("pc", "companion")}</small>
</button>}
</div>
);
}
+39 -6
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
import { ARENA_STAGES } from "../game/arenaStages";
@@ -9,10 +10,11 @@ const workerSource = readFileSync(
"utf8",
);
function contentWorker(cached?: Response) {
function contentWorker(cached?: Response, bundledFiles: Record<string, unknown> = {}, object?: Response) {
const handlers = new Map<string, (event: unknown) => void>();
const fetch = vi.fn(async () => new Response("bundled arena", { status: 200 }));
const match = vi.fn(async () => cached);
const fetch = vi.fn(async (url: RequestInfo | URL) => String(url) === "/content/bootstrap-manifest.json"
? Response.json({ files: bundledFiles }) : object?.clone() ?? new Response("bundled arena", { status: 200 }));
const match = vi.fn(async (url: RequestInfo | URL) => String(url).includes("/content/cache-objects/") ? object?.clone() : cached?.clone());
runInNewContext(workerSource, {
self: {
location: { origin: "https://healer.test" },
@@ -22,10 +24,13 @@ function contentWorker(cached?: Response) {
fetch,
URL,
Response,
Headers,
DecompressionStream,
});
return {
fetch,
match,
refresh: () => handlers.get("message")!({ data: { type: "refresh-content-bootstrap" } }),
request: (path: string) => {
let response: Promise<Response> | undefined;
handlers.get("fetch")!({
@@ -45,7 +50,7 @@ describe("browser content cache worker", () => {
expect(response?.status).toBe(200);
expect(await response?.text()).toBe("bundled arena");
}
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length);
expect(worker.fetch).toHaveBeenCalledTimes(stage.assets.length + 1);
});
it("prefers installed arena updates over bundled files, ignoring the runtime query", async () => {
@@ -61,7 +66,8 @@ describe("browser content cache worker", () => {
const response = await worker.request("/assets/game/manastorm/optional/visual.glb");
expect(response?.status).toBe(404);
expect(await response?.text()).toBe("Optional content is not installed.");
expect(worker.fetch).not.toHaveBeenCalled();
expect(worker.fetch).toHaveBeenCalledTimes(1);
expect(worker.fetch).toHaveBeenCalledWith("/content/bootstrap-manifest.json", { cache: "no-cache" });
});
it("serves only cached same-origin asset requests from the active content cache", () => {
@@ -70,11 +76,38 @@ describe("browser content cache worker", () => {
expect(workerSource).toContain("url.origin !== self.location.origin");
expect(workerSource).toContain("MANAGED_ASSET_PREFIXES.some");
expect(workerSource).toContain('"/assets/game/"');
expect(workerSource).toContain("if (cached) return cached");
expect(workerSource).toContain("if (!objectPath) return cached");
expect(workerSource).toContain("BUNDLED_ASSET_PATHS.has(url.pathname)");
expect(workerSource).toContain("BUNDLED_ASSET_PREFIXES.some");
expect(workerSource).not.toContain('"/assets/characters/wow335a-v1/textures/character/human/",');
expect(workerSource).toContain('status: 404');
expect(workerSource).not.toContain("cached ?? fetch(request)");
});
it("expands bundled gzip without requiring a Manastorm install", async () => {
const url = "/assets/game/manastorm/shared/visual.glb";
const original = "glTF original dungeon".repeat(100);
const worker = contentWorker(undefined, { [url]: { download: { url: "/content/bundled/object.glb.gz", encoding: "gzip" } } }, new Response(gzipSync(original)));
const response = await worker.request(url);
expect(await response?.text()).toBe(original);
expect(response?.headers.get("content-type")).toBe("model/gltf-binary");
});
it("resolves a tiny installed alias to one compressed shared object", async () => {
const original = "glTF installed original".repeat(100);
const worker = contentWorker(new Response(null, { headers: { "x-healer-man-content-object": "/content/cache-objects/shared.glb" } }), {},
new Response(gzipSync(original), { headers: { "x-healer-man-content-encoding": "gzip" } }));
expect(await (await worker.request("/assets/creatures/optional/model.glb"))?.text()).toBe(original);
expect(worker.fetch).not.toHaveBeenCalled();
});
it("refreshes bootstrap mappings when a new app session starts", async () => {
const path = "/assets/game/dungeons/new/visual.glb";
const files: Record<string, unknown> = {};
const worker = contentWorker(undefined, files, new Response(gzipSync("new original")));
expect((await worker.request(path))?.status).toBe(404);
files[path] = { download: { url: "/content/bundled/new.glb.gzip", encoding: "gzip" } };
worker.refresh();
expect(await (await worker.request(path))?.text()).toBe("new original");
});
});
+64 -3
View File
@@ -1,8 +1,10 @@
import { createHash } from "node:crypto";
import { gzipSync, gunzipSync } from "node:zlib";
import { Capacitor } from "@capacitor/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BROWSER_CONTENT_CACHE_NAME,
browserContentObjectPath,
checkForContentUpdates,
contentPackHasUpdate,
contentPackIsBundled,
@@ -120,7 +122,8 @@ describe("browser content packs", () => {
error: "",
});
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model");
const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]);
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
await checkForContentUpdates();
@@ -129,12 +132,13 @@ describe("browser content packs", () => {
corruptUpdate = true;
await expect(installContentPack("characters:test")).rejects.toThrow(/verification/i);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("first-model");
expect(await (await active?.match(originalObject))?.text()).toBe("first-model");
corruptUpdate = false;
await installContentPack("characters:test");
expect(contentPackHasUpdate("characters:test")).toBe(false);
expect(await (await active?.match("http://localhost/assets/characters/test.glb"))?.text()).toBe("updated-model");
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("updated-model");
expect(await active?.match(originalObject)).toBeUndefined();
await removeContentPack("characters:test");
expect(contentPackIsInstalled("characters:test")).toBe(false);
@@ -151,6 +155,63 @@ describe("browser content packs", () => {
expect(contentPackIsUsable("core:ui")).toBe(true);
});
it("stores a compressed duplicate once and retains it until its last pack is removed", async () => {
const original = new TextEncoder().encode("original shared geometry ".repeat(200));
const compressed = gzipSync(original);
const firstPath = "/assets/characters/shared-first.glb";
const secondPath = "/assets/characters/shared-second.glb";
const thirdPath = "/assets/characters/shared-third.glb";
const base = contentManifest("compressed", "original.glb", original);
const entry = { ...base.files["/assets/characters/test.glb"], download: {
url: "/content/objects/shared.glb.gz", encoding: "gzip" as const, sha256: sha256(compressed), bytes: compressed.length,
} };
const manifest: ContentManifest = { ...base, packs: [
{ ...base.packs[0], id: "characters:shared-first", fileCount: 2 },
{ ...base.packs[0], id: "characters:shared-second" },
], files: {
[firstPath]: { ...entry, packId: "characters:shared-first" },
[secondPath]: { ...entry, packId: "characters:shared-first" },
[thirdPath]: { ...entry, packId: "characters:shared-second" },
} };
const storage = new MemoryCacheStorage();
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubGlobal("caches", storage as unknown as CacheStorage);
const fetcher = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed));
vi.stubGlobal("fetch", fetcher);
await checkForContentUpdates();
await installContentPack("characters:shared-first");
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME)!;
expect(active.responses.size).toBe(3);
const objectPath = browserContentObjectPath(entry);
expect(gunzipSync(Buffer.from(await (await active.match(objectPath))!.arrayBuffer()))).toEqual(Buffer.from(original));
expect(getContentManagerSnapshot().totalBytes).toBe(compressed.length);
await installContentPack("characters:shared-second");
expect(getContentManagerSnapshot().totalBytes).toBe(0);
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith(".gz"))).toHaveLength(1);
await removeContentPack("characters:shared-first");
expect(await active.match(objectPath)).toBeDefined();
expect(contentPackIsUsable("characters:shared-second")).toBe(true);
await removeContentPack("characters:shared-second");
expect(active.responses.size).toBe(0);
});
it("rejects a valid gzip payload that expands to the wrong original model", async () => {
const original = new TextEncoder().encode("expected model");
const compressed = gzipSync("different model");
const base = contentManifest("bad-compressed", "model.glb", original);
const manifest = { ...base, files: { "/assets/characters/test.glb": { ...base.files["/assets/characters/test.glb"],
download: { url: "/content/objects/model.glb.gz", encoding: "gzip", sha256: sha256(compressed), bytes: compressed.length },
} } };
const storage = new MemoryCacheStorage();
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubGlobal("caches", storage as unknown as CacheStorage);
vi.stubGlobal("fetch", vi.fn(async (url: RequestInfo | URL) => String(url).endsWith("manifest.json") ? Response.json(manifest) : new Response(compressed)));
await checkForContentUpdates();
await expect(installContentPack("characters:test")).rejects.toThrow(/expanded content failed verification/i);
expect(storage.stores.get(BROWSER_CONTENT_CACHE_NAME)?.responses.size).toBe(0);
expect(contentPackIsUsable("characters:test")).toBe(false);
});
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubEnv("DEV", true);
+104 -61
View File
@@ -32,6 +32,8 @@ export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
const BROWSER_CONTENT_OBJECT_HEADER = "x-healer-man-content-object";
const BROWSER_CONTENT_ENCODING_HEADER = "x-healer-man-content-encoding";
export interface InstalledContentFile {
readonly packId: string;
@@ -178,6 +180,23 @@ function browserCacheRequest(logicalPath: string): Request {
return new Request(new URL(logicalPath, `${appOrigin}/`).toString(), { method: "GET" });
}
export function browserContentObjectPath(entry: { readonly sha256: string; readonly extension?: string; readonly url?: string }): string {
const extension = entry.extension ?? extensionForEntry(entry as ContentManifestFile);
return `/content/cache-objects/${entry.sha256}${extension}`;
}
function verifiedCachedResponse(response: Response | undefined, entry: { readonly sha256: string; readonly bytes: number }): boolean {
return response?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& response.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes);
}
async function cachedLogicalObject(cache: Cache, logicalPath: string): Promise<Response | undefined> {
const alias = await cache.match(browserCacheRequest(logicalPath));
const objectPath = alias?.headers.get(BROWSER_CONTENT_OBJECT_HEADER);
if (!objectPath) return alias;
return objectPath.startsWith("/content/cache-objects/") ? cache.match(browserCacheRequest(objectPath)) : undefined;
}
function browserCacheStorage(): CacheStorage | null {
return typeof caches === "undefined" ? null : caches;
}
@@ -209,9 +228,8 @@ async function validateInstalledContentStorage(): Promise<void> {
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
if (active) {
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
const cached = await active.match(browserCacheRequest(logicalPath));
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
const cached = await cachedLogicalObject(active, logicalPath);
if (verifiedCachedResponse(cached, entry)) {
validFiles[logicalPath] = entry;
}
}
@@ -246,7 +264,8 @@ async function registerBrowserContentWorker(): Promise<void> {
scope: "/",
updateViaCache: "none",
});
await navigator.serviceWorker.ready;
const registration = await navigator.serviceWorker.ready;
registration.active?.postMessage({ type: "refresh-content-bootstrap" });
} catch (error) {
console.warn("Browser content cache worker could not be registered.", error);
}
@@ -305,9 +324,9 @@ export function resolveContentUrl(logicalUrl: string): string {
}
export function contentPackIsBundled(packId: string): boolean {
if (!Capacitor.isNativePlatform()) return STATIC_BROWSER_BUNDLED_PACKS.has(packId);
return STATIC_ANDROID_BUNDLED_PACKS.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId));
const defaults = Capacitor.isNativePlatform() ? STATIC_ANDROID_BUNDLED_PACKS : STATIC_BROWSER_BUNDLED_PACKS;
return defaults.has(packId)
|| Boolean(snapshot.bundledManifest?.packs.some((pack) => pack.id === packId && pack.bundledOnAndroid));
}
export function contentPackIsInstalled(packId: string): boolean {
@@ -323,6 +342,8 @@ function matchingReceiptFiles(
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
return expectedEntries.every(([logicalPath, expected]) => {
const recorded = receipt.files[logicalPath];
if (recorded?.sha256 === expected.sha256 && recorded.bytes === expected.bytes
&& snapshot.bundledManifest?.files[logicalPath]?.sha256 === expected.sha256) return true;
const installed = installedState.files[logicalPath];
return recorded?.sha256 === expected.sha256
&& recorded.bytes === expected.bytes
@@ -394,7 +415,7 @@ export async function checkForContentUpdates(): Promise<ContentManifest | null>
async function loadBundledManifest(): Promise<void> {
if (!Capacitor.isNativePlatform() && import.meta.env.VITE_CONTENT_MODE !== "downloadable") return;
try {
const response = await fetch("/content/bootstrap-manifest.json", { cache: "force-cache" });
const response = await fetch("/content/bootstrap-manifest.json", { cache: Capacitor.isNativePlatform() ? "force-cache" : "no-cache" });
if (!response.ok) return;
publish({ bundledManifest: parseContentManifest(await response.json()) });
} catch {
@@ -431,46 +452,63 @@ async function installBrowserContentPack(
): Promise<void> {
const storage = browserCacheStorage();
if (!storage) throw new Error("Offline content storage is unavailable in this browser.");
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
const objects = new Map<string, { logicalPath: string; entry: ContentManifestFile; cached?: Response }>();
for (const [logicalPath, entry] of changes) {
const objectPath = browserContentObjectPath(entry);
if (objects.has(objectPath)) continue;
let cached = await active.match(browserCacheRequest(objectPath));
if (!verifiedCachedResponse(cached, entry)) cached = await cachedLogicalObject(active, logicalPath);
objects.set(objectPath, { logicalPath, entry, cached: verifiedCachedResponse(cached, entry) ? cached : undefined });
}
const totalBytes = [...objects.values()].reduce((sum, object) => sum + (object.cached ? 0 : object.entry.download?.bytes ?? object.entry.bytes), 0);
const stagingName = `${BROWSER_CONTENT_CACHE_NAME}-staging-${encodeURIComponent(packId)}`;
await storage.delete(stagingName);
const staging = await storage.open(stagingName);
publish({ installingPackId: packId, downloadedBytes: 0, totalBytes, error: "" });
let completedBytes = 0;
try {
for (const [logicalPath, entry] of changes) {
const response = await fetch(absoluteContentUrl(entry.url), { cache: "no-store" });
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
const bytes = await response.arrayBuffer();
if (bytes.byteLength !== entry.bytes) {
throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
for (const [objectPath, { logicalPath, entry, cached }] of objects) {
if (cached) {
await staging.put(browserCacheRequest(objectPath), cached);
continue;
}
if (await sha256Bytes(bytes) !== entry.sha256) {
throw new Error(`Downloaded content failed verification: ${logicalPath}`);
const download = entry.download ?? entry;
const response = await fetch(absoluteContentUrl(download.url), { cache: "no-store" });
if (!response.ok) throw new Error(`Content request failed (${response.status}): ${logicalPath}`);
const stored = await response.arrayBuffer();
if (stored.byteLength !== download.bytes) throw new Error(`Downloaded content has the wrong size: ${logicalPath}`);
if (await sha256Bytes(stored) !== download.sha256) throw new Error(`Downloaded content failed verification: ${logicalPath}`);
if (entry.download) {
const expanded = await new Response(new Blob([stored]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer();
if (expanded.byteLength !== entry.bytes || await sha256Bytes(expanded) !== entry.sha256) {
throw new Error(`Expanded content failed verification: ${logicalPath}`);
}
}
const headers = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
if (!entry.download && response.headers.get("content-type")) headers.set("content-type", response.headers.get("content-type")!);
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
}));
completedBytes += entry.bytes;
if (entry.download) headers.set(BROWSER_CONTENT_ENCODING_HEADER, "gzip");
await staging.put(browserCacheRequest(objectPath), new Response(stored, { headers, status: 200 }));
completedBytes += download.bytes;
publish({ downloadedBytes: completedBytes });
}
const active = await storage.open(BROWSER_CONTENT_CACHE_NAME);
for (const [logicalPath] of changes) {
const cached = await staging.match(browserCacheRequest(logicalPath));
if (!cached) throw new Error(`Verified content was not staged: ${logicalPath}`);
await active.put(browserCacheRequest(logicalPath), cached);
// Commit every verified object before switching any logical URL to the new version.
for (const objectPath of objects.keys()) {
const cached = await staging.match(browserCacheRequest(objectPath));
if (!cached) throw new Error(`Verified content was not staged: ${objectPath}`);
await active.put(browserCacheRequest(objectPath), cached);
}
const pendingFiles = { ...installedState.files };
for (const [logicalPath, entry] of changes) {
await active.put(browserCacheRequest(logicalPath), new Response(null, { headers: {
[BROWSER_CONTENT_OBJECT_HEADER]: browserContentObjectPath(entry),
[BROWSER_CONTENT_SHA256_HEADER]: entry.sha256,
[BROWSER_CONTENT_BYTES_HEADER]: String(entry.bytes),
} }));
}
const previousFiles = installedState.files;
const pendingFiles = { ...previousFiles };
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
@@ -479,31 +517,21 @@ async function installBrowserContentPack(
validatedInstalledFiles.delete(logicalPath);
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = {
packId,
sha256: entry.sha256,
bytes: entry.bytes,
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
if (snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
pendingFiles[logicalPath] = { packId, sha256: entry.sha256, bytes: entry.bytes,
extension: extensionForEntry(entry), fileUri: absoluteContentUrl(logicalPath) };
validatedInstalledFiles.add(logicalPath);
}
installedState = {
version: 2,
files: pendingFiles,
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
installedState = { version: 2, files: pendingFiles, packs: {
...installedState.packs, [packId]: packReceiptForManifest(manifest, packId),
} };
persistInstalledState();
publish({
installingPackId: null,
downloadedBytes: totalBytes,
totalBytes,
error: "",
installedRevision: snapshot.installedRevision + 1,
});
const retained = new Set(Object.values(pendingFiles).map(browserContentObjectPath));
for (const previous of Object.values(previousFiles)) {
const objectPath = browserContentObjectPath(previous);
if (!retained.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
}
publish({ installingPackId: null, downloadedBytes: totalBytes, totalBytes, error: "", installedRevision: snapshot.installedRevision + 1 });
} catch (error) {
publish({ installingPackId: null, error: error instanceof Error ? error.message : "Content download failed." });
throw error;
@@ -535,11 +563,17 @@ export async function installContentPack(packId: string): Promise<void> {
await installBrowserContentPack(packId, manifest, changes);
return;
}
const totalBytes = changes.reduce((total, [, entry]) => total + entry.bytes, 0);
const existingObjects = new Set(Object.entries(installedState.files)
.filter(([logicalPath]) => validatedInstalledFiles.has(logicalPath))
.map(([, entry]) => browserContentObjectPath(entry)));
const transferObjects = new Map(changes.map(([, entry]) => [browserContentObjectPath(entry), entry]));
const missingObjects = [...transferObjects].filter(([key]) => !existingObjects.has(key));
const totalBytes = missingObjects.reduce((total, [, entry]) => total + (entry.download?.bytes ?? entry.bytes), 0);
const installedBytes = missingObjects.reduce((total, [, entry]) => total + entry.bytes, 0);
const storage = await NativeContentStorage.getFreeBytes();
const safetyReserve = 128 * 1024 * 1024;
if (storage.freeBytes < totalBytes + safetyReserve) {
const error = `Not enough free storage. ${Math.ceil((totalBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`;
if (storage.freeBytes < installedBytes + safetyReserve) {
const error = `Not enough free storage. ${Math.ceil((installedBytes + safetyReserve - storage.freeBytes) / 1024 / 1024)} MB more is required.`;
publish({ error });
throw new Error(error);
}
@@ -554,11 +588,12 @@ export async function installContentPack(packId: string): Promise<void> {
for (const [logicalPath, entry] of changes) {
const extension = extensionForEntry(entry);
const result = await NativeContentStorage.download({
url: absoluteContentUrl(entry.url),
url: absoluteContentUrl(entry.download?.url ?? entry.url),
logicalPath,
sha256: entry.sha256,
extension,
bytes: entry.bytes,
...(entry.download ? { encoding: entry.download.encoding, downloadSha256: entry.download.sha256, downloadBytes: entry.download.bytes } : {}),
});
pendingFiles[logicalPath] = {
packId,
@@ -567,7 +602,9 @@ export async function installContentPack(packId: string): Promise<void> {
extension,
fileUri: result.fileUri,
};
completedBytes += entry.bytes;
const objectKey = browserContentObjectPath(entry);
if (!existingObjects.has(objectKey)) completedBytes += entry.download?.bytes ?? entry.bytes;
existingObjects.add(objectKey);
publish({ downloadedBytes: completedBytes });
}
@@ -580,6 +617,7 @@ export async function installContentPack(packId: string): Promise<void> {
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
const installed = pendingFiles[logicalPath];
if (!installed && snapshot.bundledManifest?.files[logicalPath]?.sha256 === entry.sha256) continue;
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
pendingFiles[logicalPath] = {
...installed,
@@ -634,6 +672,11 @@ export async function removeContentPack(packId: string): Promise<void> {
await active.delete(browserCacheRequest(logicalPath));
delete remainingFiles[logicalPath];
}
const retainedObjects = new Set(Object.values(remainingFiles).map(browserContentObjectPath));
for (const [, entry] of targetFiles) {
const objectPath = browserContentObjectPath(entry);
if (!retainedObjects.has(objectPath)) await active.delete(browserCacheRequest(objectPath));
}
}
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
const packs = { ...installedState.packs };
@@ -130,6 +130,25 @@ afterEach(() => {
});
describe("persisted content readiness", () => {
it.each([false, true])("honors explicit bundle flags without installing shared Manastorm maps (native=%s)", async (native) => {
const { manager } = await loadOfflineManager({ version: 2, files: {}, packs: {} }, []);
vi.stubEnv("VITE_CONTENT_MODE", "downloadable");
const { Capacitor } = await import("@capacitor/core");
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(native);
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
schemaVersion: 1, version: "bundled", createdAt: "2026-09-10", minimumAppVersion: "0.1.0",
packs: [
{ id: "dungeon:test", label: "Test", bytes: 0, fileCount: 0, bundledOnAndroid: true, dependencies: ["manastorm:test"] },
{ id: "manastorm:test", label: "Map", bytes: 11, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
],
files: { [ENVIRONMENT_PATH]: { packId: "manastorm:test", url: "/content/bundled/model.glb", sha256: ENVIRONMENT_HASH, bytes: 11 } },
})));
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(true);
expect(manager.contentPackIsBundled("manastorm:test")).toBe(false);
expect(manager.contentPackIsInstalled("manastorm:test")).toBe(false);
});
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
+9
View File
@@ -31,6 +31,15 @@ describe("content manifest", () => {
expect(appVersionMeetsMinimum("1.1.9", "1.2.0")).toBe(false);
});
it("rejects unsupported or unverified compressed representations", () => {
const withDownload = (download: unknown) => ({ ...manifest, files: { "/assets/a.glb": { ...manifest.files["/assets/a.glb"], download } } });
const download = { url: "/content/objects/a.glb.gz", encoding: "gzip", sha256: "c".repeat(64), bytes: 10 };
expect(() => parseContentManifest(withDownload(download))).not.toThrow();
expect(() => parseContentManifest(withDownload({ ...download, encoding: "unknown" }))).toThrow(/compressed file/i);
expect(() => parseContentManifest(withDownload({ ...download, sha256: "invalid" }))).toThrow(/compressed file/i);
expect(() => parseContentManifest(withDownload({ ...download, bytes: -1 }))).toThrow(/compressed file/i);
});
it("downloads only files whose verified hash changed", () => {
expect(filesForContentPack(manifest, "dungeon:test")).toHaveLength(2);
expect(changedFilesForPack(manifest, "dungeon:test", {
+15
View File
@@ -5,12 +5,19 @@ export interface ContentManifestFile {
readonly url: string;
readonly sha256: string;
readonly bytes: number;
readonly download?: {
readonly url: string;
readonly encoding: "gzip";
readonly sha256: string;
readonly bytes: number;
};
}
export interface ContentManifestPack {
readonly id: string;
readonly label: string;
readonly bytes: number;
readonly downloadBytes?: number;
readonly fileCount: number;
readonly bundledOnAndroid: boolean;
readonly dependencies: readonly string[];
@@ -63,6 +70,14 @@ export function parseContentManifest(value: unknown): ContentManifest {
|| entry.bytes < 0) {
throw new Error(`The content catalog contains a malformed file: ${logicalPath}`);
}
if (entry.download !== undefined && (!isRecord(entry.download)
|| entry.download.encoding !== "gzip"
|| typeof entry.download.url !== "string"
|| !/^[a-f0-9]{64}$/.test(String(entry.download.sha256))
|| !Number.isSafeInteger(entry.download.bytes)
|| Number(entry.download.bytes) < 0)) {
throw new Error(`The content catalog contains a malformed compressed file: ${logicalPath}`);
}
return [logicalPath, entry as unknown as ContentManifestFile];
}));
return { ...value, packs, files } as unknown as ContentManifest;
+3
View File
@@ -18,6 +18,9 @@ interface DownloadOptions {
readonly sha256: string;
readonly extension: string;
readonly bytes: number;
readonly encoding?: "gzip";
readonly downloadSha256?: string;
readonly downloadBytes?: number;
}
interface ObjectOptions {
+4 -1
View File
@@ -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,
+1 -1
View File
@@ -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 } : {}),
});
}
+31 -10
View File
@@ -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
View File
@@ -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);
}
+28
View File
@@ -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();
});
});
+42
View File
@@ -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,
};
}
+35
View File
@@ -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.";
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+66 -2
View File
@@ -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
View File
@@ -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 {
+32 -4
View File
@@ -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);
+16 -2
View File
@@ -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);
}
+5 -4
View File
@@ -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);
});
+5 -8
View File
@@ -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
View File
@@ -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;
+110
View File
@@ -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
View File
@@ -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,
+84
View File
@@ -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);
});
});
+27
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
+49
View File
@@ -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]);
}
+98
View File
@@ -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]);
}
+10 -1
View File
@@ -1,4 +1,5 @@
import { Capacitor } from "@capacitor/core";
import { controllerModifierTokens } from "./keybindRuntime";
export interface ControllerTokenEvent {
token: string;
@@ -29,6 +30,7 @@ const GAMEPAD_DISCOVERY_INTERVAL_MS = 500;
const REPEATABLE_BUTTON_TOKENS = new Set(["Button12", "Button13", "Button14", "Button15"]);
const listeners = new Set<TokenListener>();
const interceptors = new Set<(event: ControllerTokenEvent) => boolean>();
const movementListeners = new Set<MovementListener>();
const repeatAt = new Map<string, number>();
const lastNativeTokenAt = new Map<string, number>();
@@ -74,7 +76,7 @@ function tokenSetsMatch(left: ReadonlySet<string>, right: ReadonlySet<string>):
}
function isLayerModifier(token: string): boolean {
return token === "Button4" || token === "Button6";
return controllerModifierTokens().has(token);
}
/**
@@ -106,9 +108,16 @@ export function emitControllerToken(event: ControllerTokenEvent): void {
};
if (normalized.pressed) heldTokens.add(normalized.token);
else heldTokens.delete(normalized.token);
for (const intercept of interceptors) if (intercept(normalized)) return;
for (const listener of listeners) listener(normalized);
}
export function getControllerHeldTokens(): ReadonlySet<string> { return heldTokens; }
export function interceptControllerInput(intercept: (event: ControllerTokenEvent) => boolean): () => void {
interceptors.add(intercept);
return () => { interceptors.delete(intercept); };
}
export function subscribeControllerToken(listener: TokenListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
+21
View File
@@ -0,0 +1,21 @@
import { normalizeInputPreferences, type InputPreferences } from "./keybinds";
// Kept independent of React, account storage and gameplay stores. The profile store
// publishes changes here so shell menus, native input and the simulation agree.
let preferences = normalizeInputPreferences(null);
let capturing = false;
const listeners = new Set<() => void>();
export function getInputPreferences(): InputPreferences { return preferences; }
export function publishInputPreferences(next: InputPreferences): void {
if (preferences === next) return;
preferences = next;
for (const listener of listeners) listener();
}
export function subscribeInputPreferences(listener: () => void): () => void {
listeners.add(listener); return () => { listeners.delete(listener); };
}
export function isCapturingInput(): boolean { return capturing; }
export function setCapturingInput(value: boolean): void { capturing = value; }
export function controllerModifierTokens(): Set<string> {
return new Set(Object.values(preferences.controller).flatMap((key) => key?.split("&").slice(0, -1) ?? []));
}
+165
View File
@@ -0,0 +1,165 @@
import { ACTION_CONTROLS, ACTION_CONTROL_DEFINITIONS, ACTION_LAYERS } from "../game/actionBindings";
export type InputDevice = "pc" | "controller";
export function defaultInputDevice(native: boolean, mobile: boolean): InputDevice { return native || mobile ? "controller" : "pc"; }
export type InputContext = "game" | "ground" | "menu" | "global" | "ability-editor";
export interface InputActionDefinition {
id: string;
label: string;
group: string;
context: InputContext;
pc: string | null;
controller: string | null;
continuous?: boolean;
}
const action = (id: string, label: string, group: string, pc: string | null, controller: string | null,
context: InputContext = "game", continuous = false): InputActionDefinition => ({ id, label, group, pc, controller, context, continuous });
export const INPUT_ACTIONS: readonly InputActionDefinition[] = [
action("move-forward", "Move forward", "Movement & camera", "KeyW", "Axis1-", "game", true),
action("move-backward", "Move backward", "Movement & camera", "KeyS", "Axis1+", "game", true),
action("move-left", "Strafe left", "Movement & camera", "KeyA", "Axis0-", "game", true),
action("move-right", "Strafe right", "Movement & camera", "KeyD", "Axis0+", "game", true),
action("jump", "Jump", "Movement & camera", "Space", "Button10"),
action("look-up", "Look up", "Movement & camera", "ArrowUp", "Axis3-", "game", true),
action("look-down", "Look down", "Movement & camera", "ArrowDown", "Axis3+", "game", true),
action("look-left", "Look left", "Movement & camera", "ArrowLeft", "Axis2-", "game", true),
action("look-right", "Look right", "Movement & camera", "ArrowRight", "Axis2+", "game", true),
action("target-next", "Next enemy", "Targeting & interaction", "Tab", "Button11"),
action("target-previous", "Previous enemy", "Targeting & interaction", "Shift+Tab", null),
action("party-next", "Next party member / self", "Targeting & interaction", null, "Button13"),
action("party-previous", "Previous party member / self", "Targeting & interaction", null, "Button12"),
action("target-clear", "Clear target", "Targeting & interaction", null, null),
// Contextual interaction uses A before its ability only when there is something to use/loot.
action("loot", "Interact / loot", "Targeting & interaction", "KeyE", "Button0"),
action("ground-confirm", "Confirm ground spell", "Ground targeting", "Enter", "Button0", "ground"),
action("ground-cancel", "Cancel ground spell", "Ground targeting", "Escape", "Button1", "ground"),
action("party-attack", "Party: attack", "Party commands", "F1", null),
action("party-defend", "Party: defend", "Party commands", "F2", null),
action("party-stop", "Party: stop", "Party commands", "F3", null),
action("party-recall", "Party: recall", "Party commands", "F4", null),
action("pause", "Pause / resume", "Panels", "Escape", "Button9"),
action("map", "Dungeon map", "Panels", "KeyM", null),
action("inventory", "Inventory & equipment", "Panels", "KeyI", null),
action("spellbook", "Spellbook", "Panels", "KeyP", null),
action("talents", "Talents / skills", "Panels", "KeyN", null),
action("party", "Party management", "Panels", null, null),
action("boss-guide", "Boss guide", "Panels", null, null),
action("bindings", "Ability assignments", "Panels", null, null),
action("options", "Options", "Panels", null, null),
action("keybinds", "Keybinds", "Panels", null, null),
action("ui-layout", "Edit UI & templates", "Panels", null, null),
action("companion", "Companion display", "Panels", "KeyC", "Button8", "global"),
action("menu-up", "Menu: up", "Menu navigation", "ArrowUp", "Button12", "menu"),
action("menu-down", "Menu: down", "Menu navigation", "ArrowDown", "Button13", "menu"),
action("menu-left", "Menu: left", "Menu navigation", "ArrowLeft", "Button14", "menu"),
action("menu-right", "Menu: right", "Menu navigation", "ArrowRight", "Button15", "menu"),
action("menu-confirm", "Menu: confirm", "Menu navigation", "Enter", "Button0", "menu"),
action("menu-back", "Menu: back", "Menu navigation", "Escape", "Button1", "menu"),
action("menu-up-alt", "Menu: up (alternate)", "Menu navigation", "KeyW", "Axis1-", "menu"),
action("menu-down-alt", "Menu: down (alternate)", "Menu navigation", "KeyS", "Axis1+", "menu"),
action("menu-left-alt", "Menu: left (alternate)", "Menu navigation", "KeyA", "Axis0-", "menu"),
action("menu-right-alt", "Menu: right (alternate)", "Menu navigation", "KeyD", "Axis0+", "menu"),
action("menu-confirm-alt", "Menu: confirm (alternate)", "Menu navigation", "Space", null, "menu"),
action("menu-back-alt", "Menu: back (alternate)", "Menu navigation", "Backspace", null, "menu"),
action("editor-layer-2", "Ability editor: preview layer 2", "Ability editor", null, "Button4", "ability-editor"),
action("editor-layer-3", "Ability editor: preview layer 3", "Ability editor", null, "Button6", "ability-editor"),
action("editor-clear", "Ability editor: clear slot", "Ability editor", null, "Button2", "ability-editor"),
action("editor-reset", "Ability editor: restore layer", "Ability editor", null, "Button3", "ability-editor"),
...ACTION_LAYERS.flatMap((layer, index) => ACTION_CONTROLS.map((control) => {
const definition = ACTION_CONTROL_DEFINITIONS[control];
return action(`ability:${layer}:${control}`, `${["Base", "Layer 2", "Layer 3"][index]} · ${definition.label}`,
"Ability slots", `${["", "Shift+", "Alt+"][index]}${definition.keyboardCode}`,
`${["", "Button4&", "Button6&"][index]}${definition.gamepadToken}`);
})),
...Array.from({ length: 60 }, (_, index) => action(`bar:${Math.floor(index / 12)}:${index % 12}`,
`Bar ${Math.floor(index / 12) + 1} · Button ${index % 12 + 1}`, "WoW action bars", null, null)),
];
export const INPUT_ACTION_BY_ID = Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry]));
export type InputBindings = Record<string, string | null>;
export interface InputPreferences { pc: InputBindings; controller: InputBindings }
const PC_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)$/;
const CONTROLLER_TOKEN = /^(?:Button(?:[0-9]|[12][0-9]|3[01])|Axis[0-3][+-])$/;
export function normalizeInputBinding(device: InputDevice, value: unknown): string | null {
if (typeof value !== "string" || value.length > 100) return null;
if (device === "pc") {
const parts = value.split("+"); const code = parts.pop()!;
if (!PC_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
}
const parts = value.split("&"); const token = parts.pop()!;
if (!CONTROLLER_TOKEN.test(token) || parts.length > 3 || parts.some((part) => !/^Button(?:[0-9]|[12][0-9]|3[01])$/.test(part) || part === token)) return null;
return [...new Set(parts)].sort().concat(token).join("&");
}
export function inputKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
return normalizeInputBinding("pc", [event.ctrlKey && "Ctrl", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Meta", event.code].filter(Boolean).join("+"));
}
export function defaultInputBindings(device: InputDevice): InputBindings {
return Object.fromEntries(INPUT_ACTIONS.map((entry) => [entry.id, entry[device]]));
}
export function normalizeInputPreferences(value: unknown): InputPreferences {
const source = value && typeof value === "object" ? value as Partial<InputPreferences> : {};
const result = { pc: defaultInputBindings("pc"), controller: defaultInputBindings("controller") };
for (const device of ["pc", "controller"] as const) {
const bindings = source[device];
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) continue;
for (const entry of INPUT_ACTIONS) {
if (!Object.hasOwn(bindings, entry.id)) continue;
result[device][entry.id] = bindings[entry.id] === null ? null
: normalizeInputBinding(device, bindings[entry.id]) ?? result[device][entry.id];
}
}
return result;
}
const PAD_LABELS: Record<string, string> = {
Button0: "A / Cross", Button1: "B / Circle", Button2: "X / Square", Button3: "Y / Triangle",
Button4: "LB / L1", Button5: "RB / R1", Button6: "LT / L2", Button7: "RT / R2",
Button8: "Select / View", Button9: "Start / Menu", Button10: "L3", Button11: "R3",
Button12: "D-pad up", Button13: "D-pad down", Button14: "D-pad left", Button15: "D-pad right",
"Axis0-": "Left stick left", "Axis0+": "Left stick right", "Axis1-": "Left stick up", "Axis1+": "Left stick down",
"Axis2-": "Right stick left", "Axis2+": "Right stick right", "Axis3-": "Right stick up", "Axis3+": "Right stick down",
};
export function inputBindingLabel(device: InputDevice, binding: string | null | undefined): string {
if (!binding) return "Unbound";
if (device === "controller") return binding.split("&").map((part) => PAD_LABELS[part] ?? part).join(" + ");
return binding.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ");
}
export const CONTROLLER_BINDING_CHOICES = Object.keys(PAD_LABELS);
/** Ground targeting and menus intentionally reuse gameplay buttons in separate contexts. */
export function bindingContextsOverlap(a: string, b: string, device: InputDevice): boolean {
// Contextual A interaction falls through to its base ability when nothing can be used.
if (device === "controller" && [a, b].includes("loot") && [a, b].includes("ability:primary:face-bottom")) return false;
const left = INPUT_ACTION_BY_ID[a]; const right = INPUT_ACTION_BY_ID[b];
if (!left || !right) return false;
// The PC's legacy ability slots and WoW bars are alternative layouts.
if (device === "pc" && ((a.startsWith("bar:") && b.startsWith("ability:")) || (b.startsWith("bar:") && a.startsWith("ability:")))) return false;
return left.context === right.context || left.context === "global" || right.context === "global"
|| [left.context, right.context].every((context) => context === "menu" || context === "ability-editor");
}
export function conflictingInputActions(bindings: InputBindings, device: InputDevice, id: string, key: string | null): string[] {
if (!key) return [];
const collides = (other: string | null | undefined) => {
if (!other) return false;
if (key === other) return true;
if (device !== "controller") return false;
const left = key.split("&"); const right = other.split("&");
// A standalone command on a chord's modifier would fire before its final
// button (e.g. View would open the companion before View + A could jump).
// Sharing the final button is intentional: the more specific chord wins.
return left.every((token) => right.slice(0, -1).includes(token))
|| right.every((token) => left.slice(0, -1).includes(token));
};
return INPUT_ACTIONS.filter((entry) => entry.id !== id && collides(bindings[entry.id]) && bindingContextsOverlap(id, entry.id, device)).map((entry) => entry.id);
}
export function replaceInputBinding(bindings: InputBindings, device: InputDevice, id: string, key: string | null): InputBindings {
const next = { ...bindings, [id]: key };
for (const conflict of conflictingInputActions(bindings, device, id, key)) next[conflict] = null;
return next;
}
export function controllerBindingMatches(binding: string | null | undefined, token: string, held: ReadonlySet<string>): boolean {
if (!binding) return false;
const parts = binding.split("&");
return parts.pop() === token && parts.every((part) => held.has(part));
}
+8
View File
@@ -0,0 +1,8 @@
import { useSyncExternalStore } from "react";
import { getInputPreferences, subscribeInputPreferences } from "./keybindRuntime";
import { inputBindingLabel, type InputDevice } from "./keybinds";
export function useInputBindingLabels() {
const preferences = useSyncExternalStore(subscribeInputPreferences, getInputPreferences, getInputPreferences);
return (device: InputDevice, id: string) => inputBindingLabel(device, preferences[device][id]);
}
+20 -17
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CONTROLLER_SYSTEM_BACK_TOKEN, subscribeControllerToken } from "./controller";
import { CONTROLLER_SYSTEM_BACK_TOKEN, getControllerHeldTokens, subscribeControllerToken } from "./controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "./keybinds";
import { getInputPreferences, isCapturingInput } from "./keybindRuntime";
export type MenuDirection = "up" | "down" | "left" | "right";
@@ -17,7 +19,8 @@ interface MenuControllerOptions {
}
export function isControllerMenuBackToken(token: string): boolean {
return token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN;
const bindings = getInputPreferences().controller;
return token === CONTROLLER_SYSTEM_BACK_TOKEN || ["menu-back", "menu-back-alt"].some((id) => controllerBindingMatches(bindings[id], token, getControllerHeldTokens()));
}
function nextIndex(index: number, direction: MenuDirection, count: number, columns: number): number {
@@ -63,14 +66,15 @@ export function useMenuController(actions: MenuAction[], options: MenuController
const interactiveTarget = event.target instanceof Element
? event.target.closest("button, input, select, textarea, a[href], [contenteditable='true'], [role='button'], [role='link']")
: null;
if (event.repeat || interactiveTarget) return;
const key = event.key.toLowerCase();
if (["arrowup", "w"].includes(key)) move("up");
else if (["arrowdown", "s"].includes(key)) move("down");
else if (["arrowleft", "a"].includes(key)) move("left");
else if (["arrowright", "d"].includes(key)) move("right");
else if (key === "enter" || key === " ") confirm();
else if (key === "escape" || key === "backspace") backRef.current?.();
const textTarget = event.target instanceof Element ? event.target.closest("input, select, textarea, [contenteditable='true']") : null;
if (event.repeat || event.defaultPrevented || textTarget || isCapturingInput()) return;
// Keep native activation/focus navigation on focused controls.
if (interactiveTarget && ["Enter", "Space", "Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) return;
const key = inputKeyForEvent(event);
const command = key && INPUT_ACTIONS.find((entry) => entry.context === "menu" && getInputPreferences().pc[entry.id] === key)?.id.replace(/-alt$/, "");
if (["menu-up", "menu-down", "menu-left", "menu-right"].includes(command || "")) move(command!.slice(5) as MenuDirection);
else if (command === "menu-confirm") confirm();
else if (command === "menu-back") backRef.current?.();
else return;
event.preventDefault();
};
@@ -79,14 +83,13 @@ export function useMenuController(actions: MenuAction[], options: MenuController
}, [confirm, move]);
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false) return;
const direction: MenuDirection | null = token === "Button12" || token === "Axis1-" ? "up"
: token === "Button13" || token === "Axis1+" ? "down"
: token === "Button14" || token === "Axis0-" ? "left"
: token === "Button15" || token === "Axis0+" ? "right"
: null;
if (pressed === false || isCapturingInput()) return;
const bindings = getInputPreferences().controller;
const command = INPUT_ACTIONS.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
.sort((a, b) => (bindings[b.id]?.length ?? 0) - (bindings[a.id]?.length ?? 0))[0]?.id.replace(/-alt$/, "");
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command) ? command.slice(5) as MenuDirection : null;
if (direction) move(direction);
else if (!repeat && token === "Button0") confirm();
else if (!repeat && command === "menu-confirm") confirm();
else if (!repeat && isControllerMenuBackToken(token)) backRef.current?.();
}), [confirm, move]);
+6 -1
View File
@@ -12,6 +12,8 @@ import {
type CompanionSnapshot,
type DualScreenMessage,
} from "./dualScreenSync";
import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen";
export function BottomDisplayApp() {
@@ -44,7 +46,10 @@ export function BottomDisplayApp() {
} satisfies DualScreenMessage))
: () => undefined;
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "app-state") setSnapshot(event.data.snapshot);
if (event.data.type === "app-state") {
setSnapshot(event.data.snapshot);
if (event.data.snapshot.keybinds) publishInputPreferences(normalizeInputPreferences(event.data.snapshot.keybinds));
}
else if (event.data.type === "authoritative-ready") ready();
};
window.addEventListener("pageshow", ready);
+4
View File
@@ -1,3 +1,5 @@
import { getInputPreferences } from "../input/keybindRuntime";
import type { InputPreferences } from "../input/keybinds";
import { classById, type GenderId, type RaceId } from "../app/characterCatalog";
import { useGameStore } from "../game/store";
import type { GameMode, GameOverlay } from "../game/store";
@@ -121,6 +123,7 @@ export interface CompanionGameSnapshot {
}
export interface CompanionSnapshot {
keybinds?: InputPreferences;
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
@@ -239,6 +242,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
}];
});
return {
keybinds: getInputPreferences(),
phase: shell.phase,
session: shell.session,
characters: shell.characters,
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Plane, Raycaster, Vector2, Vector3 } from "three";
import { CombatEffects } from "./CombatEffects";
import { emitCombatPresentation } from "../game/combatPresentation";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaAuthoritativeSnapshot, Vec3Tuple } from "../game/arenaTypes";
export function ArenaCombatEffects({ snapshot, actorId }: { snapshot: ArenaAuthoritativeSnapshot; actorId?: string }) {
const seen = useRef(new Set<number>());
useEffect(() => {
const team = snapshot.actors.find((actor) => actor.id === actorId)?.team;
for (const event of snapshot.combatPresentation ?? []) {
if (seen.current.has(event.id)) continue;
seen.current.add(event.id);
const source = snapshot.actors.find((actor) => actor.id === event.sourceActorId);
emitCombatPresentation({ ...event, occurredAt: Date.now(),
actorGroup: event.sourceActorId === actorId ? "player" : source?.team === team ? "party" : "enemy" });
}
if (seen.current.size > 500) seen.current = new Set((snapshot.combatPresentation ?? []).map((event) => event.id));
}, [snapshot, actorId]);
return <>
<CombatEffects active />
{snapshot.actors.flatMap((actor) => (actor.combat?.summons ?? []).map((summon) => <group key={summon.id} name={`arena-summon:${summon.id}`} position={summon.position}
onPointerDown={(event) => { if (event.button === 0) { event.stopPropagation(); useArenaStore.getState().intent({ type: "target", targetActorId: summon.id }); } }}>
<mesh position={[0, .55, 0]} castShadow>{summon.kind === "totem" ? <cylinderGeometry args={[.22, .38, 1.1, 8]} /> : <capsuleGeometry args={[.32, .55, 5, 10]} />}
<meshStandardMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#89bbbd" : "#c36966"} emissive={summon.kind === "totem" ? "#1f4939" : "#000000"} /></mesh>
<Html center position={[0, 1.3, 0]} distanceFactor={12}><span className="arena-summon-name">{summon.name}</span></Html>
</group>))}
{snapshot.actors.flatMap((actor) => (actor.combat?.areaEffects ?? []).map((area) => <mesh key={area.id} position={[area.position[0], area.position[1] + .04, area.position[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[Math.max(.1, area.radius - .12), area.radius, 48]} /><meshBasicMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#79d7e8" : "#ed685c"} transparent opacity={.6} depthWrite={false} />
</mesh>))}
</>;
}
export function ArenaGroundTarget({ environment, position, radius, origin, controller }: {
environment: RefObject<Group | null>; position: RefObject<Vec3Tuple>; radius: number; origin: Vec3Tuple; controller: boolean;
}) {
const ring = useRef<Group>(null);
const ray = useMemo(() => new Raycaster(), []);
const plane = useMemo(() => new Plane(new Vector3(0, 1, 0)), []);
const point = useMemo(() => new Vector3(), []);
const center = useMemo(() => new Vector2(), []);
useFrame(({ camera, pointer }) => {
ray.setFromCamera(controller ? center : pointer, camera);
const hit = environment.current ? ray.intersectObject(environment.current, true).find((entry) => Math.abs(entry.point.y - origin[1]) < 6) : null;
plane.constant = -origin[1];
if (hit) point.copy(hit.point);
else if (!ray.ray.intersectPlane(plane, point)) return;
position.current = [point.x, point.y, point.z];
ring.current?.position.set(point.x, point.y + .06, point.z);
});
return <group ref={ring}><mesh rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[Math.max(.1, radius - .12), radius, 48]} /><meshBasicMaterial color="#81edbd" transparent opacity={.9} depthWrite={false} /></mesh></group>;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import geometry from "../../server/generated/arena-geometry.json" with { type: "json" };
/** The fallback draws the same surfaces used by the authoritative simulation. */
export default function ArenaFallbackStage({ stageId }: { stageId: string }) {
const meshes = useMemo(() => {
const data = (geometry as Record<string, { navigation: number[]; collision: number[] }>)[stageId];
return ["navigation", "collision"].map((kind) => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute(data?.[kind as "navigation" | "collision"] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
});
}, [stageId]);
useEffect(() => () => meshes.forEach((mesh) => mesh.dispose()), [meshes]);
return <group>
<mesh geometry={meshes[0]} position={[0, -.025, 0]} receiveShadow><meshStandardMaterial color="#536452" side={DoubleSide} roughness={.9} /></mesh>
<mesh geometry={meshes[1]} receiveShadow castShadow><meshStandardMaterial color="#66705e" side={DoubleSide} roughness={.85} /></mesh>
</group>;
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import navigation from "../../server/generated/arena-navigation.json";
export function ArenaNavigationSurface({ stageId }: { stageId: string }) {
const geometry = useMemo(() => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute((navigation as Record<string, number[]>)[stageId] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
}, [stageId]);
useEffect(() => () => geometry.dispose(), [geometry]);
return <mesh geometry={geometry} position={[0, -.12, 0]} receiveShadow>
<meshStandardMaterial color="#465142" side={DoubleSide} roughness={.92} />
</mesh>;
}
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Group, Raycaster, Vector3 } from "three";
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "../game/arenaCamera";
import { cameraRelativeMovement, cameraYawAfterHorizontalLook, clampCameraPitch, DEFAULT_CAMERA_PITCH } from "../game/inputMath";
import { consumeMouseLook, consumeJumpRequest, isMouseLookDragActive, readInputSnapshot } from "../game/inputManager";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaActorSnapshot, ArenaStageManifest } from "../game/arenaTypes";
export function ArenaPlayerCamera({ actor, stage, player, environment, movement, blocked }: {
actor: ArenaActorSnapshot | null;
stage: ArenaStageManifest;
player: RefObject<Group | null>;
environment: RefObject<Group | null>;
movement: RefObject<[number, number]>;
blocked: boolean;
}) {
const orbit = useRef({ yaw: arenaSpawnYaw(stage, actor?.team ?? 0), pitch: DEFAULT_CAMERA_PITCH });
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const ray = useMemo(() => new Raycaster(), []);
const distance = useRef(ARENA_CAMERA_DISTANCE);
const sent = useRef({ x: NaN, z: NaN, time: 0 });
useFrame(({ camera, clock }, rawDelta) => {
if (!actor) return;
const delta = Math.min(0.05, rawDelta);
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
if (consumeJumpRequest() !== null && !blocked && actor.alive) useArenaStore.getState().intent({ type: "jump" });
if (!blocked) {
const dragging = isMouseLookDragActive();
orbit.current.yaw = cameraYawAfterHorizontalLook(orbit.current.yaw, input.lookX * delta * 2.25 + (dragging ? mouseX * 0.0021 : 0));
orbit.current.pitch = clampCameraPitch(orbit.current.pitch + input.lookY * delta * 1.55 + (dragging ? mouseY * 0.0017 : 0));
}
const [x, z] = blocked || !actor.alive ? [0, 0] : cameraRelativeMovement(input.moveX, input.moveForward, orbit.current.yaw);
movement.current = [x, z];
// Publish immediately on input changes, and refresh held input through preparation/reconnect.
if (Math.abs(x - sent.current.x) > 0.002 || Math.abs(z - sent.current.z) > 0.002 || clock.elapsedTime - sent.current.time > 0.1 || !Number.isFinite(sent.current.x)) {
useArenaStore.getState().intent({ type: "movement", movement: { x, z } });
if (isMouseLookDragActive() || Math.abs(input.lookX) > .01) useArenaStore.getState().intent({ type: "facing", facing: orbit.current.yaw });
sent.current = { x, z, time: clock.elapsedTime };
}
const foot = player.current?.position.toArray() ?? actor.position;
const pose = arenaCameraPose(foot as [number, number, number], orbit.current.yaw, orbit.current.pitch);
target.set(...pose.target);
desired.set(...pose.position);
const clear = environment.current ? arenaCameraClearDistance(target, desired, environment.current, ray) : ARENA_CAMERA_DISTANCE;
distance.current = clear < distance.current ? clear : Math.min(clear, distance.current + delta * 8);
// A tight starting room can require a first-person distance. Keep the
// character's own head/shoulders out of that view, then restore the body
// automatically as the camera backs out into the arena.
if (player.current) player.current.visible = actor.alive && distance.current > 0.9;
desired.sub(target).normalize().multiplyScalar(distance.current).add(target);
// Never interpolate from a spectator/world-origin anchor through the arena walls.
camera.position.copy(desired);
camera.lookAt(target);
});
return null;
}
+8
View File
@@ -340,6 +340,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
fallback={(
<CombatEffectPrimitive
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
)}
/>
) : (
<CombatEffectPrimitive
+12 -16
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
@@ -35,6 +35,7 @@ import {
originalCombatAnchorActorId,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
selectCombatEffectRender,
parseOriginalCombatEffectGraph,
type OriginalCombatEffectAnimatedTrackName,
type OriginalCombatEffectGraph,
@@ -730,15 +731,12 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
quality,
active,
onDone,
}: OriginalCombatEffectProps) {
fallback,
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const onDoneRef = useRef(onDone);
const terminalRef = useRef(false);
onDoneRef.current = onDone;
useEffect(() => {
let cancelled = false;
terminalRef.current = false;
setLoaded(null);
void preloadCombatEffectManifest().then(async () => {
const assets = combatOriginalPhaseAssets(event);
@@ -756,21 +754,19 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
failures,
);
}
if (ready.length) {
setLoaded(ready);
return;
}
if (!terminalRef.current) {
terminalRef.current = true;
onDoneRef.current();
}
// Keep any usable original layers; only substitute when none can render.
setLoaded(ready);
}).catch(() => {
if (!cancelled) setLoaded([]);
});
return () => {
cancelled = true;
};
}, [event]);
return loaded ? (
if (loaded === null) return null;
const selection = selectCombatEffectRender("original", loaded.map((item) => item.asset));
return selection.kind === "original" ? (
<LoadedOriginalCombatEffect
event={event}
quality={quality}
@@ -778,7 +774,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
loaded={loaded}
onDone={onDone}
/>
) : null;
) : fallback;
}, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
@@ -293,7 +293,7 @@ describe("original combat-effect graph contract", () => {
expect(originalEmitterParticleBudget(inactive, "high")).toBe(0);
});
it("never substitutes generated rendering when Original has no graph asset", () => {
it("prefers original rendering and falls back to generated when no original graph is available", () => {
expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [
{},
@@ -302,7 +302,8 @@ describe("original combat-effect graph contract", () => {
kind: "original",
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json",
});
expect(selectCombatEffectRender("original", undefined)).toBeNull();
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull();
expect(selectCombatEffectRender("original", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [])).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toEqual({ kind: "generated" });
});
});
+2 -2
View File
@@ -682,12 +682,12 @@ export type OriginalCombatEffectRenderSelection =
export function selectCombatEffectRender(
mode: CombatVfxMode,
phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined,
): OriginalCombatEffectRenderSelection | null {
): OriginalCombatEffectRenderSelection {
if (mode === "generated") return { kind: "generated" };
const authentic = phaseAssets?.find((asset) => (
typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0
));
return authentic?.effectGraphUrl
? { kind: "original", effectGraphUrl: authentic.effectGraphUrl }
: null;
: { kind: "generated" };
}
+64
View File
@@ -3711,3 +3711,67 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
color: #bdb6a5;
font-size: 0.68rem;
}
.arena-player-hud {
position: absolute;
z-index: 4;
left: 50%;
bottom: max(.6rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
width: min(56rem, calc(100% - 1.2rem));
display: grid;
justify-items: center;
gap: .3rem;
pointer-events: none;
}
.arena-player-hud > .action-bar { position: relative; inset: auto; transform: none; width: min(100%, 38rem); pointer-events: auto; }
.arena-player-status, .arena-cast-status, .arena-action-feedback {
padding: .25rem .6rem;
border: 1px solid #88966b77;
border-radius: .25rem;
background: #07130fe8;
text-align: center;
font-size: .75rem;
}
.arena-player-status { display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem; }
.arena-player-status span { color: #d1dbc8; }
.arena-action-feedback { color: #ffe4a1; }
.arena-control-hints { font-size: .65rem; color: #e0e7d5; background: #07130fd9; padding: .2rem .4rem; text-align: center; }
.arena-personal-bars { display: grid; gap: .25rem; max-width: 100%; pointer-events: auto; }
.arena-personal-bars section { display: grid; gap: .2rem; max-width: 100%; }
.arena-personal-bars button {
position: relative; width: clamp(2.6rem, 5vw, 4.1rem); min-width: 0; min-height: 3.9rem;
display: grid; justify-items: center; gap: .1rem; padding: .2rem;
border: 1px solid #9dad8277; background: #08130feb; color: #fff;
}
.arena-personal-bars button:disabled { opacity: .55; }
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
.arena-personal-bars button em { position: absolute; top: 1rem; font-size: .9rem; background: #000b; }
.arena-runtime .arena-exit { z-index: 5; bottom: auto; top: .7rem; }
.arena-result-panel { z-index: 8; }
.arena-countdown { top: 28%; pointer-events: none; }
@media (max-width: 760px) {
.arena-player-hud { width: calc(100% - .5rem); }
.arena-player-hud .action-slot strong { font-size: .55rem; }
.arena-player-hud .action-bar--compact { padding: .25rem; }
.arena-team-frames { width: min(10rem, 33vw); font-size: .72rem; }
.arena-team-frames button small { font-size: .55rem; }
.arena-control-hints { font-size: .55rem; }
.arena-countdown { top: 38%; min-width: 8rem; padding: .4rem; }
}
.arena-toolbar { position: absolute; z-index: 6; right: .8rem; bottom: .6rem; display: flex; flex-wrap: wrap; gap: .3rem; max-width: 26rem; justify-content: flex-end; }
.arena-toolbar button, .arena-pet-orders button { padding: .35rem .6rem; background: #17241fe8; border: 1px solid #69837a; border-radius: .3rem; color: #dcece4; text-transform: capitalize; }
.arena-reference-panel svg { display: block; width: 100%; height: 250px; background: #11201b; border: 1px solid #728376; }
.arena-reference-panel { max-height: 85vh; overflow: auto; }
.arena-ground-hint { position: absolute; top: 22%; left: 50%; transform: translateX(-50%); z-index: 5; padding: .6rem 1rem; border-radius: .4rem; color: #c5ffdd; background: #13291dec; pointer-events: none; }
.arena-summon-name { color: #e1eee4; font-size: 10px; white-space: nowrap; text-shadow: 0 1px 3px #000; pointer-events: none; }
.arena-extra-resources, .arena-aura-strip, .arena-pet-orders { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; font-size: .7rem; }
.arena-extra-resources span, .arena-aura-strip span { background: #12201dda; border: 1px solid #4a6860; border-radius: .25rem; padding: .2rem .4rem; }
.arena-aura-strip img { width: 20px; height: 20px; vertical-align: middle; margin-right: .25rem; }
.arena-aura-strip .is-debuff { border-color: #c65a58; }
.arena-player-hud { bottom: 3rem; }
.arena-combat-log { position: absolute; left: .7rem; top: 47%; color: #e4e3cc; font-size: .75rem; pointer-events: none; max-width: 18rem; }
.arena-team-frames button.is-selected { outline: 2px solid #ffda8b; }
@media (max-width: 760px) { .arena-toolbar { max-width: 100%; left: .5rem; justify-content: center; } .arena-toolbar button { font-size: .6rem; padding: .25rem; } }
+30 -15
View File
@@ -5,14 +5,16 @@ import {
ACTION_CONTROL_DEFINITIONS,
ACTION_LAYERS,
ACTION_LAYER_LABELS,
actionBindingChordLabel,
actionBindingId,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { subscribeControllerToken } from "../input/controller";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -82,6 +84,8 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
};
function AbilityBindingsDialog() {
const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
const openOverlay = useGameStore((state) => state.openOverlay);
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
@@ -130,14 +134,14 @@ function AbilityBindingsDialog() {
const control = editingControl;
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
useCombatStore.getState().setActionBinding(layer, control, abilityId);
setNotice(`${actionBindingChordLabel(layer, control)}: ${ability?.name ?? "Empty"}.`);
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
const clearSelected = (control: ActionBindingControl | null = editingControl) => {
if (!control) return;
useCombatStore.getState().setActionBinding(layer, control, null);
setNotice(`${actionBindingChordLabel(layer, control)} cleared.`);
setNotice(`${bindingChord(layer, control)} cleared.`);
setEditingControl(null);
controller.select(slotControlId(control));
};
@@ -224,19 +228,30 @@ function AbilityBindingsDialog() {
});
useEffect(() => {
return subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || repeat) return;
const run = (id: string | undefined) => {
if (restoreRequest) return;
if (token === "Button4") chooseLayer("secondary");
else if (token === "Button6") chooseLayer("tertiary");
else if (token === "Button2") {
if (id === "editor-layer-2") chooseLayer("secondary");
else if (id === "editor-layer-3") chooseLayer("tertiary");
else if (id === "editor-clear") {
if (editingControl) clearSelected(editingControl);
else {
const prefix = "binding-slot-";
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
}
} else if (token === "Button3") requestRestore("layer", controller.selectedId);
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
};
const unsubscribe = subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || repeat || isCapturingInput()) return;
run(INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && controllerBindingMatches(getInputPreferences().controller[entry.id], token, getControllerHeldTokens()))?.id);
});
const onKey = (event: KeyboardEvent) => {
if (event.repeat || event.defaultPrevented || isCapturingInput() || (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']"))) return;
const key = inputKeyForEvent(event);
const id = key && INPUT_ACTIONS.find((entry) => entry.context === "ability-editor" && getInputPreferences().pc[entry.id] === key)?.id;
if (id) { event.preventDefault(); run(id); }
};
window.addEventListener("keydown", onKey);
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
}, [controller.selectedId, editingControl, layer, restoreRequest]);
const bindingSlot = (control: ActionBindingControl) => {
@@ -252,11 +267,11 @@ function AbilityBindingsDialog() {
select={controller.select}
type="button"
className={`ability-binding-slot ability-binding-slot--${control} ${editingControl === control ? "is-editing" : ""} ${locked ? "is-locked" : ""}`}
aria-label={`${actionBindingChordLabel(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-label={`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === control}
onClick={() => beginEditing(control)}
>
<kbd>{definition.gamepadLabel}</kbd>
<kbd>{keyLabel("controller", `ability:${layer}:${control}`)}</kbd>
{ability
? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
@@ -340,7 +355,7 @@ function AbilityBindingsDialog() {
</>
) : editingControl ? (
<>
<p className="eyebrow">Assign {actionBindingChordLabel(layer, editingControl)}</p>
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
<h3>Choose an ability</h3>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
@@ -368,8 +383,8 @@ function AbilityBindingsDialog() {
<footer className="game-panel__footer">
{restoreRequest
? <><span><kbd>×</kbd> Choose</span><span><kbd>○</kbd> Cancel</span></>
: <><span><kbd>×</kbd> Assign</span><span><kbd>□</kbd> Clear</span><span><kbd>○</kbd> Back</span><span><kbd>△</kbd> Restore layer</span><span><kbd>L1 / L2</kbd> Preview layer</span></>}
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore layer</span></>}
</footer>
</section>
</div>
+4 -5
View File
@@ -1,9 +1,8 @@
import { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import {
ACTION_CONTROL_DEFINITIONS,
ACTION_LAYER_LABELS,
actionBindingChordLabel,
type ActionBindingControl,
type ActionBindingLayer,
} from "../game/actionBindings";
@@ -70,15 +69,15 @@ function ActionSlot({
onInspect: (control: ActionBindingControl) => void;
onStopInspecting: (control: ActionBindingControl) => void;
}) {
const definition = ACTION_CONTROL_DEFINITIONS[control];
const cooldown = cooldownPercent(item?.cooldownRemaining, item?.cooldownDuration);
const unavailable = !item || Boolean(item.disabled);
const style = {
"--ability-color": item?.color ?? "#789b68",
"--cooldown-fill": `${cooldown}%`,
} as CSSProperties;
const bindingLabel = inputMode === "gamepad" ? definition.gamepadLabel : definition.keyboardLabel;
const chord = actionBindingChordLabel(layer, control, inputMode);
const label = useInputBindingLabels();
const chord = label(inputMode === "gamepad" ? "controller" : "pc", `ability:${layer}:${control}`);
const bindingLabel = chord.replace(/ \/ [^+]+/g, "").replace(/Left stick/g, "LS").replace(/Right stick/g, "RS");
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
? ", cooling down"
: "";
+84
View File
@@ -0,0 +1,84 @@
import { useMemo } from "react";
import type { CharacterProfile } from "../app/types";
import { arenaCharacterAbilities, arenaAbilityIsLearned, createArenaCharacterKit } from "../game/arenaCharacter";
import { ACTION_CONTROLS, actionBindingId, type ActionBindingLayer, type ActionBindingControl } from "../game/actionBindings";
import type { ArenaActorSnapshot } from "../game/arenaTypes";
import { actionKeyLabel } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore";
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
export function ArenaActionBar({ character, actor, layer, inputMode, blocked, onCast, feedback }: {
character: CharacterProfile;
actor: ArenaActorSnapshot | null;
layer: ActionBindingLayer;
inputMode: "keyboard" | "gamepad";
blocked: boolean;
onCast: (abilityId: string) => void;
feedback: string;
}) {
const ui = useUiLayoutStore();
const bindingLabel = useInputBindingLabels();
const level = actor?.level ?? character.level;
const kit = useMemo(() => createArenaCharacterKit(character, level), [character, level]);
const abilities = useMemo(() => arenaCharacterAbilities(character, level), [character, level]);
const layout = ui.preferences.current[currentUiDevice()];
const bindings = actor?.actionBindings ?? kit.actionBindings;
const desktopBars = layout.wowBars ? layout.bars.slice(0, layout.barCount).map((bar) => ({ ...bar, slots: bar.slots.map((slot) => ({ ...slot, label: actionKeyLabel(slot.key) })) }))
: [{ columns: 8, slots: PC_ACTION_CONTROLS.map((control) => ({ abilityId: bindings[actionBindingId(layer, control)], label: bindingLabel("pc", `ability:${layer}:${control}`) })) }];
const items = Object.fromEntries(abilities.map((ability): [string, ActionBarItem] => {
const learned = actor?.abilityIds ? actor.abilityIds.includes(ability.id) : arenaAbilityIsLearned(ability, character, level);
const remaining = Math.max(actor?.cooldowns[ability.id] ?? 0, actor?.gcdRemainingMs ?? 0) / 1000;
return [ability.id, {
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining,
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
}];
}));
const casting = actor?.effects.find((effect) => effect.id.startsWith("casting:"));
const castName = casting ? items[casting.id.slice(8)]?.name : null;
const castBinding = (control: ActionBindingControl) => {
const id = bindings[actionBindingId(layer, control)];
if (id) onCast(id);
};
return <div className="arena-player-hud">
<div className="arena-player-status"><strong>{character.name}</strong><span>Level {level} · {kit.classId.replace("rom-", "")} · {kit.resource.name} {actor?.resource ?? 0}/{actor?.maxResource ?? kit.resource.maximum}</span></div>
<div className="arena-extra-resources" aria-label="Character resources">
{Object.entries(actor?.combat?.resourcePools ?? {}).filter(([type]) => type !== actor?.resourceType).map(([type, amount]) => <span key={type}>{type}: {Math.round(amount ?? 0)}/{actor?.combat?.maxResourcePools[type as keyof typeof actor.combat.maxResourcePools] ?? 0}</span>)}
{(actor?.combat?.comboPoints ?? 0) > 0 && <span>Combo points: {actor!.combat!.comboPoints}/5</span>}
{actor?.combat?.runes.map((rune) => <span key={rune.slot}>{rune.type}: {Math.max(0, (rune.readyAt - (useArenaStore.getState().snapshot?.serverTime ?? 0)) / 1000).toFixed(1)}s</span>)}
{(actor?.combat?.shield ?? 0) > 0 && <span>Absorb: {Math.round(actor!.combat!.shield)}</span>}
</div>
<div className="arena-aura-strip" aria-label="Your buffs and debuffs">{actor?.effects.filter((effect) => !effect.id.startsWith("casting:")).slice(0, 12).map((effect, index) => <span className={effect.disposition === "debuff" ? "is-debuff" : ""} key={`${effect.id}:${index}`} title={effect.id}>{effect.icon && <img src={effect.icon} alt="" />}{effect.id.replace(/ \(DBC.*$/, "")} {effect.stacks && effect.stacks > 1 ? `×${effect.stacks}` : ""} {effect.remainingMs > 0 ? `${Math.ceil(effect.remainingMs / 1000)}s` : ""}</span>)}</div>
{(actor?.combat?.summons.length ?? 0) > 0 && <div className="arena-pet-orders" aria-label="Pet commands">{(["attack", "defend", "stop", "recall"] as const).map((command) => <button key={command} type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command })}>{command}</button>)}</div>}
{castName && <div className="arena-cast-status" role="status">{actor?.combat?.activeCast?.mode === "channel" ? "Channeling" : "Casting"} {castName} · {(casting!.remainingMs / 1000).toFixed(1)}s <button type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command: "cancel-cast" })}>Cancel</button></div>}
{feedback && <div className="arena-action-feedback" role="status">{feedback}</div>}
{currentUiDevice() === "pc" && inputMode !== "gamepad" ? (
<nav className="arena-personal-bars" aria-label="Your arena action bars">
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
{bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null;
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
onClick={() => item && onCast(item.id)}>
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button>;
})}
</section>)}
</nav>
) : <ActionBar variant="compact" inputMode={inputMode} activeLayer={layer}
items={Object.fromEntries(ACTION_CONTROLS.map((control) => [control, items[bindings[actionBindingId(layer, control)] ?? ""] ?? null])) as ActionBarItems}
onActivate={castBinding} />}
<small className="arena-control-hints">{inputMode === "gamepad"
? `Left stick move · Right stick look · ${bindingLabel("controller", "target-next")} enemy · ${bindingLabel("controller", "party-next")} ally`
: `${["move-forward", "move-left", "move-backward", "move-right"].map((id) => bindingLabel("pc", id)).join("/")} move · Hold right mouse to look · ${bindingLabel("pc", "target-next")} enemy · Click a team frame to target`}</small>
</div>;
}
+57
View File
@@ -0,0 +1,57 @@
import { lazy, Suspense, useMemo } from "react";
import type { CharacterProfile } from "../app/types";
import { classById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { useArenaStore } from "../game/arenaStore";
import { abilitiesForCharacter, abilityAtLevel } from "../game/abilityCatalog";
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot, ArenaStageManifest } from "../game/arenaTypes";
import { SpellbookPanel } from "./SpellbookPanel";
import { EquipmentPanel } from "./EquipmentPanel";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { KeybindsPanel } from "./KeybindsPanel";
import { OptionsPanel } from "./OptionsPanel";
import { UiLayoutEditor } from "./UiLayoutEditor";
const Talents = lazy(() => import("./TalentGameplayPanel"));
export function ArenaPanels({ character, actor, snapshot, stage, cast }: {
character: CharacterProfile; actor: ArenaActorSnapshot | null; snapshot: ArenaAuthoritativeSnapshot;
stage: ArenaStageManifest; cast: (id: string) => void;
}) {
const overlay = useGameStore((state) => state.overlay);
const settings = useCombatStore((state) => state.settings);
const presentation = useShellStore((state) => state.combatPresentationSettings);
const close = () => useGameStore.getState().closeOverlay();
const color = classById(character.classId).color;
const abilities = useMemo(() => abilitiesForCharacter(character.classId, character.secondaryClassId).map((definition) => {
const rank = definition.talentEntryId === undefined ? undefined : actor?.combat?.talentRanks[`coa-entry-${definition.talentEntryId}`];
const ability = abilityAtLevel(definition, actor?.level ?? character.level, rank);
return { id: ability.id, name: ability.name, description: ability.description, level: ability.unlockLevel,
color, sigil: ability.sigil, iconUrl: ability.icon, known: ability.passive || Boolean(actor?.abilityIds?.includes(ability.id)),
passive: ability.passive, resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource,
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max };
}), [actor?.level, actor?.abilityIds, character, color]);
return <>
<SpellbookPanel abilities={abilities} playerLevel={actor?.level ?? character.level} open={overlay === "spellbook"} onClose={close}
onActivate={(id) => { close(); cast(id); }} />
<EquipmentPanel readOnly />
{overlay === "talents" && <Suspense fallback={<div className="arena-result-panel">Loading talents…</div>}><Talents readOnly /></Suspense>}
<AbilityBindingsPanel /><KeybindsPanel /><UiLayoutEditor />
<OptionsPanel settings={settings} open={overlay === "options"} onClose={close}
onChange={(patch) => useCombatStore.getState().updateSettings(patch)} onReset={() => useCombatStore.getState().resetSettings()}
presentationSettings={presentation} onPresentationChange={(patch) => useShellStore.getState().updateCombatPresentationSettings(patch)}
onPresentationReset={() => useShellStore.getState().resetCombatPresentationSettings()} />
{(overlay === "map" || overlay === "party" || overlay === "boss-guide") && <section className="arena-result-panel arena-reference-panel" role="dialog" aria-label="Arena overview">
<button type="button" onClick={close}>Close</button><h2>{stage.name}</h2>
<svg viewBox={`${stage.bounds.min[0]} ${stage.bounds.min[2]} ${stage.bounds.max[0] - stage.bounds.min[0]} ${stage.bounds.max[2] - stage.bounds.min[2]}`} aria-label="Arena map">
{stage.lineOfSight.map((object) => <rect key={object.id} x={object.position[0] - object.size[0] / 2} y={object.position[2] - object.size[2] / 2} width={object.size[0]} height={object.size[2]} fill="#717c76" />)}
{snapshot.actors.filter((entry) => entry.alive).map((entry) => <circle key={entry.id} cx={entry.position[0]} cy={entry.position[2]} r={entry.id === actor?.id ? 2 : 1.4} fill={entry.team === actor?.team ? "#71c9ff" : "#ff736e"}><title>{entry.name}</title></circle>)}
</svg>
<p>Win by defeating the opposing team. Healing dampening: {Math.round(snapshot.dampening * 100)}%.</p>
<p>Talents and equipment use the loadout you entered with. Change them between matches.</p>
<div className="arena-pet-orders">{(["attack", "defend", "stop", "recall"] as const).map((command) => <button key={command} type="button" onClick={() => useArenaStore.getState().intent({ type: "command", command })}>{command}</button>)}</div>
</section>}
</>;
}
+1 -1
View File
@@ -70,7 +70,7 @@ export function ContentManagerScreen({ onClose }: { readonly onClose: () => void
<article key={pack.id} className="content-pack-row">
<div>
<strong>{pack.label}</strong>
<small>{formatBytes(pack.bytes)} / {pack.fileCount} files</small>
<small>{formatBytes(pack.downloadBytes ?? pack.bytes)} download / {pack.fileCount} files</small>
</div>
<span className={installed ? "is-installed" : ""}>
{update ? "Update available" : bundled ? "Included — always available" : installed ? "Installed — offline ready" : "Not installed"}
+3 -1
View File
@@ -8,8 +8,10 @@ import {
interactWithActiveDungeonObject,
} from "../game/dungeonWorldInteraction";
import { useGameStore } from "../game/store";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
export function DungeonInteractionPrompt() {
const label = useInputBindingLabels();
const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode);
const overlay = useGameStore((state) => state.overlay);
@@ -28,7 +30,7 @@ export function DungeonInteractionPrompt() {
onClick={() => interactWithActiveDungeonObject()}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
>
<span className="dungeon-interaction-prompt__key">{inputMode === "gamepad" ? "A" : "E"}</span>
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
<span>
<strong>{candidate.object.interaction!.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small>
+7 -6
View File
@@ -184,7 +184,7 @@ function itemType(item: InventoryItem): string {
].filter(Boolean).join(" · ");
}
function EquipmentDialog() {
function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const character = useShellStore((state) => state.activeCharacter);
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
@@ -255,6 +255,7 @@ function EquipmentDialog() {
const equip = (item: InventoryItem) => {
setInspectedId(item.instanceId);
if (readOnly) return;
useCombatStore.getState().equipItem(item.instanceId, owner.id);
};
const actions = useMemo<MenuAction[]>(() => [
@@ -264,7 +265,7 @@ function EquipmentDialog() {
})),
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
id: `equipment-slot-${slot}`,
run: () => useCombatStore.getState().unequipItem(owner.id, slot),
run: () => { if (!readOnly) useCombatStore.getState().unequipItem(owner.id, slot); },
}] : []),
...STAT_PAGES.map((page) => ({
id: `equipment-stat-${page}`,
@@ -393,7 +394,7 @@ function EquipmentDialog() {
type="button"
className={`equipment-slot equipment-quality--${item.quality}`}
onFocus={() => setInspectedId(item.instanceId)}
disabled={!earned}
disabled={readOnly || !earned}
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
>
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
@@ -484,7 +485,7 @@ function EquipmentDialog() {
<button
type="button"
className="button button--primary"
disabled={!compatible}
disabled={readOnly || !compatible}
onClick={() => equip(inspected)}
>{inspectedOwnerId === owner.id ? "Re-equip item" : `Equip on ${owner.name}`}</button>
</>
@@ -505,7 +506,7 @@ function EquipmentDialog() {
);
}
export function EquipmentPanel() {
export function EquipmentPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
const open = useGameStore((state) => state.overlay === "inventory");
return open ? <EquipmentDialog /> : null;
return open ? <EquipmentDialog readOnly={readOnly} /> : null;
}
+5 -15
View File
@@ -1,3 +1,4 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import {
useCallback,
useEffect,
@@ -142,6 +143,7 @@ export function DungeonCompletePrompt({
}
export function Hud() {
const bindingLabel = useInputBindingLabels();
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
const character = useShellStore((state) => state.activeCharacter);
@@ -206,15 +208,6 @@ export function Hud() {
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!pendingGroundTarget) return;
const cancel = (event: KeyboardEvent) => {
if (event.key === "Escape") useCombatStore.getState().cancelGroundTarget();
};
window.addEventListener("keydown", cancel);
return () => window.removeEventListener("keydown", cancel);
}, [pendingGroundTarget]);
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
useCombatStore.getState().updateSettings({ threatMeterPosition });
}, []);
@@ -388,7 +381,7 @@ export function Hud() {
<section className="ground-target-reticle" role="status" aria-live="polite">
<div aria-hidden="true"><i /><b /></div>
<strong>Place ground effect</strong>
<small>Move with WASD / left stick. Confirm with Enter / A; Escape / B cancels.</small>
<small>Confirm: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-confirm")} · Cancel: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "ground-cancel")}</small>
<span>
<button
type="button"
@@ -473,11 +466,8 @@ export function Hud() {
<EditableHudElement id="hints">{!activeCast && (
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
{inputMode === "gamepad"
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
: cameraLookActive
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
{cameraLookActive ? "Release right mouse for cursor · " : "Hold right mouse to look · "}
Forward: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "move-forward")} · Target: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "target-next")} · Pause: {bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "pause")}
</div>
)}</EditableHudElement>
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from "react";
import { Capacitor } from "@capacitor/core";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { defaultUiLayout } from "../game/uiLayout";
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "../game/keybindPreferences";
import {
CONTROLLER_BINDING_CHOICES, INPUT_ACTIONS, INPUT_ACTION_BY_ID, conflictingInputActions,
defaultInputDevice, defaultInputBindings, inputBindingLabel, inputKeyForEvent, normalizeInputBinding, replaceInputBinding, type InputDevice,
} from "../input/keybinds";
import { getControllerHeldTokens, interceptControllerInput, subscribeControllerMovement } from "../input/controller";
import { setCapturingInput } from "../input/keybindRuntime";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./keybinds.css";
const PC_KEYS = [
...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
"Space", "Tab", "Enter", "Escape", "Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Backquote", "Minus", "Equal", "BracketLeft", "BracketRight", "Backslash", "Semicolon", "Quote", "Comma", "Period", "Slash",
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
];
function KeybindsDialog() {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState("");
const [group, setGroup] = useState("All actions");
const [conflictsOnly, setConflictsOnly] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
const [listening, setListening] = useState(false);
const [pending, setPending] = useState<{ id: string; key: string; conflicts: string[] } | null>(null);
const [resetPending, setResetPending] = useState(false);
const [message, setMessage] = useState("");
const [manualKey, setManualKey] = useState("KeyW");
const [modifiers, setModifiers] = useState<string[]>([]);
const [controllerModifier, setControllerModifier] = useState("");
const dialog = useRef<HTMLElement>(null);
const cloud = useCloudSaveStatus();
const online = useShellStore((state) => Boolean(state.session?.accessToken));
const abilities = useCombatStore((state) => state.abilities);
const assignments = useCombatStore((state) => state.actionBindings);
const bindings = profileInputBindings(draft, device);
const conflicts = INPUT_ACTIONS.filter((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).length > 0);
const detail = (id: string): string => {
const abilityId = id.startsWith("ability:") ? assignments[id.slice(8) as keyof typeof assignments]
: id.startsWith("bar:") ? draft.current.pc.bars[Number(id.split(":")[1])]?.slots[Number(id.split(":")[2])]?.abilityId : null;
if (id.startsWith("ability:") || id.startsWith("bar:")) return abilities.find((ability) => ability.id === abilityId)?.name ?? "Empty ability slot";
if (id === "loot" && device === "controller") return "When interaction is available, it takes priority over the base Cross ability.";
return "";
};
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = () => useGameStore.getState().openOverlay("options");
const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => {
setDraft((current) => withProfileInputBindings(current, device, replaceInputBinding(profileInputBindings(current, device), device, id, key)));
setMessage(`${INPUT_ACTION_BY_ID[id].label}: ${inputBindingLabel(device, key)}. Save changes to keep it.`);
cancelEdit();
};
const offer = (key: string) => {
if (!editing) return;
endCapture();
const collisions = conflictingInputActions(bindings, device, editing, key);
if (collisions.length) setPending({ id: editing, key, conflicts: collisions });
else apply(editing, key);
};
const startCapture = () => { setCapturingInput(true); setListening(true); };
const editBinding = (id: string) => {
setEditing(id); setManualKey(device === "pc" ? "KeyW" : "Button0"); setModifiers([]); setControllerModifier("");
};
const save = () => {
try { useUiLayoutStore.getState().persist(draft); setMessage("Keybinds saved to your profile."); }
catch (error) { setMessage(error instanceof Error ? error.message : "Could not save keybinds."); }
};
const restoreMovement = () => {
setDraft((current) => restoreMovementBindings(current, device));
setMessage("Movement and jump restored. Conflicting bindings were cleared. Save changes to keep this repair.");
};
const restoreDefaults = () => {
let defaults = defaultInputBindings(device);
if (device === "pc") defaultUiLayout().bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { defaults[`bar:${b}:${s}`] = slot.key; }));
setDraft((current) => withProfileInputBindings(current, device, defaults));
cancelEdit(); setMessage(`${device === "pc" ? "PC" : "Controller"} defaults restored. Save changes to keep them.`);
};
const actions: MenuAction[] = pending ? [
{ id: "keep", run: cancelEdit }, { id: "replace", run: () => apply(pending.id, pending.key) },
] : resetPending ? [{ id: "keep", run: cancelEdit }, { id: "reset-confirm", run: restoreDefaults }]
: editing ? [{ id: "capture", run: startCapture }, { id: "clear", run: () => apply(editing, null) }, { id: "keep", run: cancelEdit }]
: [
{ id: "tab-pc", run: () => setDevice("pc") }, { id: "tab-controller", run: () => setDevice("controller") },
{ id: "movement", run: restoreMovement }, { id: "conflicts", run: () => setConflictsOnly(!conflictsOnly) },
...shown.map((entry) => ({ id: `edit-${entry.id}`, run: () => editBinding(entry.id) })),
{ id: "reset", run: () => setResetPending(true) }, { id: "close", run: close }, { id: "save", run: save },
];
const controller = useMenuController(actions, { onBack: () => editing || resetPending ? cancelEdit() : close() });
const button = (id: string, label: string, run: () => void, className = "") => <ControllerButton
key={id} controlId={id} selectedId={controller.selectedId} select={controller.select} data-control-id={id}
type="button" aria-label={label} className={`button ${className}`} onClick={run}>{className.includes("keybinds-change") ? "Change" : label}</ControllerButton>;
useEffect(() => {
const selected = [...(dialog.current?.querySelectorAll<HTMLElement>("[data-control-id]") ?? [])].find((element) => element.dataset.controlId === controller.selectedId);
selected?.scrollIntoView({ block: "nearest" });
}, [controller.selectedId]);
useEffect(() => () => setCapturingInput(false), []);
useEffect(() => {
if (!listening || !editing) return;
let captured = false;
const ignored = new Set(getControllerHeldTokens());
const accept = (key: string | null) => { if (!key || captured) return; captured = true; offer(key); };
const onKey = (event: KeyboardEvent) => {
event.preventDefault(); event.stopImmediatePropagation();
if (event.repeat) return;
if (event.code === "Escape") { endCapture(); return; }
if (device === "pc") accept(inputKeyForEvent(event));
};
window.addEventListener("keydown", onKey, true);
const releaseController = interceptControllerInput(({ token, pressed, repeat }) => {
if (pressed === false) {
const wasIgnored = ignored.delete(token);
if (!wasIgnored && device === "controller" && !captured && ["Button4", "Button6"].includes(token)) accept(token);
return true;
}
if (repeat || ignored.has(token)) return true;
if (token === "SystemBack") { endCapture(); return true; }
if (device !== "controller" || ["Button4", "Button6"].includes(token)) return true;
const held = [...getControllerHeldTokens()].filter((part) => part !== token && !ignored.has(part) && part.startsWith("Button"));
accept(normalizeInputBinding("controller", [...held, token].join("&")));
return true;
});
const releaseMotion = subscribeControllerMovement((motion) => {
if (device !== "controller") return;
const axes = [motion.moveX, motion.moveY, motion.lookX, motion.lookY];
const index = axes.findIndex((axis) => Math.abs(axis) > .65);
if (index >= 0) accept(`Axis${index}${axes[index] > 0 ? "+" : "-"}`);
});
return () => { window.removeEventListener("keydown", onKey, true); releaseController(); releaseMotion(); };
}, [listening, editing, device]);
useEffect(() => {
if (listening) return;
const onKey = (event: KeyboardEvent) => {
if (event.code !== "Escape") return;
event.preventDefault(); event.stopImmediatePropagation();
if (editing || resetPending) cancelEdit(); else close();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [listening, editing, resetPending]);
const manualBinding = device === "pc" ? normalizeInputBinding(device, [...modifiers, manualKey].join("+"))
: normalizeInputBinding(device, [controllerModifier, manualKey].filter(Boolean).join("&"));
return <div className="modal-backdrop game-panel-backdrop keybinds-backdrop">
<section ref={dialog} className="game-panel keybinds-panel" role="dialog" aria-modal="true" aria-labelledby="keybinds-title">
<header className="game-panel__header"><div><p className="eyebrow">Your controls</p><h2 id="keybinds-title">Keybinds</h2></div>
<button type="button" className="icon-button" onClick={close} aria-label="Close keybinds">×</button></header>
<div className="keybinds-toolbar" inert={editing !== null || resetPending}>
<div className="keybinds-tabs" role="tablist" aria-label="Input type">{(["pc", "controller"] as const).map((tab) => <ControllerButton
key={tab} controlId={`tab-${tab}`} selectedId={controller.selectedId} select={controller.select} type="button" role="tab"
aria-selected={device === tab} onClick={() => setDevice(tab)}>{tab === "pc" ? "PC" : "Controller"}</ControllerButton>)}</div>
<p>Both input types work on every device. Changes apply when saved.</p>
<div className="keybinds-filters"><label>Search actions or keys<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Try W, jump, or a skill name" /></label>
<label>Category<select value={group} onChange={(event) => setGroup(event.target.value)}>{["All actions", ...new Set(INPUT_ACTIONS.map((entry) => entry.group))].map((value) => <option key={value}>{value}</option>)}</select></label></div>
<div className="keybinds-tools">{button("movement", "Restore movement", restoreMovement)}
{button("conflicts", `${conflictsOnly ? "Show all actions" : "Show conflicts"} (${conflicts.length})`, () => setConflictsOnly(!conflictsOnly))}
<small>{shown.length} actions shown</small></div>
</div>
<div className="keybinds-list" inert={editing !== null || resetPending}>
{conflicts.length > 0 && <p className="keybinds-warning">Conflicting controls need attention. Movement takes priority over old conflicting ability bindings.</p>}
{shown.map((entry, index) => <div key={entry.id}>
{(index === 0 || shown[index - 1].group !== entry.group) && <h3>{entry.group}</h3>}
<div className={`keybinds-row ${conflicts.some((conflict) => conflict.id === entry.id) ? "has-conflict" : ""}`}>
<span><strong>{entry.label}</strong>{detail(entry.id) && <small>{detail(entry.id)}</small>}
{entry.group === "WoW action bars" && <small>{draft.current.pc.wowBars && Number(entry.id.split(":")[1]) < draft.current.pc.barCount ? "PC WoW layout" : "Inactive until this PC bar is enabled"}</small>}
{conflicts.some((conflict) => conflict.id === entry.id) && <small className="keybinds-warning">Also used by {conflictingInputActions(bindings, device, entry.id, bindings[entry.id]).map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}</small>}</span>
<kbd>{inputBindingLabel(device, bindings[entry.id])}</kbd>
{button(`edit-${entry.id}`, `Change ${entry.label}`, () => editBinding(entry.id), "keybinds-change")}
</div>
</div>)}
{!shown.length && <p>No matching actions.</p>}
<p className="keybinds-note">Point and click / tap selects a target. Hold right mouse and drag to turn the camera. Android system Back remains available for navigation. Ability slots cast the skills assigned in Ability controls; WoW bar keys follow the loaded PC UI template.</p>
</div>
{(editing || resetPending) && <div className="keybinds-capture" role="group" aria-label="Change binding">
{pending ? <><h3>Replace conflicting bindings?</h3><p><kbd>{inputBindingLabel(device, pending.key)}</kbd> is already assigned to {pending.conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}.</p>
<p>Replacing will unbind those actions and assign it to {INPUT_ACTION_BY_ID[pending.id].label}.</p>
<div>{button("keep", "Keep existing bindings", cancelEdit)}{button("replace", "Replace bindings", () => apply(pending.id, pending.key))}</div></>
: resetPending ? <><h3>Restore {device === "pc" ? "PC" : "Controller"} defaults?</h3><p>This resets this input type, including its action-bar keys. Assigned skills stay in place.</p>
{button("keep", "Keep my bindings", cancelEdit)}{button("reset-confirm", "Restore defaults", restoreDefaults)}</>
: editing && <><h3>{INPUT_ACTION_BY_ID[editing].label}</h3><p>Current: {inputBindingLabel(device, bindings[editing])}</p>
{listening ? <><p role="status">{device === "pc" ? "Press a key or modifier + key…" : "Press a controller button, hold L1/L2 and press a button, or move a stick…"}</p><p>Escape cancels capture. Use the selectors below to bind Escape or any controller button.</p><button type="button" onClick={endCapture}>Stop listening</button></>
: button("capture", device === "pc" ? "Listen for key" : "Listen for controller", startCapture)}
<div className="keybinds-manual"><p>Or choose a binding using touch or mouse:</p>
{device === "pc" ? <div className="keybinds-modifiers">{["Ctrl", "Alt", "Shift", "Meta"].map((modifier) => <label key={modifier}><input type="checkbox" checked={modifiers.includes(modifier)} onChange={(event) => setModifiers((current) => event.target.checked ? [...current, modifier] : current.filter((part) => part !== modifier))} />{modifier}</label>)}</div>
: <label>Hold modifier<select value={controllerModifier} onChange={(event) => setControllerModifier(event.target.value)}><option value="">None</option>{CONTROLLER_BINDING_CHOICES.filter((key) => key.startsWith("Button")).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>}
<label>{device === "pc" ? "Key" : "Button or stick direction"}<select value={manualKey} onChange={(event) => setManualKey(event.target.value)}>{(device === "pc" ? PC_KEYS : CONTROLLER_BINDING_CHOICES).map((key) => <option key={key} value={key}>{inputBindingLabel(device, key)}</option>)}</select></label>
<button type="button" disabled={!manualBinding} onClick={() => manualBinding && offer(manualBinding)}>Use selected binding</button>
</div><div>{button("clear", "Clear binding", () => apply(editing, null))}{button("keep", "Cancel binding change", cancelEdit)}</div></>}
</div>}
<footer className="keybinds-footer" inert={editing !== null || resetPending}>
<p role="status">{message || (online ? "Saved controls sync with your account and are included in UI exports." : "Offline controls save to this profile. UI export/import transfers them to another device.")}</p>
{online && <p role="status">{cloud.message}{cloud.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
<div>{button("reset", `Reset ${device === "pc" ? "PC" : "Controller"} defaults`, () => setResetPending(true))}
{button("close", "Done", close)}{button("save", "Save changes", save, "button--primary")}</div>
</footer>
</section>
</div>;
}
export function KeybindsPanel() {
const open = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null;
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useGameStore } from "../game/store";
import { useMenuController } from "../input/useMenuController";
import { DungeonMapGraphic } from "./DungeonMapGraphic";
@@ -6,6 +7,7 @@ import { resolveStagePresentation } from "../game/manastormStagePresentation";
import { useManastormStore } from "../game/manastormStore";
function MapDialog() {
const bindingLabel = useInputBindingLabels();
const close = useGameStore((state) => state.closeMap);
const openOverlay = useGameStore((state) => state.openOverlay);
const position = useGameStore((state) => state.playerPosition);
@@ -44,7 +46,7 @@ function MapDialog() {
{stagePresentation.usingFallbackEnvironment
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
: "Gold markers show encounters. Dashed lines show database patrol routes. "}
Press M to close or B to return to Pause.
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
</p>
</aside>
);
+6 -1
View File
@@ -1,6 +1,8 @@
import type { CSSProperties } from "react";
import type { Vector3Tuple } from "../game/dungeonManifest";
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useGameStore } from "../game/store";
export interface MinimapProps {
position: Vector3Tuple;
@@ -19,6 +21,9 @@ export function Minimap({
rotationMode = "north-up",
onOpenMap,
}: MinimapProps) {
const bindingLabel = useInputBindingLabels();
const inputMode = useGameStore((state) => state.inputMode);
const mapKey = bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "map");
const heading = ((Math.round((yaw * 180) / Math.PI) % 360) + 360) % 360;
return (
<section className="hud-minimap" aria-label="Dungeon minimap">
@@ -35,7 +40,7 @@ export function Minimap({
</button>
<div className="hud-minimap__caption">
<strong>{areaName}</strong>
<span>{Math.round(distanceTravelled)} m explored · <kbd>M</kbd> map</span>
<span>{Math.round(distanceTravelled)} m explored · {mapKey !== "Unbound" && <kbd>{mapKey}</kbd>} map</span>
</div>
</section>
);
+10 -9
View File
@@ -13,7 +13,7 @@ import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, type CombatVfxMode } from "../gam
import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore";
import { OptionsPanel, nextCombatVfxMode } from "./OptionsPanel";
function renderOptions(mode: CombatVfxMode, originalMediaEnabled = false): string {
function renderOptions(mode: CombatVfxMode, originalMediaEnabled = DEFAULT_COMBAT_PRESENTATION_SETTINGS.originalMediaEnabled): string {
return renderToStaticMarkup(createElement(OptionsPanel, {
settings: DEFAULT_GAMEPLAY_SETTINGS,
onChange: () => undefined,
@@ -31,28 +31,29 @@ describe("combat visual mode option", () => {
combatMedia.ready = false;
});
it("cycles between generated and strict original selections", () => {
it("allows an explicit generated override of the original-first default", () => {
expect(nextCombatVfxMode("generated")).toBe("original");
expect(nextCombatVfxMode("original")).toBe("generated");
});
it("renders generated as the default selection", () => {
it("renders original as the default selection while media is unavailable", () => {
const markup = renderOptions(DEFAULT_COMBAT_PRESENTATION_SETTINGS.vfxMode);
expect(markup).toContain("Spell visual mode");
expect(markup).toContain("Original spell effects &amp; sounds");
expect(markup).toContain("Not downloaded by default.");
expect(markup).toContain("download automatically when enabled.");
expect(markup).toContain(">Download</button>");
expect(markup).toContain("Generated effects are used until the optional original media pack is downloaded.");
expect(markup).toContain(">Generated</button>");
expect(markup).toContain("Generated visuals are the backup while original media is unavailable.");
expect(markup).toContain("missing sounds stay silent.");
expect(markup).toContain(">Original</button>");
});
it("states that original mode never substitutes generated effects", () => {
it("explains original effects take priority and generated visuals are only a fallback", () => {
combatMedia.ready = true;
const markup = renderOptions("original", true);
expect(markup).toContain("Use only packaged original source effects.");
expect(markup).toContain("Unsupported spells show no visual effect.");
expect(markup).toContain("Use original spell effects first, with generated visuals only when an original effect is unavailable.");
expect(markup).toContain("Missing sounds stay silent.");
expect(markup).toContain(">Original</button>");
expect(markup).toContain(">Remove</button>");
});
+11 -7
View File
@@ -123,13 +123,13 @@ function OptionsDialog({
const toggleOriginalMedia = () => {
if (combatMediaAction.kind === "downloading" || combatMediaAction.kind === "removing") return;
if (combatMediaInstalled && !presentationSettings.originalMediaEnabled) {
onPresentationChange({ originalMediaEnabled: true });
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
return;
}
if (combatMediaInstalled) {
setCombatMediaAction({ kind: "removing" });
void removeCombatMediaPack().then(() => {
onPresentationChange({ originalMediaEnabled: false, vfxMode: "generated" });
onPresentationChange({ originalMediaEnabled: false });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
@@ -141,7 +141,7 @@ function OptionsDialog({
void downloadCombatMediaPack(({ completed, total }) => {
setCombatMediaAction({ kind: "downloading", completed, total });
}).then(() => {
onPresentationChange({ originalMediaEnabled: true });
onPresentationChange({ originalMediaEnabled: true, vfxMode: "original" });
setCombatMediaAction({ kind: "idle" });
}).catch((error: unknown) => setCombatMediaAction({
kind: "error",
@@ -164,6 +164,7 @@ function OptionsDialog({
previewFrameRate: nextFrameRateLimit(settings.previewFrameRate, PREVIEW_FRAME_RATE_LIMITS),
});
const actions = useMemo<MenuAction[]>(() => [
{ id: "option-keybinds", run: () => useGameStore.getState().openOverlay("keybinds") },
{ id: "option-gameplay-frame-rate", run: cycleGameplayFrameRate },
{ id: "option-preview-frame-rate", run: cyclePreviewFrameRate },
...TOGGLES.map((option) => ({
@@ -221,6 +222,9 @@ function OptionsDialog({
</header>
<div className="options-panel__list">
<div className="option-row"><span><strong>Keybinds</strong><small>View and change every PC and Controller action. Resolve conflicts or restore movement.</small></span>
{optionButton("option-keybinds", "Keybinds", () => useGameStore.getState().openOverlay("keybinds"))}
</div>
<h3 className="options-panel__section-title">Performance</h3>
<div className="option-row">
<span><strong>Gameplay frame rate</strong><small>Limit 3D gameplay rendering without changing combat or physics timing.</small></span>
@@ -286,10 +290,10 @@ function OptionsDialog({
<span>
<strong>Original spell effects &amp; sounds</strong>
<small>{originalMediaReady
? "Stored on this device. Original visuals and authentic combat sounds are available."
? "Stored on this device. Original spell effects and sounds are ready. Missing sounds stay silent."
: combatMediaInstalled
? "Stored on this device. Enable the pack to use original visuals and authentic combat sounds."
: "Not downloaded by default. Download this optional media pack to unlock original visuals and authentic combat sounds."}</small>
: "Original spell effects and sounds download automatically when enabled. Generated visuals cover missing effects; missing sounds stay silent."}</small>
{combatMediaAction.kind === "error"
? <small role="status">{combatMediaAction.message}</small>
: null}
@@ -305,9 +309,9 @@ function OptionsDialog({
<span>
<strong>Spell visual mode</strong>
<small>{!originalMediaReady
? "Generated effects are used until the optional original media pack is downloaded."
? "Original effects are preferred by default. Generated visuals are the backup while original media is unavailable."
: presentationSettings.vfxMode === "original"
? "Use only packaged original source effects. Unsupported spells show no visual effect."
? "Use original spell effects first, with generated visuals only when an original effect is unavailable."
: "Use the current generated combat effects."}</small>
</span>
{optionButton(
+10 -11
View File
@@ -4,8 +4,11 @@ import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { isGameMasterSession } from "../game/manastormAdminConfig";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
function PauseDialog() {
const bindingLabel = useInputBindingLabels();
const closeOverlay = useGameStore((state) => state.closeOverlay);
const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance);
@@ -41,6 +44,7 @@ function PauseDialog() {
{ id: "pause-spellbook", run: () => openOverlay("spellbook") },
{ id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") },
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
@@ -77,6 +81,7 @@ function PauseDialog() {
{menuButton("pause-spellbook", "Spellbook", () => openOverlay("spellbook"))}
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
@@ -86,17 +91,11 @@ function PauseDialog() {
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
</div>
<dl className="controls-list">
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
<div><dt>Jump</dt><dd>Space / L3</dd></div>
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
<div><dt>Abilities</dt><dd>1–8 / Face, R1, R2, D-pad left/right</dd></div>
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
<div><dt>Boss guide</dt><dd>Pause menu</dd></div>
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
<div><dt>Pause</dt><dd>Escape / Start</dd></div>
{["move-forward", "jump", "target-next", "party-next", "loot", "inventory", "spellbook", "pause"].map((id) => <div key={id}>
<dt>{INPUT_ACTION_BY_ID[id].label}</dt><dd>{bindingLabel("pc", id)} / {bindingLabel("controller", id)}</dd>
</div>)}
<div><dt>Camera</dt><dd>Hold right mouse and drag</dd></div>
<div><dt>All controls</dt><dd>Options → Keybinds</dd></div>
</dl>
</section>
</div>
+4 -3
View File
@@ -13,7 +13,7 @@ function treeSigil(name: string): string {
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
}
export default function TalentGameplayPanel() {
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
@@ -42,7 +42,8 @@ export default function TalentGameplayPanel() {
availablePoints={runtimeUnspentTalentPoints(level, talentRanks, classId)}
abilityEssence={coaEssence?.ability}
talentEssence={coaEssence?.talent}
onInvest={(nodeId) => { useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => useCombatStore.getState().resetTalents()}
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
readOnly={readOnly}
/>;
}
+7 -5
View File
@@ -26,6 +26,7 @@ export interface TalentTreeView {
}
export interface TalentsPanelProps {
readOnly?: boolean;
trees: readonly TalentTreeView[];
ranks: TalentRanks;
classId: ClassId;
@@ -109,6 +110,7 @@ function TalentsDialog({
onInvest,
onReset,
onClose,
readOnly = false,
}: Omit<TalentsPanelProps, "open" | "onClose"> & { onClose: () => void }) {
const [activeTreeId, setActiveTreeId] = useState(() => trees[0]?.id ?? "");
const [detailNodeId, setDetailNodeId] = useState(() => trees[0]?.nodes[0]?.id ?? "");
@@ -167,7 +169,7 @@ function TalentsDialog({
id: nodeControlId(node.id),
run: () => {
setDetailNodeId(node.id);
if (!state?.failure) onInvest(node.id);
if (!readOnly && !state?.failure) onInvest(node.id);
},
neighbors: {
up: up ? nodeControlId(up.id) : tabControlId(activeTreeId),
@@ -180,7 +182,7 @@ function TalentsDialog({
return [
...tabActions,
...nodeActions,
{ id: "talent-reset", run: onReset, enabled: spent > 0 },
{ id: "talent-reset", run: onReset, enabled: !readOnly && spent > 0 },
];
}, [activeNodes, activeTreeId, nodeStateById, onInvest, onReset, spent, trees]);
const controller = useMenuController(actions, {
@@ -219,7 +221,7 @@ function TalentsDialog({
const activateNode = (treeId: string, node: TalentUiNode) => {
inspectNode(treeId, node.id);
if (!nodeStateById.get(node.id)?.failure) onInvest(node.id);
if (!readOnly && !nodeStateById.get(node.id)?.failure) onInvest(node.id);
};
return (
@@ -333,7 +335,7 @@ function TalentsDialog({
className={`talent-node ${learned ? "is-learned" : ""} ${maximum ? "is-max" : ""} ${locked ? "is-locked" : ""} ${unavailable ? "is-unavailable" : ""} ${detailNode?.id === node.id ? "is-inspected" : ""}`}
style={{ gridColumn: node.column, gridRow: node.row } as CSSProperties}
aria-label={label}
aria-disabled={state.failure !== null}
aria-disabled={readOnly || state.failure !== null}
aria-describedby={detailNode?.id === node.id ? "talent-node-details" : undefined}
title={`${label} — ${talentUiDescriptionForRank(node, state.rank || 1)}`}
onFocus={() => inspectNode(tree.id, node.id)}
@@ -429,7 +431,7 @@ function TalentsDialog({
select={controller.select}
type="button"
className="button"
disabled={spent <= 0}
disabled={readOnly || spent <= 0}
onClick={onReset}
>Reset talents</ControllerButton>
</div>
+10 -2
View File
@@ -8,6 +8,8 @@ import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
} from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore";
export function UiLayoutEditor() {
@@ -52,6 +54,12 @@ export function UiLayoutEditor() {
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
const { bar, slot: button } = state.selectedButton;
const bindings = profileInputBindings(state.preferences, "pc");
const conflicts = conflictingInputActions(bindings, "pc", `bar:${bar}:${button}`, key).filter((id) => !id.startsWith("bar:"));
if (conflicts.length) {
setMessage(`That key belongs to ${conflicts.map((id) => INPUT_ACTION_BY_ID[id].label).join(", ")}. Change it in Options → Keybinds first. Your existing bindings were kept.`);
useUiLayoutStore.setState({ selectedButton: null }); return;
}
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
useUiLayoutStore.setState({ selectedButton: null });
@@ -118,7 +126,7 @@ export function UiLayoutEditor() {
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
</div>}
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots and both saved layouts with this file?</p><button type="button" onClick={() => attempt(() => {
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots, both saved layouts, and profile keybinds with this file?</p><button type="button" onClick={() => attempt(() => {
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
@@ -144,7 +152,7 @@ export function UiLayoutEditor() {
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
</div>)}
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Bound keys take priority over game shortcuts." : "Click a bar button to assign an ability."}</p>
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Keys already used by game controls are protected. Review all bindings in Options → Keybinds." : "Click a bar button to assign an ability."}</p>
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
+2
View File
@@ -91,6 +91,8 @@ export async function preloadSharedGameplayResources(originalMediaEnabled: boole
loadCompanionManastormPanel(),
]);
if (!originalMediaEnabled) return;
const { ensureCombatMediaPack } = await import("../game/combatMediaPack");
if (!await ensureCombatMediaPack()) return;
const { preloadCombatEffectManifest } = await import("../game/combatPresentation");
await preloadCombatEffectManifest();
}
+50
View File
@@ -0,0 +1,50 @@
.keybinds-backdrop { padding: 0; }
.keybinds-panel { width: min(1000px, 96vw); height: min(900px, 94dvh); max-height: 94dvh; padding: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.keybinds-panel .game-panel__header { padding: 16px 20px 6px; flex-shrink: 0; }
.keybinds-panel button, .keybinds-panel select, .keybinds-panel input { min-height: 42px; }
.keybinds-toolbar, .keybinds-footer { flex-shrink: 0; padding: 12px 20px; }
.keybinds-toolbar p, .keybinds-footer p { margin: 6px 0; font-size: 13px; color: #b7c7b5; }
.keybinds-tabs, .keybinds-tools, .keybinds-footer > div, .keybinds-capture > div { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.keybinds-tabs button { flex: 1; border: 1px solid #5a6846; padding: 8px 18px; background: #14231b; color: #dfe9d6; border-radius: 6px; }
.keybinds-tabs button[aria-selected="true"] { color: #ffe4a0; background: #344531; border-color: #d0ae65; }
.keybinds-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 10px 0; }
.keybinds-filters label, .keybinds-manual > label { display: grid; gap: 5px; font-size: 12px; color: #c6d0c0; }
.keybinds-panel select, .keybinds-panel input:not([type="checkbox"]) { width: 100%; min-width: 0; border: 1px solid #56674c; background: #0e1a14; color: #f0eada; border-radius: 5px; padding: 8px; }
.keybinds-list { flex: 1; min-height: 0; overflow-y: auto; padding: 0 20px 12px; overscroll-behavior: contain; }
.keybinds-list h3 { color: #dcc88e; padding: 12px 0 7px; margin: 0; font-size: 14px; }
.keybinds-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(90px, 210px) 90px; align-items: center; gap: 14px; border-top: 1px solid #344432; padding: 9px 0; }
.keybinds-row strong { font-size: 14px; }
.keybinds-row small { display: block; font-size: 11px; margin-top: 4px; color: #aabb9f; }
.keybinds-row kbd { white-space: normal; overflow-wrap: anywhere; text-align: center; padding: 5px 8px; color: #e6d8b5; background: #14221a; }
.keybinds-change { font-size: 13px; }
.keybinds-warning, .keybinds-row .keybinds-warning { color: #f0ba79; }
.keybinds-row.has-conflict { border-left: 3px solid #dc9d56; padding-left: 9px; }
.keybinds-note { font-size: 12px; line-height: 1.6; color: #aabb9f; }
.keybinds-footer { border-top: 1px solid #586041; background: #111e16; }
.keybinds-footer > div { justify-content: flex-end; margin-top: 10px; }
.keybinds-capture { position: absolute; z-index: 4; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(560px, 92%); max-height: 90%; overflow: auto; padding: 24px; border: 1px solid #d0ae65; border-radius: 12px; background: #14221b; box-shadow: 0 0 0 100vmax #0009; }
.keybinds-capture h3 { margin-top: 0; color: #ffe4a0; }
.keybinds-capture p { line-height: 1.5; }
.keybinds-capture button { padding: 8px 12px; border: 1px solid #6b7555; border-radius: 6px; background: #253826; color: #f0eada; cursor: pointer; }
.keybinds-capture .keybinds-manual { display: grid; gap: 10px; margin: 18px 0; border-top: 1px solid #5a6846; padding-top: 12px; }
.keybinds-modifiers { display: flex; flex-wrap: wrap; gap: 14px; }
.keybinds-modifiers label { display: flex; align-items: center; gap: 5px; }
.keybinds-panel :focus-visible { outline: 2px solid #ffe4a0; outline-offset: 2px; }
@media (max-width: 600px) {
.keybinds-panel { width: 100vw; height: 100dvh; max-height: 100dvh; border-radius: 0; }
.keybinds-toolbar, .keybinds-footer, .keybinds-list { padding-left: 12px; padding-right: 12px; }
.keybinds-toolbar { padding-top: 6px; padding-bottom: 6px; }
.keybinds-row { grid-template-columns: minmax(0, 1fr) 105px 64px; gap: 8px; }
.keybinds-row strong { font-size: 12px; }
.keybinds-row kbd { font-size: 11px; }
.keybinds-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
.keybinds-footer .button { padding: 8px; font-size: 11px; }
.keybinds-footer p { font-size: 11px; }
.keybinds-capture { padding: 16px; }
}
@media (max-height: 600px) {
.keybinds-panel { height: 100dvh; max-height: 100dvh; }
.keybinds-toolbar > p, .keybinds-footer > p:empty { display: none; }
.keybinds-toolbar, .keybinds-footer { padding-top: 5px; padding-bottom: 5px; }
.keybinds-panel .game-panel__header { padding-top: 8px; padding-bottom: 8px; }
}