new help section explaining classes and their mechanics

This commit is contained in:
Warren H
2026-07-18 12:05:34 -04:00
parent 638aef22b2
commit 40aa23be46
32 changed files with 1423 additions and 134 deletions
+92 -19
View File
@@ -1,6 +1,6 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
@@ -60,6 +60,7 @@ import {
HEALER_VISUAL_PROFILES,
type HealerVisualProfile,
} from "../game/healerVisuals";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
import {
CHARACTER_MODEL_MODE,
type CharacterAppearanceV1,
@@ -73,7 +74,7 @@ import {
} from "../game/weaponCatalog";
import { resolveCharacterEquipment } from "../game/characterEquipment";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { useGameStore } from "../game/store";
import { BARRIER_RADIUS, useGameStore } from "../game/store";
import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { HealerClassAccessory } from "./HealerClassAccessory";
@@ -117,6 +118,8 @@ import {
GAMEPLAY_FRAME_INTERVAL_MS,
isOutcomePhase,
outcomeElapsedAfterPhaseChange,
resetSceneClockForMode,
sceneCanvasFrameloop,
SIMULATION_STEP_SECONDS,
selectSceneRenderMode,
startSceneFrameLoop,
@@ -965,8 +968,8 @@ const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;
/**
* Owns manual rendering only while gameplay or a finite outcome animation is active.
* Static scenes use R3F demand rendering; suspended scenes render nothing.
* Requests demand frames only while gameplay or a finite outcome animation is
* active. Keeping one R3F clock domain avoids demand/manual RAF handoff races.
*/
function SceneFrameScheduler({
dpr,
@@ -981,9 +984,8 @@ function SceneFrameScheduler({
onDprChange: (next: number) => void;
onOutcomeComplete: () => void;
}) {
const { advance, invalidate } = useThree();
const { clock, get, invalidate } = useThree();
const simulationAccumulator = useRef(0);
const manualTimeSeconds = useRef(0);
const outcomeElapsedSeconds = useRef(0);
const previousOutcomePhase = useRef<OutcomePhase | null>(null);
const slowFrameMs = useRef(0);
@@ -991,7 +993,9 @@ function SceneFrameScheduler({
const dprRef = useRef(dpr);
dprRef.current = dpr;
useEffect(() => {
useLayoutEffect(() => {
if (mode === "suspended") get().internal.frames = 0;
resetSceneClockForMode(clock, mode);
outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange(
previousOutcomePhase.current,
outcomePhase,
@@ -1005,7 +1009,6 @@ function SceneFrameScheduler({
}
if (mode === "static") {
manualTimeSeconds.current = 0;
invalidate();
return;
}
@@ -1013,12 +1016,10 @@ function SceneFrameScheduler({
return startSceneFrameLoop({
mode,
initialManualTimeSeconds: manualTimeSeconds.current,
initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
onFrame: (sample) => {
manualTimeSeconds.current = sample.manualTimeSeconds;
if (mode === "active") {
const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds);
simulationAccumulator.current = stepResult.remainderSeconds;
@@ -1047,19 +1048,74 @@ function SceneFrameScheduler({
outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds;
}
advance(sample.manualTimeSeconds, true);
invalidate();
},
onOutcomeComplete,
});
}, [advance, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
}, [clock, get, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);
return null;
}
function BeaconOfLightMarker() {
const marker = useRef<THREE.Group>(null);
const glow = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!marker.current) return;
const pulse = Math.sin(clock.elapsedTime * 4.4);
marker.current.position.y = 2.62 + pulse * 0.06;
marker.current.rotation.y = clock.elapsedTime * 0.7;
if (glow.current) glow.current.opacity = 0.78 + pulse * 0.12;
});
return (
<group ref={marker} position={[0, 2.62, 0]}>
<pointLight color="#ffe989" intensity={3.2} distance={3.2} decay={2} />
<mesh>
<octahedronGeometry args={[0.18, 0]} />
<meshBasicMaterial
ref={glow}
color="#fff4af"
transparent
opacity={0.9}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.29, 0.025, 8, 24]} />
<meshBasicMaterial
color="#ffd45e"
transparent
opacity={0.8}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
<mesh position={[0, -0.38, 0]}>
<coneGeometry args={[0.23, 0.72, 12, 1, true]} />
<meshBasicMaterial
color="#ffe68a"
side={THREE.DoubleSide}
transparent
opacity={0.16}
blending={THREE.AdditiveBlending}
depthWrite={false}
toneMapped={false}
/>
</mesh>
</group>
);
}
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
const group = useRef<THREE.Group>(null);
const animationState = useRef<ActorAnimationState>("idle");
const animationTrigger = useRef(0);
const beaconed = useGameStore((state) => isBeaconOfLightTarget(memberId, state.healerMechanic, state.time));
const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype);
const visualMemberId: MemberId = visualArchetype === "knight"
? "brann"
@@ -1126,6 +1182,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
return (
<group ref={group} rotation={[0, Math.PI, 0]}>
<PartyCharacterModel memberId={memberId} visualMemberId={visualMemberId} animationState={animationState} animationTrigger={animationTrigger} />
{beaconed && <BeaconOfLightMarker />}
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -1144,6 +1201,7 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
const scenePulse = useGameStore((state) => state.scenePulse);
const healerClassId = useGameStore((state) => state.healerClassId);
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
const beaconed = useGameStore((state) => isBeaconOfLightTarget("aelia", state.healerMechanic, state.time));
const rpgEncounterKey = useGameStore((state) => {
const phase = state.rpgRun?.phase;
if (state.runMode !== "rpg-roguelike") return null;
@@ -1163,12 +1221,26 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
const hockeyAimMovement = useRef<PlanarMovement>({ x: 0, z: -1 });
useEffect(() => {
useLayoutEffect(() => {
const start = useGameStore.getState().partyPositions.aelia;
group.current?.position.set(start[0], 0.025, start[1]);
const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE;
const sinYaw = Math.sin(cameraOrbit.current.yaw);
const cosYaw = Math.cos(cameraOrbit.current.yaw);
desiredCameraPosition.set(
start[0] + sinYaw * horizontalDistance,
CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
start[1] + cosYaw * horizontalDistance,
);
camera.position.copy(desiredCameraPosition);
camera.lookAt(
start[0] - sinYaw * CAMERA_LOOK_AHEAD,
CAMERA_FOCUS_HEIGHT,
start[1] - cosYaw * CAMERA_LOOK_AHEAD,
);
keys.current.clear();
broadcastTimer.current = 0;
}, [rpgEncounterKey]);
}, [camera, desiredCameraPosition, rpgEncounterKey]);
useEffect(() => {
if (["periodic-heal", "protective", "cleanse", "group-heal", "field"].includes(scenePulse.kind)) {
@@ -1326,6 +1398,7 @@ function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 })
animationState={animationState}
animationTrigger={animationTrigger}
/>
{beaconed && <BeaconOfLightMarker />}
{selected && (
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.55, 0.66, 32]} />
@@ -2098,15 +2171,15 @@ function BarrierField() {
return (
<group ref={group} visible={false}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[3, 64]} />
<circleGeometry args={[BARRIER_RADIUS, 64]} />
<meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
</mesh>
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[2.92, 3.05, 64]} />
<ringGeometry args={[BARRIER_RADIUS - 0.08, BARRIER_RADIUS + 0.05, 64]} />
<meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
</mesh>
<mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.82, 1.9, 48]} />
<ringGeometry args={[BARRIER_RADIUS * 0.607, BARRIER_RADIUS * 0.633, 48]} />
<meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
</mesh>
{Array.from({ length: 8 }, (_, index) => {
@@ -2114,7 +2187,7 @@ function BarrierField() {
return (
<mesh
key={index}
position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
position={[Math.sin(angle) * BARRIER_RADIUS * 0.783, 0.02, Math.cos(angle) * BARRIER_RADIUS * 0.783]}
rotation={[-Math.PI / 2, 0, angle]}
>
<ringGeometry args={[0.09, 0.16, 6]} />
@@ -2782,7 +2855,7 @@ export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAp
return (
<Canvas
frameloop={renderMode === "static" ? "demand" : "never"}
frameloop={sceneCanvasFrameloop(renderMode)}
shadows="basic"
dpr={dpr}
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}