Release Healer Man 0.1.6

This commit is contained in:
phenom committed 2026-08-20 14:05:50 -04:00
1 parent 55cfd43d66
commit b12fb84859
96 files changed
+10874 -609

No files matched your search

+10 -4
View File
@@ -20,6 +20,7 @@ import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment";
import { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig";
import { useManastormStore } from "../game/manastormStore";
import { ManastormPortal } from "./ManastormPortal";
@@ -40,6 +41,9 @@ import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement";
import { GmPlacementMarkers } from "./GmPlacementMarkers";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -88,6 +92,8 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
@@ -96,9 +102,7 @@ export function GameScene() {
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| paused
|| mapOpen
|| companionOpen;
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -236,7 +240,9 @@ export function GameScene() {
{worldReady && (
<>
<PlayerRig />
<PartyPopulation active={!simulationBlocked} />
<OnlinePlayerPopulation active={!simulationBlocked} />
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
<GroundEffectTelegraphs />
<MobPopulation
active={!simulationBlocked}
entities={stagePopulation.entities}
+40
View File
@@ -0,0 +1,40 @@
import { DoubleSide } from "three";
import { useCombatStore } from "../game/combatStore";
/** 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);
return (
<group name="combat-ground-effects">
{pending ? (
<mesh
name="ground-target-reticle"
position={[pending.position[0], pending.position[1] + 0.045, pending.position[2]]}
rotation={[-Math.PI / 2, 0, 0]}
renderOrder={5}
>
<ringGeometry args={[Math.max(0.1, pending.radius - 0.16), pending.radius, 64]} />
<meshBasicMaterial
color={pending.valid ? "#78dfac" : "#e55f57"}
transparent
opacity={0.78}
depthWrite={false}
side={DoubleSide}
/>
</mesh>
) : null}
{effects.map((effect) => (
<mesh
key={effect.id}
position={[effect.position[0], effect.position[1] + 0.035, effect.position[2]]}
rotation={[-Math.PI / 2, 0, 0]}
renderOrder={4}
>
<ringGeometry args={[Math.max(0.1, effect.radius - 0.12), effect.radius, 64]} />
<meshBasicMaterial color="#e9c95c" transparent opacity={0.58} depthWrite={false} side={DoubleSide} />
</mesh>
))}
</group>
);
}
+5
View File
@@ -6,6 +6,7 @@ import { MANASTORM_BUBBLE_DEFAULT_RADIUS } from "../game/manastormClientOverlay"
import { advanceManastormSession } from "../game/manastormSession";
import { useManastormStore } from "../game/manastormStore";
import { useGameStore } from "../game/store";
import { requestOnlineInteraction } from "../game/onlineSessionRuntime";
const STORM_PARTICLES = [
[-0.9, 1.4, 0.4],
@@ -101,6 +102,10 @@ export function ManastormPortal() {
useGameStore.getState().playerPosition,
);
if (inside && !wasInsideRef.current) {
if (requestOnlineInteraction("portal")) {
wasInsideRef.current = true;
return;
}
transitionLatchedRef.current = true;
transitionStartedAtRef.current = Date.now();
displayedCountdownRef.current = NEXT_WAVE_COUNTDOWN_SECONDS;
+41 -1
View File
@@ -73,6 +73,7 @@ import {
resolveMobAnimationInterval,
} from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -916,7 +917,9 @@ function ProxyMob({
if (event.button !== PRIMARY_MOUSE_BUTTON) return;
event.stopPropagation();
if (dead) {
useCombatStore.getState().lootMob(instanceId);
if (!requestOnlineInteraction("loot", instanceId)) {
useCombatStore.getState().lootMob(instanceId);
}
return;
}
usePartyStore.getState().clearSelection();
@@ -1188,6 +1191,25 @@ function RoamingPack({
const forward = scratchForwards[index];
const member = definition.members[index];
const instanceId = `${definition.id}:${member.id}`;
const synchronizedTransform = onlineSynchronizedMobTransform(instanceId);
if (synchronizedTransform) {
const object = memberRefs.current[index];
if (object) {
object.position.set(...synchronizedTransform.position);
object.rotation.y = synchronizedTransform.yaw;
}
chasePositions[index][0] = synchronizedTransform.position[0];
chasePositions[index][1] = synchronizedTransform.position[1];
chasePositions[index][2] = synchronizedTransform.position[2];
updateMobRuntimePosition(
instanceId,
synchronizedTransform.position[0],
synchronizedTransform.position[1],
synchronizedTransform.position[2],
synchronizedTransform.yaw,
);
continue;
}
const mob = combat.mobs[instanceId];
const chasePosition = chasePositions[index];
const engaged = Boolean(mob?.engaged && !mob.dead);
@@ -1303,6 +1325,24 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
useFrame((_, delta) => {
const synchronizedTransform = onlineSynchronizedMobTransform(spawn.id);
if (synchronizedTransform) {
positionRef.current[0] = synchronizedTransform.position[0];
positionRef.current[1] = synchronizedTransform.position[1];
positionRef.current[2] = synchronizedTransform.position[2];
if (groupRef.current) {
groupRef.current.position.set(...synchronizedTransform.position);
groupRef.current.rotation.y = synchronizedTransform.yaw;
}
updateMobRuntimePosition(
spawn.id,
synchronizedTransform.position[0],
synchronizedTransform.position[1],
synchronizedTransform.position[2],
synchronizedTransform.yaw,
);
return;
}
const mob = useCombatStore.getState().mobs[spawn.id];
const position = positionRef.current;
const player = useGameStore.getState().playerPosition;
+140
View File
@@ -0,0 +1,140 @@
import { Html } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef, type CSSProperties } from "react";
import type { Group } from "three";
import {
classById,
clampAppearance,
createDefaultAppearance,
type ClassId,
type GenderId,
type RaceId,
} from "../app/characterCatalog";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { CharacterModel, type AvatarIdentity } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { useCombatStore } from "../game/combatStore";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
import { usePartyStore } from "../game/partyStore";
const labelStyle: CSSProperties = {
color: "#d8f6ff",
fontFamily: "system-ui, sans-serif",
fontSize: "11px",
fontWeight: 700,
lineHeight: 1.1,
pointerEvents: "none",
textAlign: "center",
textShadow: "0 1px 2px #000, 0 0 5px #000",
userSelect: "none",
whiteSpace: "nowrap",
};
function validAppearance(player: OnlineSessionPlayer): AvatarIdentity {
const raceId = (player.character?.raceId ?? "human") as RaceId;
const gender = (player.character?.gender ?? "male") as GenderId;
const source = player.character?.appearance;
const appearance = source && [source.skinColor, source.face, source.hairStyle, source.hairColor, source.feature].every(Number.isFinite)
? source
: createDefaultAppearance();
return {
raceId,
gender,
classId: (player.character?.classId ?? "priest") as ClassId,
appearance: clampAppearance(raceId, gender, appearance),
};
}
function RemoteOnlinePlayer({ player, active }: {
readonly player: OnlineSessionPlayer;
readonly active: boolean;
}) {
const rootRef = useRef<Group>(null);
const movingRef = useRef(false);
const animationEventRef = useRef(player.animationEvent);
animationEventRef.current = player.animationEvent;
const previousTargetRef = useRef<[number, number, number]>([...player.position]);
const identity = useMemo(() => validAppearance(player), [player.character]);
const characterClass = classById(identity.classId);
const actorId = onlinePlayerActorId(player.accountId);
const selected = useCombatStore((state) => state.selectedFriendlyActorId === actorId);
useFrame((_, delta) => {
const root = rootRef.current;
if (!root) return;
const target = player.position;
const previousTarget = previousTargetRef.current;
movingRef.current = Math.hypot(
target[0] - previousTarget[0],
target[2] - previousTarget[2],
) > 0.015;
previousTarget[0] = target[0];
previousTarget[1] = target[1];
previousTarget[2] = target[2];
const alpha = 1 - Math.exp(-18 * Math.min(delta, 0.05));
root.position.x += (target[0] - root.position.x) * alpha;
root.position.y += (target[1] - root.position.y) * alpha;
root.position.z += (target[2] - root.position.z) * alpha;
const yawDelta = Math.atan2(Math.sin(player.yaw - root.rotation.y), Math.cos(player.yaw - root.rotation.y));
root.rotation.y += yawDelta * alpha;
});
return (
<group
ref={rootRef}
name={`online-player-${player.accountId}`}
position={player.position}
rotation={[0, player.yaw, 0]}
onClick={(event) => {
event.stopPropagation();
event.nativeEvent.stopPropagation();
if (event.button !== 0 || !active || player.health <= 0) return;
useCombatStore.getState().clearTarget();
usePartyStore.getState().clearSelection();
useCombatStore.getState().selectFriendlyActor(actorId);
}}
>
<mesh position={[0, 1.05, 0]}>
<capsuleGeometry args={[0.48, 1.2, 6, 10]} />
<meshBasicMaterial transparent opacity={0} colorWrite={false} depthWrite={false} />
</mesh>
<CharacterModel
identity={identity}
equipment={player.equipment}
active={active}
movingRef={movingRef}
animationEventRef={animationEventRef}
fallback={<ProceduralAvatar accent={characterClass.color} />}
/>
{selected && player.health > 0 ? (
<mesh position={[0, 0.035, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.62, 0.055, 6, 28]} />
<meshBasicMaterial color="#8deaff" transparent opacity={0.95} depthWrite={false} />
</mesh>
) : null}
<Html center position={[0, 2.45, 0]} zIndexRange={[5, 0]} style={{
...labelStyle,
opacity: player.health > 0 ? 1 : 0.55,
}}>
<div>{player.character?.name ?? player.username}<br /><small>Online player · {player.role ?? "party"}</small></div>
</Html>
</group>
);
}
export function OnlinePlayerPopulation({ active = true }: { readonly active?: boolean }) {
const snapshot = useOnlineSessionStore((state) => state.snapshot);
if (!snapshot) return null;
const remotePlayers = snapshot.players.filter((player) => (
player.accountId !== snapshot.localAccountId && player.character
));
if (!remotePlayers.length) return null;
return (
<group name="online-player-population">
{remotePlayers.map((player) => (
<RemoteOnlinePlayer key={player.accountId} player={player} active={active} />
))}
</group>
);
}
+78 -1
View File
@@ -65,6 +65,7 @@ import { partyCombatRangeBand } from "../game/partyRuntime";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import {
clearPartyRuntimeAttackCorridor,
getPartyRuntimePosition,
registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition,
@@ -75,6 +76,8 @@ import {
import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { registerCombatSpatialProvider } from "../game/combatSpatial";
import { PLAYER_AGGRO_ID } from "../game/aggro";
const PARTY_MOVE_SPEED = 5.25;
const PARTY_CATCH_UP_SPEED = 7.5;
@@ -266,12 +269,14 @@ function initialMemberPosition(index: number): MutablePartyWorldPosition {
function PartyActors({
active,
simulate,
navigationGraph,
navigationFailed,
objectives,
stackAtEntrance,
}: {
readonly active: boolean;
readonly simulate: boolean;
readonly navigationGraph: PartyNavigationGraph | null;
readonly navigationFailed: boolean;
readonly objectives: readonly DungeonBossObjective[];
@@ -442,6 +447,56 @@ function PartyActors({
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const memberIdKey = members.map((member) => member.id).join("|");
useEffect(() => registerCombatSpatialProvider({
actor: (actorId) => {
const gameState = useGameStore.getState();
if (actorId === PLAYER_AGGRO_ID) {
return { id: actorId, position: gameState.playerPosition, yaw: gameState.cameraYaw };
}
const memberIndex = usePartyStore.getState().members.findIndex((member) => member.id === actorId);
const memberPosition = positionsRef.current.get(actorId);
if (memberPosition) {
return {
id: actorId,
position: memberPosition,
yaw: actorRefs.current[memberIndex]?.rotation.y ?? gameState.cameraYaw,
};
}
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === actorId);
if (summon) return { id: actorId, position: summon.position, yaw: gameState.cameraYaw };
const mobPosition = getMobPosition(actorId);
if (!mobPosition) return null;
const mob = useCombatStore.getState().mobs[actorId];
const targetPosition = mob?.targetActorId === PLAYER_AGGRO_ID
? gameState.playerPosition
: mob?.targetActorId
? positionsRef.current.get(mob.targetActorId)
?? useCombatStore.getState().summons.find((candidate) => candidate.id === mob.targetActorId)?.position
: null;
const yaw = targetPosition
? Math.atan2(targetPosition[0] - mobPosition[0], targetPosition[2] - mobPosition[2])
: 0;
return { id: actorId, position: mobPosition, yaw };
},
hasLineOfSight: hasStaticLineOfSight,
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
const dx = requested[0] - origin[0];
const dz = requested[2] - origin[2];
const planar = Math.hypot(dx, dz);
const scale = planar > maximumDistance && planar > 0 ? maximumDistance / planar : 1;
const x = origin[0] + dx * scale;
const z = origin[2] + dz * scale;
const ray = new rapier.Ray({ x, y: requested[1] + 8, z }, { x: 0, y: -1, z: 0 });
const hit = world.castRay(
ray,
20,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
},
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
party.members.forEach((member, index) => {
@@ -606,6 +661,21 @@ function PartyActors({
if (!active) return;
const party = usePartyStore.getState();
if (!party.members.length) return;
if (!simulate) {
party.members.forEach((member, index) => {
const synchronized = getPartyRuntimePosition(member.id);
if (!synchronized) return;
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
position[0] = synchronized[0];
position[1] = synchronized[1];
position[2] = synchronized[2];
positionsRef.current.set(member.id, position);
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
const movingSignal = movingSignalsRef.current.get(member.id);
if (movingSignal) movingSignal.current = false;
});
return;
}
const game = useGameStore.getState();
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
@@ -1320,7 +1390,13 @@ function PartyActors({
);
}
export function PartyPopulation({ active = true }: { readonly active?: boolean }) {
export function PartyPopulation({
active = true,
simulate = true,
}: {
readonly active?: boolean;
readonly simulate?: boolean;
}) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision);
@@ -1383,6 +1459,7 @@ export function PartyPopulation({ active = true }: { readonly active?: boolean }
<PartyActors
key={activeDungeonId}
active={active}
simulate={simulate}
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !assets.navigation}
objectives={objectives}
+13 -3
View File
@@ -180,6 +180,15 @@ function PlayerController({
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25);
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
}
const groundTargetActive = useCombatStore.getState().pendingGroundTarget !== null;
if (groundTargetActive && !paused && !mapOpen && !companionOpen) {
useCombatStore.getState().moveGroundTarget(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
delta,
);
}
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
@@ -222,7 +231,8 @@ function PlayerController({
body.setNextKinematicTranslation(translation);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
if (requestedAtMs !== null && !groundTargetActive) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
if (groundTargetActive) cancelBufferedPlayerJump(jumpTimingRef.current);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
@@ -238,8 +248,8 @@ function PlayerController({
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
groundTargetActive ? 0 : snapshot.moveX,
groundTargetActive ? 0 : snapshot.moveForward,
orbit.yaw,
);
if (body.numColliders() === 0) return;