forked from phenom/healer-man
Release Healer Man 0.1.14
This commit is contained in:
1 parent
67d7a8ebe7
commit
7b41b102fa
147 files changed
+169892
-1342
No files matched your search
@@ -13,6 +13,7 @@ import { useCombatStore } from "../game/combatStore";
|
||||
import {
|
||||
combatEffectStyle,
|
||||
preloadCombatEffectManifest,
|
||||
effectiveCombatVfxMode,
|
||||
subscribeCombatPresentation,
|
||||
type CombatPresentationEvent,
|
||||
type CombatVfxQuality,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import {
|
||||
combatAudioOutputEnabled,
|
||||
configureCombatAudio,
|
||||
combatMediaPreloadEnabled,
|
||||
playCombatAudio,
|
||||
resetCombatAudio,
|
||||
setCombatAudioListener,
|
||||
@@ -254,17 +256,18 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
const camera = useThree((state) => state.camera);
|
||||
const forward = useMemo(() => new Vector3(), []);
|
||||
const audioOutputEnabled = combatAudioOutputEnabled(settings, masterVolume);
|
||||
const vfxMode = effectiveCombatVfxMode(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY] = JSON.stringify({
|
||||
mode: settings.vfxMode,
|
||||
mode: vfxMode,
|
||||
quality: effectiveQuality,
|
||||
systems: effects.length,
|
||||
activeLights: 0,
|
||||
allocatedLights: 0,
|
||||
});
|
||||
}, [effectiveQuality, effects.length, settings.vfxMode]);
|
||||
}, [effectiveQuality, effects.length, vfxMode]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (import.meta.env.DEV) delete document.documentElement.dataset[COMBAT_EFFECT_DATASET_KEY];
|
||||
@@ -274,10 +277,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
settingsRef.current = settings;
|
||||
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
|
||||
configureCombatAudio(settings, masterVolume);
|
||||
if (
|
||||
qualityRef.current !== "off"
|
||||
|| combatAudioOutputEnabled(settings, masterVolume)
|
||||
) void preloadCombatEffectManifest();
|
||||
if (combatMediaPreloadEnabled(settings, masterVolume)) void preloadCombatEffectManifest();
|
||||
setEffects((current) => capCombatEffects(current.filter(({ event }) => (
|
||||
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
|
||||
)), qualityRef.current));
|
||||
@@ -333,7 +333,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
return (
|
||||
<group name="combat-effects">
|
||||
{effects.map((effect) => (
|
||||
settings.vfxMode === "original" ? (
|
||||
vfxMode === "original" ? (
|
||||
<OriginalCombatEffect
|
||||
key={`original:${effect.key}`}
|
||||
event={effect.event}
|
||||
|
||||
@@ -1,12 +1,107 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { DoubleSide } from "three";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useCombatStore, type EnemyActiveCast } from "../game/combatStore";
|
||||
import type { EnemyAttackDefinition, EnemyDamageSchool } from "../game/enemyAttacks";
|
||||
import {
|
||||
enemyTelegraphOrigin,
|
||||
enemyTelegraphYaw,
|
||||
} from "../game/enemyTelegraphGeometry";
|
||||
|
||||
const TELEGRAPH_COLORS: Readonly<Record<EnemyDamageSchool, string>> = Object.freeze({
|
||||
physical: "#f2b45e",
|
||||
arcane: "#c68cff",
|
||||
fire: "#ff684e",
|
||||
frost: "#75cfff",
|
||||
nature: "#8ddf68",
|
||||
shadow: "#bd79e8",
|
||||
holy: "#ffe47a",
|
||||
});
|
||||
|
||||
function EnemyCastTelegraph({
|
||||
cast,
|
||||
attack,
|
||||
}: {
|
||||
readonly cast: EnemyActiveCast;
|
||||
readonly attack: EnemyAttackDefinition;
|
||||
}) {
|
||||
const telegraph = attack.telegraph;
|
||||
if (!telegraph) return null;
|
||||
const placement = {
|
||||
sourcePosition: cast.sourcePosition,
|
||||
targetPosition: cast.targetPosition,
|
||||
sourceYaw: cast.sourceYaw,
|
||||
};
|
||||
const origin = enemyTelegraphOrigin(telegraph, placement);
|
||||
const position = [origin[0], origin[1] + 0.055, origin[2]] as const;
|
||||
const color = TELEGRAPH_COLORS[attack.school];
|
||||
if (telegraph.shape === "circle") {
|
||||
const radius = telegraph.radius ?? 1;
|
||||
return (
|
||||
<group position={position} name={`enemy-telegraph-${cast.attackId}`}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={6}>
|
||||
<circleGeometry args={[radius, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.18} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.008, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={7}>
|
||||
<ringGeometry args={[Math.max(0.1, radius - 0.18), radius, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const yaw = enemyTelegraphYaw(placement);
|
||||
if (telegraph.shape === "cone") {
|
||||
const length = telegraph.length ?? 1;
|
||||
const angle = ((telegraph.angleDegrees ?? 90) * Math.PI) / 180;
|
||||
const start = -Math.PI / 2 - angle / 2;
|
||||
return (
|
||||
<group position={position} rotation={[0, yaw, 0]} name={`enemy-telegraph-${cast.attackId}`}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={6}>
|
||||
<circleGeometry args={[length, 48, start, angle]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.2} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.008, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={7}>
|
||||
<ringGeometry args={[Math.max(0.1, length - 0.18), length, 48, 1, start, angle]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const length = telegraph.length ?? 1;
|
||||
const width = telegraph.width ?? 1;
|
||||
return (
|
||||
<group position={position} rotation={[0, yaw, 0]} name={`enemy-telegraph-${cast.attackId}`}>
|
||||
<mesh position={[0, 0, length / 2]} rotation={[Math.PI / 2, 0, 0]} renderOrder={6}>
|
||||
<planeGeometry args={[width + 0.22, length + 0.22]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.009, length / 2]} rotation={[Math.PI / 2, 0, 0]} renderOrder={7}>
|
||||
<planeGeometry args={[width, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.2} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Presentation-only rings; placement and ticking remain in the combat runtime. */
|
||||
export function GroundEffectTelegraphs() {
|
||||
const effects = useCombatStore((state) => state.areaEffects);
|
||||
const pending = useCombatStore((state) => state.pendingGroundTarget);
|
||||
const mobs = useCombatStore((state) => state.mobs);
|
||||
const enemyCasts = useMemo(() => Object.values(mobs).flatMap((mob) => {
|
||||
if (!mob.engaged || mob.dead || !mob.activeCast) return [];
|
||||
const attack = mob.attacks.find((candidate) => candidate.id === mob.activeCast?.attackId);
|
||||
return attack?.telegraph ? [{ mobId: mob.id, cast: mob.activeCast, attack }] : [];
|
||||
}), [mobs]);
|
||||
return (
|
||||
<group name="combat-ground-effects">
|
||||
{enemyCasts.map(({ mobId, cast, attack }) => (
|
||||
<Fragment key={`${mobId}:${cast.attackId}:${cast.startedAt}`}>
|
||||
<EnemyCastTelegraph cast={cast} attack={attack} />
|
||||
</Fragment>
|
||||
))}
|
||||
{pending ? (
|
||||
<mesh
|
||||
name="ground-target-reticle"
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "../game/partyRuntimeRegistry";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import {
|
||||
getMobRuntimeTransform,
|
||||
registerMobRuntime,
|
||||
updateMobRuntimePosition,
|
||||
} from "../game/mobRuntimeRegistry";
|
||||
@@ -1450,6 +1451,100 @@ function StaticPopulationCluster({
|
||||
);
|
||||
}
|
||||
|
||||
function SummonedPopulationMember({
|
||||
instanceId,
|
||||
entity,
|
||||
active,
|
||||
useOriginalModels,
|
||||
}: {
|
||||
readonly instanceId: string;
|
||||
readonly entity: PopulationEntityDefinition;
|
||||
readonly active: boolean;
|
||||
readonly useOriginalModels: boolean;
|
||||
}) {
|
||||
const groupRef = useRef<Group>(null);
|
||||
const initial = getMobRuntimeTransform(instanceId)?.position ?? [0, 0, 0];
|
||||
const positionRef = useRef<MutableMobVector3>([...initial]);
|
||||
useFrame((_, delta) => {
|
||||
const mob = useCombatStore.getState().mobs[instanceId];
|
||||
if (!mob || mob.dead || mob.despawned) return;
|
||||
const player = useGameStore.getState().playerPosition;
|
||||
const targetPosition = mobTargetPosition(mob.targetActorId, player);
|
||||
const position = positionRef.current;
|
||||
const now = Date.now();
|
||||
const movementLocked = mob.statuses.some((status) => status.endsAt > now
|
||||
&& MOVEMENT_LOCKING_MOB_STATUSES.has(status.kind));
|
||||
const feared = mob.statuses.some((status) => status.endsAt > now && status.kind === "fear");
|
||||
const slow = mob.statuses.find((status) => status.endsAt > now && status.kind === "slow")?.magnitude ?? 0;
|
||||
const maximumStep = Math.min(delta, 0.05) * mob.moveSpeed * Math.max(0.1, 1 - slow);
|
||||
if (!movementLocked && feared) {
|
||||
stepFearedMobAway(
|
||||
position,
|
||||
targetPosition,
|
||||
mob.homePosition ?? initial,
|
||||
maximumStep * 1.1,
|
||||
mob.leashRange,
|
||||
position,
|
||||
);
|
||||
} else if (!movementLocked && mob.combatPhase === "chasing") {
|
||||
stepToward(position, targetPosition, maximumStep);
|
||||
}
|
||||
const facingSign = feared && !movementLocked ? -1 : 1;
|
||||
const yaw = Math.atan2(
|
||||
(targetPosition[0] - position[0]) * facingSign,
|
||||
(targetPosition[2] - position[2]) * facingSign,
|
||||
);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(position[0], position[1], position[2]);
|
||||
groupRef.current.rotation.y = yaw;
|
||||
}
|
||||
updateMobRuntimePosition(instanceId, position[0], position[1], position[2], yaw);
|
||||
});
|
||||
return (
|
||||
<group ref={groupRef} position={initial} name={`summoned-mob-${instanceId}`}>
|
||||
<ProxyMob
|
||||
definition={entity}
|
||||
instanceId={instanceId}
|
||||
active={active}
|
||||
moving
|
||||
showLabel={false}
|
||||
useOriginalModels={useOriginalModels}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function SummonedEnemyPopulation({
|
||||
entities,
|
||||
active,
|
||||
useOriginalModels,
|
||||
}: {
|
||||
readonly entities: PopulationDefinitionMap;
|
||||
readonly active: boolean;
|
||||
readonly useOriginalModels: boolean;
|
||||
}) {
|
||||
const mobs = useCombatStore((state) => state.mobs);
|
||||
const summons = useMemo(() => Object.values(mobs).flatMap((mob) => {
|
||||
if (!mob.summonedBy || mob.serverEntry === null || mob.despawned) return [];
|
||||
const entity = entities[`entry-${mob.serverEntry}`]
|
||||
?? Object.values(entities).find((candidate) => candidate.combat?.serverEntry === mob.serverEntry);
|
||||
return entity ? [{ instanceId: mob.id, entity }] : [];
|
||||
}), [entities, mobs]);
|
||||
return (
|
||||
<group name="summoned-enemy-population">
|
||||
{summons.map(({ instanceId, entity }) => (
|
||||
<SummonedPopulationMember
|
||||
key={instanceId}
|
||||
instanceId={instanceId}
|
||||
entity={entity}
|
||||
active={active}
|
||||
useOriginalModels={useOriginalModels}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MobPopulationProps {
|
||||
readonly entities: PopulationDefinitionMap;
|
||||
readonly roamingPacks: readonly MobPackDefinition[];
|
||||
@@ -1512,6 +1607,11 @@ export function MobPopulation({
|
||||
useOriginalModels={useOriginalModels}
|
||||
/>
|
||||
))}
|
||||
<SummonedEnemyPopulation
|
||||
entities={entities}
|
||||
active={active}
|
||||
useOriginalModels={useOriginalModels}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useMemo, useRef, type CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
import type { Group } from "three";
|
||||
import {
|
||||
classById,
|
||||
@@ -17,6 +17,7 @@ import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
|
||||
const labelStyle: CSSProperties = {
|
||||
color: "#d8f6ff",
|
||||
@@ -60,6 +61,11 @@ function RemoteOnlinePlayer({ player, active }: {
|
||||
const actorId = onlinePlayerActorId(player.accountId);
|
||||
const selected = useCombatStore((state) => state.selectedFriendlyActorId === actorId);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
return root ? registerCombatEffectAnchor(actorId, root) : undefined;
|
||||
}, [actorId]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import {
|
||||
AdditiveBlending,
|
||||
BufferAttribute,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Texture,
|
||||
TextureLoader,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import {
|
||||
combatOriginalPhaseAssets,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
type CombatPresentationEvent,
|
||||
type CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
parseOriginalCombatMeshGraph,
|
||||
type OriginalCombatMeshGraph,
|
||||
} from "./originalCombatMeshRuntime";
|
||||
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
|
||||
|
||||
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
|
||||
|
||||
@@ -201,7 +204,7 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
// Graph filenames are keyed to the immutable source M2, while the decoded
|
||||
// browser schema can improve between releases. Revalidate the small JSON so
|
||||
// an older cached decoder payload cannot fail the current strict parser.
|
||||
const request = fetch(url, { cache: "no-cache" })
|
||||
const request = fetchCombatMediaAsset(url)
|
||||
.then((response) => response.ok
|
||||
? response.json()
|
||||
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
|
||||
@@ -218,11 +221,18 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
|
||||
async function loadTexture(url: string): Promise<Texture> {
|
||||
const existing = textureRequestCache.get(url);
|
||||
if (existing) return existing;
|
||||
const request = textureLoader.loadAsync(url).then((texture) => {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.name = `original-combat-effect:${url}`;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
const request = fetchCombatMediaAsset(url).then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Original effect texture request failed (${response.status}): ${url}`);
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
try {
|
||||
const texture = await textureLoader.loadAsync(objectUrl);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.name = `original-combat-effect:${url}`;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
});
|
||||
textureRequestCache.set(url, request);
|
||||
request.catch(() => textureRequestCache.delete(url));
|
||||
@@ -446,7 +456,8 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
readonly active: boolean;
|
||||
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
|
||||
}) {
|
||||
const billboardRef = useRef<Group>(null);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const sourcePlaneRef = useRef<Group>(null);
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry();
|
||||
const sourcePositions = graph.geometry.positions;
|
||||
@@ -457,7 +468,7 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
const positions = new Float32Array(sourcePositions.length);
|
||||
const normals = new Float32Array(sourceNormals.length);
|
||||
for (let index = 0; index < sourcePositions.length; index += 3) {
|
||||
// Turn WoW's Z-up source plane into an upright, camera-facing local XY plane.
|
||||
// Turn WoW's Z-up source plane into an upright local XY plane.
|
||||
positions[index] = -sourcePositions[index + 1];
|
||||
positions[index + 1] = sourcePositions[index + 2];
|
||||
positions[index + 2] = sourcePositions[index] - xCenter;
|
||||
@@ -501,9 +512,14 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
for (const material of materials) material.dispose();
|
||||
}, [geometry, materials]);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
useEffect(() => {
|
||||
// WoW authored this mesh as a billboard. Resolve that orientation once at
|
||||
// spawn so it stays readable without rotating whenever the camera moves.
|
||||
sourcePlaneRef.current?.lookAt(camera.position);
|
||||
}, [camera]);
|
||||
|
||||
useFrame(() => {
|
||||
if (!active) return;
|
||||
if (billboardRef.current) billboardRef.current.lookAt(camera.position);
|
||||
const playback = originalCombatMeshPlaybackFrame(
|
||||
graph,
|
||||
playbackRef.current.elapsedMs,
|
||||
@@ -517,8 +533,22 @@ const OriginalStaticMesh = memo(function OriginalStaticMesh({
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={billboardRef}>
|
||||
<mesh geometry={geometry} material={materials} renderOrder={20} frustumCulled={false} />
|
||||
<group ref={sourcePlaneRef}>
|
||||
<mesh
|
||||
name="original-combat-static-source-plane"
|
||||
geometry={geometry}
|
||||
material={materials}
|
||||
renderOrder={20}
|
||||
frustumCulled={false}
|
||||
/>
|
||||
<mesh
|
||||
name="original-combat-static-cross-plane"
|
||||
geometry={geometry}
|
||||
material={materials}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
renderOrder={20}
|
||||
frustumCulled={false}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
@@ -533,6 +563,7 @@ function LoadedOriginalCombatEffect({
|
||||
const elapsedRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
const groupRef = useRef<Group>(null);
|
||||
const liveAnchorPosition = useMemo(() => new Vector3(), []);
|
||||
const auraDecayStartedAtRef = useRef<number | null>(null);
|
||||
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
|
||||
const particleBudgets = useMemo(() => {
|
||||
@@ -617,8 +648,10 @@ function LoadedOriginalCombatEffect({
|
||||
);
|
||||
} else {
|
||||
const actorId = originalCombatAnchorActorId(event);
|
||||
const live = liveActorPosition(actorId);
|
||||
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
|
||||
if (!updateCombatEffectAnchorPosition(actorId, groupRef.current, liveAnchorPosition)) {
|
||||
const live = liveActorPosition(actorId);
|
||||
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
const terminalDuration = projectile
|
||||
|
||||
@@ -84,6 +84,7 @@ import { useManastormStore } from "../game/manastormStore";
|
||||
import { registerCombatSpatialProvider } from "../game/combatSpatial";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { hasDungeonLineOfSight } from "./dungeonLineOfSight";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
|
||||
const PARTY_MOVE_SPEED = 5.25;
|
||||
const PARTY_CATCH_UP_SPEED = 7.5;
|
||||
@@ -563,7 +564,13 @@ function PartyActors({
|
||||
}
|
||||
if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health);
|
||||
actorRefs.current[index]?.position.set(position[0], position[1], position[2]);
|
||||
return registerPartyRuntimePosition(member.id, position);
|
||||
const runtimeCleanup = registerPartyRuntimePosition(member.id, position);
|
||||
const actor = actorRefs.current[index];
|
||||
const anchorCleanup = actor ? registerCombatEffectAnchor(member.id, actor) : undefined;
|
||||
return () => {
|
||||
runtimeCleanup();
|
||||
anchorCleanup?.();
|
||||
};
|
||||
});
|
||||
// Make the initial facing agree with the player instead of flashing at yaw zero.
|
||||
for (const actor of actorRefs.current) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
consumeMouseLook,
|
||||
readInputSnapshot,
|
||||
} from "../game/inputManager";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
|
||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||
import {
|
||||
equipmentItemsForOwner,
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "../game/playerMovement";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry";
|
||||
|
||||
const PLAYER_RADIUS = 0.36;
|
||||
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||
@@ -145,6 +146,11 @@ function PlayerController({
|
||||
world.removeCharacterController(characterController);
|
||||
}, [characterController, world]);
|
||||
|
||||
useEffect(() => {
|
||||
const avatar = avatarRef.current;
|
||||
return avatar ? registerCombatEffectAnchor(PLAYER_AURA_ENTITY_ID, avatar) : undefined;
|
||||
}, [avatarRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
||||
lastReported.current = [...spawn.footPosition];
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Group, Vector3 } from "three";
|
||||
import {
|
||||
clearCombatEffectAnchorRegistry,
|
||||
registerCombatEffectAnchor,
|
||||
updateCombatEffectAnchorPosition,
|
||||
} from "./combatEffectAnchorRegistry";
|
||||
|
||||
afterEach(() => clearCombatEffectAnchorRegistry());
|
||||
|
||||
describe("combat-effect visual anchors", () => {
|
||||
it("copies the rendered actor position into the effect parent's local space", () => {
|
||||
const world = new Group();
|
||||
world.position.set(10, 2, -4);
|
||||
const actor = new Group();
|
||||
actor.position.set(3, 1, 5);
|
||||
world.add(actor);
|
||||
|
||||
const effectParent = new Group();
|
||||
effectParent.position.set(-2, 4, 1);
|
||||
world.add(effectParent);
|
||||
const effect = new Group();
|
||||
effectParent.add(effect);
|
||||
registerCombatEffectAnchor("party-tank", actor);
|
||||
|
||||
expect(updateCombatEffectAnchorPosition("party-tank", effect, new Vector3())).toBe(true);
|
||||
expect(effect.getWorldPosition(new Vector3()).toArray()).toEqual(
|
||||
actor.getWorldPosition(new Vector3()).toArray(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a replacement registration when an older actor unmounts", () => {
|
||||
const first = new Group();
|
||||
const second = new Group();
|
||||
second.position.set(7, 8, 9);
|
||||
const unregisterFirst = registerCombatEffectAnchor("player", first);
|
||||
registerCombatEffectAnchor("player", second);
|
||||
unregisterFirst();
|
||||
|
||||
const effect = new Group();
|
||||
expect(updateCombatEffectAnchorPosition("player", effect, new Vector3())).toBe(true);
|
||||
expect(effect.position.toArray()).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it("falls back cleanly when an actor has no mounted visual", () => {
|
||||
const effect = new Group();
|
||||
effect.position.set(1, 2, 3);
|
||||
|
||||
expect(updateCombatEffectAnchorPosition("missing", effect, new Vector3())).toBe(false);
|
||||
expect(effect.position.toArray()).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Vector3, type Object3D } from "three";
|
||||
|
||||
const anchors = new Map<string, Object3D>();
|
||||
|
||||
export function registerCombatEffectAnchor(actorId: string, object: Object3D): () => void {
|
||||
anchors.set(actorId, object);
|
||||
return () => {
|
||||
if (anchors.get(actorId) === object) anchors.delete(actorId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an actor's current rendered world position into an effect's parent
|
||||
* space. This follows the scene object directly instead of a throttled combat
|
||||
* or network snapshot while deliberately avoiding inherited actor rotation.
|
||||
*/
|
||||
export function updateCombatEffectAnchorPosition(
|
||||
actorId: string | undefined,
|
||||
effect: Object3D,
|
||||
worldPosition: Vector3,
|
||||
): boolean {
|
||||
if (!actorId) return false;
|
||||
const anchor = anchors.get(actorId);
|
||||
if (!anchor) return false;
|
||||
anchor.getWorldPosition(worldPosition);
|
||||
effect.parent?.worldToLocal(worldPosition);
|
||||
effect.position.copy(worldPosition);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearCombatEffectAnchorRegistry(): void {
|
||||
anchors.clear();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const graphUrl = new URL(
|
||||
"../../public/assets/game/combat-effects/original/wow335/priest/graphs/holydivineshield-state-base-83b519d78c12.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const componentSource = readFileSync(new URL("./OriginalCombatEffect.tsx", import.meta.url), "utf8");
|
||||
|
||||
describe("original combat static-mesh contract", () => {
|
||||
it("parses the shipped Power Word: Shield mesh and its three original layers", () => {
|
||||
@@ -77,6 +78,12 @@ describe("original combat static-mesh contract", () => {
|
||||
expect(originalCombatMeshPlaybackFrame(graph, 1_100, true).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("locks the source shield at spawn and never billboards it during frame updates", () => {
|
||||
expect(componentSource.match(/lookAt\(camera\.position\)/g)).toHaveLength(1);
|
||||
expect(componentSource).not.toContain("useFrame(({ camera })");
|
||||
expect(componentSource).toContain('name="original-combat-static-cross-plane"');
|
||||
});
|
||||
|
||||
it("rejects geometry and draw references that leave the packaged source", () => {
|
||||
const invalidIndex = JSON.parse(readFileSync(graphUrl, "utf8")) as Record<string, unknown>;
|
||||
((invalidIndex.geometry as Record<string, unknown>).indices as number[])[0] = 12;
|
||||
|
||||
Reference in new issue
Block a user