new help section explaining classes and their mechanics
This commit is contained in:
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user