Release Healer Man 0.1.5

This commit is contained in:
phenom committed 2026-08-18 16:22:19 -04:00
1 parent f4c9cf356c
commit 55cfd43d66
62 files changed
+3030 -153

No files matched your search

+9 -3
View File
@@ -29,7 +29,7 @@ import {
rendererCanvasKey,
} from "./sceneLifecycle";
import { manastormChaoticLinkRuntimeIds } from "../game/manastormChaoticLink";
import { manastormStagePopulation } from "../game/manastormStagePopulation";
import { manastormStagePopulation, manastormStageRoamingPacks } from "../game/manastormStagePopulation";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import {
useWailingEncounterStore,
@@ -38,6 +38,8 @@ import {
import { WailingNaralexEvent } from "./WailingNaralexEvent";
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement";
import { GmPlacementMarkers } from "./GmPlacementMarkers";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -73,7 +75,10 @@ export function GameScene() {
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
[dungeon, gameMode, manastormEncounter],
);
const roamingPacks = dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask));
const roamingPacks = manastormStageRoamingPacks(
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
gameMode === "manastorm" ? manastormEncounter : null,
);
const difficultySpawns = stagePopulation.staticSpawns.filter(
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
);
@@ -223,7 +228,7 @@ export function GameScene() {
/>
<Physics
key={encounterWorldKey(activeDungeonId, sessionRevision)}
gravity={[0, -18, 0]}
gravity={[0, PLAYER_GRAVITY, 0]}
paused={simulationBlocked}
timeStep={1 / 60}
>
@@ -241,6 +246,7 @@ export function GameScene() {
/>
<WailingNaralexEvent />
<ManastormPortal />
<GmPlacementMarkers />
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
</>
)}
+88
View File
@@ -0,0 +1,88 @@
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import type { Group } from "three";
import { useShellStore } from "../app/shellStore";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { Vector3Tuple } from "../game/dungeonTypes";
import {
authoredStageConfigForBoss,
derivePartySpawnSlots,
isGameMasterSession,
resolvedBossAdminCatalog,
} from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
import { getMobRuntimePosition } from "../game/mobRuntimeRegistry";
import { useGameStore } from "../game/store";
function WorldMarker(props: {
readonly position: Vector3Tuple;
readonly color: string;
readonly scale?: number;
readonly runtimeId?: string;
}) {
const group = useRef<Group>(null);
useFrame(() => {
if (!props.runtimeId || !group.current) return;
const live = getMobRuntimePosition(props.runtimeId);
if (live) group.current.position.set(live[0], live[1], live[2]);
});
return (
<group ref={group} position={props.position} scale={props.scale ?? 1}>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.04, 0]} renderOrder={20}>
<ringGeometry args={[0.55, 0.76, 28]} />
<meshBasicMaterial color={props.color} transparent opacity={0.9} depthTest={false} />
</mesh>
<mesh position={[0, 0.72, 0]} renderOrder={20}>
<coneGeometry args={[0.16, 1.25, 10]} />
<meshBasicMaterial color={props.color} transparent opacity={0.78} depthTest={false} />
</mesh>
</group>
);
}
function fallbackMobPosition(dungeonId: string, runtimeId: string, bossPosition: Vector3Tuple): Vector3Tuple {
const dungeon = requireDungeonDefinition(dungeonId);
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === runtimeId);
if (staticSpawn) return staticSpawn.position;
for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === runtimeId);
const origin = pack.waypoints[0];
if (member && origin) {
return [origin[0] + member.formationOffset[0], origin[1] + member.formationOffset[1], origin[2] + member.formationOffset[2]];
}
}
return bossPosition;
}
export function GmPlacementMarkers() {
const overlay = useGameStore((state) => state.overlay);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const session = useShellStore((state) => state.session);
const draft = useManastormAdminStore((state) => state.draft);
const editingBossKey = useManastormAdminStore((state) => state.editingBossKey);
const entry = useMemo(
() => resolvedBossAdminCatalog(draft).find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null,
[activeDungeonId, draft, editingBossKey],
);
const stage = useMemo(() => entry ? authoredStageConfigForBoss(entry, draft) : null, [draft, entry]);
const party = useMemo(() => stage ? derivePartySpawnSlots(stage.groupSpawn, 5) : [], [stage]);
if (overlay !== "gm" || !isGameMasterSession(session) || !entry || !stage) return null;
return (
<group name="gm-placement-markers">
{party.map((slot, index) => (
<WorldMarker key={`party-${index}`} position={slot.footPosition} color="#55e8ff" scale={index === 0 ? 1.15 : 0.8} />
))}
<WorldMarker position={stage.bossPosition} color="#ff485f" scale={1.4} />
<WorldMarker position={stage.portalPosition} color="#c26bff" scale={1.2} />
{stage.linkedMobRuntimeIds.map((runtimeId) => (
<WorldMarker
key={runtimeId}
position={fallbackMobPosition(stage.dungeonId, runtimeId, stage.bossPosition)}
color="#ffd255"
runtimeId={runtimeId}
/>
))}
</group>
);
}
+13 -4
View File
@@ -1146,9 +1146,10 @@ function RoamingPack({
const cleanups = definition.members.map((member, index) => registerMobRuntime(
`${definition.id}:${member.id}`,
scratchPositions[index],
Math.atan2(scratchForwards[index][0], scratchForwards[index][2]),
));
return () => cleanups.forEach((cleanup) => cleanup());
}, [definition.id, definition.members, scratchPositions]);
}, [definition.id, definition.members, scratchForwards, scratchPositions]);
useFrame((_, delta) => {
if (!nearby) return;
@@ -1242,16 +1243,18 @@ function RoamingPack({
chasePosition[1] = patrolPosition[1];
chasePosition[2] = patrolPosition[2];
}
const yaw = Math.atan2(forward[0], forward[2]);
updateMobRuntimePosition(
instanceId,
position[0],
position[1],
position[2],
yaw,
);
const object = memberRefs.current[index];
if (!object) continue;
object.position.set(position[0], position[1], position[2]);
object.rotation.y = Math.atan2(forward[0], forward[2]);
object.rotation.y = yaw;
}
});
@@ -1298,7 +1301,7 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const entity = entities[spawn.entityId];
const groupRef = useRef<Group>(null);
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
useEffect(() => registerMobRuntime(spawn.id, spawn.position), [spawn.id, spawn.position]);
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
useFrame((_, delta) => {
const mob = useCombatStore.getState().mobs[spawn.id];
const position = positionRef.current;
@@ -1334,7 +1337,6 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
position[1] = spawn.position[1];
position[2] = spawn.position[2];
}
updateMobRuntimePosition(spawn.id, position[0], position[1], position[2]);
if (groupRef.current) {
groupRef.current.position.set(position[0], position[1], position[2]);
if (mob?.engaged) {
@@ -1345,6 +1347,13 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
);
}
}
updateMobRuntimePosition(
spawn.id,
position[0],
position[1],
position[2],
groupRef.current?.rotation.y ?? spawn.yaw ?? 0,
);
});
if (!entity) return null;
+6 -13
View File
@@ -23,6 +23,7 @@ import { equipmentItemsForOwner } from "../game/equipment";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/manastormAdminConfig";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import type { DungeonBossObjective } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
@@ -534,21 +535,13 @@ function PartyActors({
useEffect(() => {
const game = useGameStore.getState();
const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1);
members.forEach((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
if (game.gameMode === "manastorm" || stackAtEntrance) {
position[0] = game.playerPosition[0];
position[1] = game.playerPosition[1];
position[2] = game.playerPosition[2];
} else {
setEntrancePosition(
index,
game.playerPosition,
Math.sin(game.cameraYaw),
Math.cos(game.cameraYaw),
position,
);
}
const slot = spawnSlots[index + 1] ?? spawnSlots[0];
position[0] = slot.footPosition[0];
position[1] = slot.footPosition[1];
position[2] = slot.footPosition[2];
const navigationSpawn = navigationGraphRef.current
? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6)
: null;
+74 -25
View File
@@ -50,6 +50,13 @@ import {
updatePlayerJumpTiming,
type CharacterVerticalMotion,
} from "../game/playerJump";
import {
PLAYER_CHARACTER_CONTROLLER_OFFSET,
PLAYER_GROUND_STICK_VELOCITY,
airbornePlayerVerticalVelocity,
configurePlayerCharacterController,
playerFrameDeltaSeconds,
} from "../game/playerMovement";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
@@ -85,11 +92,13 @@ interface PlayerControllerProps extends PlayerRigRefs {
function placeAtActiveSpawn(body: RapierRigidBody, orbit: OrbitState): void {
const spawn = useGameStore.getState().activeSpawn;
body.setTranslation({
const translation = {
x: spawn.footPosition[0],
y: spawn.footPosition[1] + PLAYER_CENTER_HEIGHT,
z: spawn.footPosition[2],
}, true);
};
body.setTranslation(translation, true);
body.setNextKinematicTranslation(translation);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
orbit.yaw = spawn.yaw;
@@ -122,8 +131,19 @@ function PlayerController({
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
[rapier],
);
const characterController = useMemo(
() => configurePlayerCharacterController(
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
),
[world],
);
const jumpTimingRef = useRef(createPlayerJumpTiming());
const verticalPresentationTimingRef = useRef(createCharacterVerticalPresentationTiming());
const verticalVelocityRef = useRef(0);
useEffect(() => () => {
world.removeCharacterController(characterController);
}, [characterController, world]);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
@@ -131,6 +151,7 @@ function PlayerController({
clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
verticalPresentationTimingRef.current = createCharacterVerticalPresentationTiming();
verticalVelocityRef.current = 0;
verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
@@ -160,7 +181,6 @@ function PlayerController({
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
}
const velocity = body.linvel();
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
@@ -181,20 +201,25 @@ function PlayerController({
undefined,
body,
);
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
const groundedBeforeMove = verticalVelocityRef.current <= 0.5
&& isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
const gameplayBlocked = paused
|| mapOpen
|| companionOpen
|| useCombatStore.getState().health <= 0
|| controlledUntil > Date.now();
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
verticalMotionRef.current = updateCharacterVerticalPresentation(
verticalPresentationTimingRef.current,
grounded,
velocity.y,
groundedBeforeMove,
verticalVelocityRef.current,
nowMs,
false,
);
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
body.setNextKinematicTranslation(translation);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
@@ -202,11 +227,42 @@ function PlayerController({
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
nowMs,
grounded,
velocity.y,
groundedBeforeMove,
verticalVelocityRef.current,
);
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
if (shouldJump) grounded = false;
const movementDeltaSeconds = playerFrameDeltaSeconds(delta);
let verticalVelocity = shouldJump
? PLAYER_JUMP_VELOCITY
: groundedBeforeMove
? PLAYER_GROUND_STICK_VELOCITY
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
if (body.numColliders() === 0) return;
characterController.computeColliderMovement(
body.collider(0),
{
x: worldX * MOVE_SPEED * movementDeltaSeconds,
y: verticalVelocity * movementDeltaSeconds,
z: worldZ * MOVE_SPEED * movementDeltaSeconds,
},
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
);
const computedMovement = characterController.computedMovement();
const nextTranslation = {
x: translation.x + computedMovement.x,
y: translation.y + computedMovement.y,
z: translation.z + computedMovement.z,
};
body.setNextKinematicTranslation(nextTranslation);
const grounded = !shouldJump && characterController.computedGrounded();
if (grounded && verticalVelocity <= 0) verticalVelocity = 0;
verticalVelocityRef.current = verticalVelocity;
verticalMotionRef.current = updateCharacterVerticalPresentation(
verticalPresentationTimingRef.current,
grounded,
@@ -215,13 +271,6 @@ function PlayerController({
shouldJump,
);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
const moving = Math.hypot(worldX, worldZ) > 0.05;
movingRef.current = moving;
if (avatarRef.current && moving) {
@@ -235,11 +284,11 @@ function PlayerController({
: null;
const belowRecoveryPlane = activeManastormPackage
? isBelowManastormRecoveryPlane(
translation.y,
nextTranslation.y,
activeManastormPackage.anchors.map((anchor) => anchor.position[1]),
dungeon.bounds,
)
: isBelowDungeonRecoveryPlane(translation.y, dungeon.bounds);
: isBelowDungeonRecoveryPlane(nextTranslation.y, dungeon.bounds);
if (belowRecoveryPlane) {
useGameStore.getState().resetAtActiveSpawn();
return;
@@ -249,9 +298,9 @@ function PlayerController({
if (reportAccumulator.current >= 0.1) {
reportAccumulator.current = 0;
const footPosition: [number, number, number] = [
translation.x,
translation.y - PLAYER_CENTER_HEIGHT,
translation.z,
nextTranslation.x,
nextTranslation.y - PLAYER_CENTER_HEIGHT,
nextTranslation.z,
];
const distanceDelta = planarDistance(lastReported.current, footPosition);
useGameStore.getState().updatePlayerSnapshot({
@@ -268,7 +317,7 @@ function PlayerController({
<RigidBody
ref={bodyRef}
name="player-capsule"
type="dynamic"
type="kinematicPosition"
colliders={false}
canSleep={false}
enabledRotations={[false, false, false]}