Release v0.1.4 2026-07-12
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const ROOM_CENTER_Z = ARENA_CENTER[1];
|
||||
const KAYKIT_DUNGEON_PILLAR_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/pillar-decorated.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_WALL_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/wall-pillar.glb", import.meta.url).href;
|
||||
const KAYKIT_DUNGEON_TORCH_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/torch-lit.glb", import.meta.url).href;
|
||||
|
||||
type ArenaFixture = {
|
||||
position: readonly [number, number, number];
|
||||
rotationY: number;
|
||||
scale: number;
|
||||
scaleX?: number;
|
||||
scaleZ?: number;
|
||||
scaleY?: number;
|
||||
};
|
||||
|
||||
const ARENA_WALL_SEGMENTS: readonly ArenaFixture[] = Array.from({ length: 16 }, (_, index) => {
|
||||
const angle = (index / 16) * Math.PI * 2;
|
||||
return {
|
||||
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 0, ROOM_CENTER_Z + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
|
||||
rotationY: angle,
|
||||
scale: 1,
|
||||
scaleX: ARENA_SIZE_MULTIPLIER,
|
||||
};
|
||||
});
|
||||
const ARENA_COLUMNS: readonly ArenaFixture[] = Array.from({ length: 8 }, (_, index) => {
|
||||
const angle = (index / 8) * Math.PI * 2 + Math.PI / 8;
|
||||
return {
|
||||
position: [Math.sin(angle) * 8.2 * ARENA_SIZE_MULTIPLIER, 0, ROOM_CENTER_Z + Math.cos(angle) * 8.2 * ARENA_SIZE_MULTIPLIER] as const,
|
||||
rotationY: angle + Math.PI / 2,
|
||||
scale: 0.9,
|
||||
};
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [
|
||||
new THREE.Color("#ffb24d"),
|
||||
new THREE.Color("#ff7048"),
|
||||
new THREE.Color("#f7dd88"),
|
||||
new THREE.Color("#72d6ce"),
|
||||
] as const;
|
||||
const ARENA_TORCHES: readonly ArenaFixture[] = ARENA_COLUMNS.map((column) => ({
|
||||
position: [column.position[0], 3.45, column.position[2]] as const,
|
||||
rotationY: column.rotationY,
|
||||
scale: 0.9,
|
||||
}));
|
||||
const SCENERY_SLOTS = Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2 + Math.PI / 10;
|
||||
return { angle, x: Math.sin(angle) * 8.45 * ARENA_SIZE_MULTIPLIER, z: ROOM_CENTER_Z + Math.cos(angle) * 8.45 * ARENA_SIZE_MULTIPLIER };
|
||||
});
|
||||
|
||||
type MarkPattern = "rays" | "rings" | "cross" | "pools";
|
||||
|
||||
const ROOM_PATTERNS: Record<BossRoomFloor, MarkPattern> = {
|
||||
cinder: "rays",
|
||||
desert: "rings",
|
||||
tide: "pools",
|
||||
reliquary: "rings",
|
||||
royal: "cross",
|
||||
prism: "rays",
|
||||
storm: "rays",
|
||||
wilds: "rings",
|
||||
void: "rays",
|
||||
mire: "pools",
|
||||
quarry: "rays",
|
||||
junkyard: "cross",
|
||||
web: "rings",
|
||||
moon: "rings",
|
||||
frost: "rays",
|
||||
warcamp: "cross",
|
||||
sky: "pools",
|
||||
};
|
||||
|
||||
type SceneryKind = "crystal" | "obelisk" | "coral" | "grave" | "crown" | "shard" | "lightning" | "tree" | "rift" | "reed" | "boulder" | "scrap" | "web" | "moonstone" | "ice" | "banner" | "cloud";
|
||||
|
||||
const ROOM_SCENERY: Record<BossRoomFloor, SceneryKind> = {
|
||||
cinder: "crystal",
|
||||
desert: "obelisk",
|
||||
tide: "coral",
|
||||
reliquary: "grave",
|
||||
royal: "crown",
|
||||
prism: "shard",
|
||||
storm: "lightning",
|
||||
wilds: "tree",
|
||||
void: "rift",
|
||||
mire: "reed",
|
||||
quarry: "boulder",
|
||||
junkyard: "scrap",
|
||||
web: "web",
|
||||
moon: "moonstone",
|
||||
frost: "ice",
|
||||
warcamp: "banner",
|
||||
sky: "cloud",
|
||||
};
|
||||
|
||||
function firstMesh(scene: THREE.Object3D) {
|
||||
let mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]> | undefined;
|
||||
scene.traverse((child) => {
|
||||
if (!mesh && child instanceof THREE.Mesh) mesh = child;
|
||||
});
|
||||
if (!mesh) throw new Error("Dungeon arena asset has no mesh.");
|
||||
return mesh as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||||
}
|
||||
|
||||
function DungeonAssetInstances({
|
||||
url,
|
||||
fixtures,
|
||||
tint,
|
||||
opacity = 1,
|
||||
colors,
|
||||
}: {
|
||||
url: string;
|
||||
fixtures: readonly ArenaFixture[];
|
||||
tint: string;
|
||||
opacity?: number;
|
||||
colors?: readonly THREE.Color[];
|
||||
}) {
|
||||
const gltf = useGLTF(url, false, true);
|
||||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||||
const sourceMaterial = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
const material = useMemo(() => {
|
||||
const next = sourceMaterial.clone();
|
||||
if ("color" in next) (next as THREE.MeshStandardMaterial).color.set(tint);
|
||||
next.transparent = opacity < 1;
|
||||
next.opacity = opacity;
|
||||
next.depthWrite = opacity >= 1;
|
||||
return next;
|
||||
}, [opacity, sourceMaterial, tint]);
|
||||
const instances = useRef<THREE.InstancedMesh>(null);
|
||||
|
||||
useEffect(() => () => material.dispose(), [material]);
|
||||
useLayoutEffect(() => {
|
||||
if (!instances.current) return;
|
||||
const dummy = new THREE.Object3D();
|
||||
fixtures.forEach((fixture, index) => {
|
||||
dummy.position.fromArray(fixture.position);
|
||||
dummy.rotation.set(0, fixture.rotationY, 0);
|
||||
dummy.scale.set(
|
||||
fixture.scale * (fixture.scaleX ?? 1),
|
||||
fixture.scale * (fixture.scaleY ?? 1),
|
||||
fixture.scale * (fixture.scaleZ ?? 1),
|
||||
);
|
||||
dummy.updateMatrix();
|
||||
instances.current!.setMatrixAt(index, dummy.matrix);
|
||||
if (colors) instances.current!.setColorAt(index, colors[index % colors.length]);
|
||||
});
|
||||
instances.current.instanceMatrix.needsUpdate = true;
|
||||
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
|
||||
instances.current.computeBoundingSphere();
|
||||
}, [colors, fixtures]);
|
||||
|
||||
return <instancedMesh ref={instances} args={[mesh.geometry, material, fixtures.length]} castShadow receiveShadow />;
|
||||
}
|
||||
|
||||
function ArenaArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
...fixture,
|
||||
scaleY: room.wallHeight / 4,
|
||||
})), [room.wallHeight]);
|
||||
return (
|
||||
<group>
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||||
<DungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
if (!walls.current) return;
|
||||
const previous = previousCameraPosition.current;
|
||||
if (previous && previous.distanceToSquared(camera.position) < 0.0001) return;
|
||||
const cameraPosition = previous ?? new THREE.Vector3();
|
||||
cameraPosition.copy(camera.position);
|
||||
previousCameraPosition.current = cameraPosition;
|
||||
for (const child of walls.current.children) {
|
||||
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
|
||||
const distance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
|
||||
material.opacity = THREE.MathUtils.smoothstep(distance, 2.5, 7.5) * 0.48 + 0.05;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
|
||||
rotation={[0, fixture.rotationY, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[3.86 * ARENA_SIZE_MULTIPLIER, room.wallHeight, 0.22]} />
|
||||
<meshStandardMaterial color={room.wallColor} roughness={0.9} transparent opacity={0.53} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<ArenaArchitecture room={room} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||
const rays = useRef<THREE.InstancedMesh>(null);
|
||||
const pattern = ROOM_PATTERNS[room.floor];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!rays.current || pattern === "rings" || pattern === "pools") return;
|
||||
const dummy = new THREE.Object3D();
|
||||
const count = pattern === "cross" ? 4 : 8;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const angle = (index / count) * Math.PI * 2 + (pattern === "cross" ? Math.PI / 4 : 0);
|
||||
const radius = (pattern === "cross" ? 3 : 4.3) * ARENA_SIZE_MULTIPLIER;
|
||||
dummy.position.set(Math.sin(angle) * radius, 0.025, ROOM_CENTER_Z + Math.cos(angle) * radius);
|
||||
dummy.rotation.set(0, -angle, 0);
|
||||
dummy.scale.set(
|
||||
(pattern === "cross" ? 1.6 : 0.75) * ARENA_SIZE_MULTIPLIER,
|
||||
1,
|
||||
(pattern === "cross" ? 0.48 : 2.6) * ARENA_SIZE_MULTIPLIER,
|
||||
);
|
||||
dummy.updateMatrix();
|
||||
rays.current.setMatrixAt(index, dummy.matrix);
|
||||
}
|
||||
rays.current.instanceMatrix.needsUpdate = true;
|
||||
}, [pattern]);
|
||||
|
||||
if (pattern === "rings") {
|
||||
return (
|
||||
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.022, ROOM_CENTER_Z]}>
|
||||
<mesh><ringGeometry args={[2.4 * ARENA_SIZE_MULTIPLIER, 2.5 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.32} /></mesh>
|
||||
<mesh><ringGeometry args={[5.2 * ARENA_SIZE_MULTIPLIER, 5.3 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accentSecondary} transparent opacity={0.22} /></mesh>
|
||||
<mesh><ringGeometry args={[7.5 * ARENA_SIZE_MULTIPLIER, 7.58 * ARENA_SIZE_MULTIPLIER, 48]} /><meshBasicMaterial color={room.accent} transparent opacity={0.16} /></mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
if (pattern === "pools") {
|
||||
return (
|
||||
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.024, ROOM_CENTER_Z]}>
|
||||
{[[0, 0, 2.5], [4, -2.4, 1.5], [-4.4, 2.8, 1.25]].map(([x, z, radius], index) => (
|
||||
<mesh key={index} position={[x * ARENA_SIZE_MULTIPLIER, z * ARENA_SIZE_MULTIPLIER, 0]}>
|
||||
<circleGeometry args={[radius * ARENA_SIZE_MULTIPLIER, 32]} />
|
||||
<meshBasicMaterial color={index === 0 ? room.accent : room.accentSecondary} transparent opacity={0.2} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<instancedMesh ref={rays} args={[undefined, undefined, 8]}>
|
||||
<boxGeometry args={[0.15, 0.025, 1]} />
|
||||
<meshBasicMaterial color={room.accent} transparent opacity={0.3} depthWrite={false} />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomScenery({ room }: { room: BossRoomDefinition }) {
|
||||
const instances = useRef<THREE.InstancedMesh>(null);
|
||||
const kind = ROOM_SCENERY[room.floor];
|
||||
const height = kind === "tree" ? 2.1 : kind === "obelisk" || kind === "banner" ? 1.5 : kind === "ice" || kind === "lightning" ? 1.2 : 0.7;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!instances.current) return;
|
||||
const dummy = new THREE.Object3D();
|
||||
SCENERY_SLOTS.forEach(({ angle, x, z }, index) => {
|
||||
const scale = 0.78 + (index % 3) * 0.12;
|
||||
dummy.position.set(x, height * scale, z);
|
||||
dummy.rotation.set(0, -angle + (index % 2 ? 0.35 : 0), 0);
|
||||
dummy.scale.setScalar(scale);
|
||||
dummy.updateMatrix();
|
||||
instances.current!.setMatrixAt(index, dummy.matrix);
|
||||
instances.current!.setColorAt(index, new THREE.Color(index % 2 ? room.accent : room.accentSecondary));
|
||||
});
|
||||
instances.current.instanceMatrix.needsUpdate = true;
|
||||
if (instances.current.instanceColor) instances.current.instanceColor.needsUpdate = true;
|
||||
}, [height, kind, room.accent, room.accentSecondary]);
|
||||
|
||||
return (
|
||||
<instancedMesh ref={instances} args={[undefined, undefined, SCENERY_SLOTS.length]} castShadow receiveShadow>
|
||||
{kind === "crystal" || kind === "shard" || kind === "ice" || kind === "lightning" ? <coneGeometry args={[0.46, height * 2, kind === "ice" ? 4 : 5]} />
|
||||
: kind === "obelisk" || kind === "grave" || kind === "moonstone" ? <boxGeometry args={[0.62, height * 2, 0.38]} />
|
||||
: kind === "coral" || kind === "reed" ? <cylinderGeometry args={[0.12, 0.34, height * 2, 5]} />
|
||||
: kind === "crown" ? <cylinderGeometry args={[0.52, 0.68, height * 2, 6]} />
|
||||
: kind === "tree" ? <coneGeometry args={[0.8, height * 2, 7]} />
|
||||
: kind === "rift" ? <tetrahedronGeometry args={[height]} />
|
||||
: kind === "boulder" || kind === "cloud" ? <dodecahedronGeometry args={[height, 0]} />
|
||||
: kind === "scrap" ? <boxGeometry args={[0.94, height, 0.48]} />
|
||||
: kind === "web" ? <icosahedronGeometry args={[height, 1]} />
|
||||
: <boxGeometry args={[0.16, height * 2, 0.95]} />}
|
||||
<meshStandardMaterial vertexColors color={room.accent} emissive={room.accent} emissiveIntensity={kind === "web" || kind === "rift" ? 0.24 : 0.08} roughness={0.68} />
|
||||
</instancedMesh>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
|
||||
<cylinderGeometry args={[10.5 * ARENA_SIZE_MULTIPLIER, 11.2 * ARENA_SIZE_MULTIPLIER, 0.8, 48]} />
|
||||
<meshStandardMaterial color={room.ground} roughness={0.94} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.02, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<circleGeometry args={[9.35 * ARENA_SIZE_MULTIPLIER, 48]} />
|
||||
<meshStandardMaterial color={room.floorColor} roughness={0.86} metalness={room.floor === "royal" || room.floor === "junkyard" ? 0.34 : 0.04} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.008, ROOM_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[8.62 * ARENA_SIZE_MULTIPLIER, 8.82 * ARENA_SIZE_MULTIPLIER, 48]} />
|
||||
<meshBasicMaterial color={room.accentSecondary} transparent opacity={0.32} />
|
||||
</mesh>
|
||||
<RoomMarks room={room} />
|
||||
<RoomScenery room={room} />
|
||||
<RoomWalls room={room} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Main-display room projection. Gameplay stays in the shared arena domain. */
|
||||
export function BossRoom() {
|
||||
const bossId = useGameStore((state) => state.boss.id);
|
||||
const room = bossRoomFor(bossId);
|
||||
return (
|
||||
<group key={room.id}>
|
||||
<color attach="background" args={[room.background]} />
|
||||
<fog attach="fog" args={[room.fog, 15 * ARENA_SIZE_MULTIPLIER, 32 * ARENA_SIZE_MULTIPLIER]} />
|
||||
<hemisphereLight args={[room.sky, room.ground, 1.18]} />
|
||||
<directionalLight castShadow position={[5, 10, 8]} intensity={2.1} color={room.accentSecondary} shadow-mapSize={[512, 512]} />
|
||||
<pointLight color={room.accent} intensity={2.35} distance={8} position={[-6, 2.8, ROOM_CENTER_Z]} />
|
||||
<pointLight color={room.accentSecondary} intensity={1.75} distance={7} position={[6, 2.4, ROOM_CENTER_Z - 1]} />
|
||||
<RoomFloor room={room} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||||
@@ -9,7 +9,7 @@ import { useFrontendStore } from "../frontend/store";
|
||||
function RewardSummary() {
|
||||
const rewards = useFrontendStore((state) => state.recentRewards);
|
||||
if (!rewards.length) return null;
|
||||
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.coin.id}-${index}`}><b>{reward.coin.glyph}</b>{reward.coin.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
|
||||
return <div className="reward-summary" aria-label="Boss rewards">{rewards.map((reward, index) => <span key={`${reward.drop.id}-${index}`}><b>{reward.drop.glyph}</b>{reward.drop.name} ×{reward.quantity}{reward.pet ? <i> + {reward.pet.name}</i> : null}</span>)}</div>;
|
||||
}
|
||||
|
||||
function HealthBar({ member }: { member: PartyMember }) {
|
||||
|
||||
+53
-34
@@ -5,7 +5,7 @@ import { useActiveHunter, useFrontendStore } from "../frontend/store";
|
||||
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { HEALER_CLASSES, HEALER_CLASS_ORDER } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUP_BY_ID } from "../game/bossCatalog";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import type { BossId } from "../game/types";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
gearBonusText,
|
||||
gearUpgradeCosts,
|
||||
} from "../game/progression/gear";
|
||||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossCoinDrop } from "../game/progression/loot";
|
||||
import { DIFFICULTIES, DIFFICULTY_BY_SLUG, bossGroupDrop } from "../game/progression/loot";
|
||||
import {
|
||||
ACTIVE_INFUSION_MIN_GEAR_LEVEL,
|
||||
PASSIVE_INFUSIONS,
|
||||
@@ -388,7 +388,7 @@ function HomeScreen() {
|
||||
</div>
|
||||
<div className="home-secondary-actions">
|
||||
<FocusButton id="profile" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("profile")}><i>♙</i><span><strong>Hunter Profile</strong><small>Stats & collection log</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend boss coins</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="gear" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("gear")}><i>⚒</i><span><strong>Gear Upgrade</strong><small>Spend group drops</small></span><b>›</b></FocusButton>
|
||||
<FocusButton id="settings" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("settings")}><i>⚙</i><span><strong>Settings</strong><small>Audio, display, controls</small></span><b>›</b></FocusButton>
|
||||
</div>
|
||||
<ControllerLegend back />
|
||||
@@ -424,10 +424,10 @@ function ProfileScreen() {
|
||||
const hunter = useActiveHunter();
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const collections = useMemo(() => hunter ? buildCollections(hunter.collectionLog, hunter.stats.bossKills) : [], [hunter]);
|
||||
const [bossId, setBossId] = useState(collections[0]?.bossId ?? "");
|
||||
const collection = collections.find((boss) => boss.bossId === bossId) ?? collections[0];
|
||||
const [groupId, setGroupId] = useState(collections[0]?.groupId ?? "");
|
||||
const collection = collections.find((group) => group.groupId === groupId) ?? collections[0];
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...collections.map((boss) => ({ id: boss.bossId, run: () => setBossId(boss.bossId) })),
|
||||
...collections.map((group) => ({ id: `group-${group.groupId}`, run: () => setGroupId(group.groupId) })),
|
||||
{ id: "back", run: () => navigate("home") },
|
||||
], [collections, navigate]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
@@ -441,22 +441,22 @@ function ProfileScreen() {
|
||||
top={
|
||||
<FrontSurface className="profile-surface" ariaLabel="Hunter profile collection log">
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Hunter profile</span><h1>Collection log</h1></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<div className="collection-heading"><span><small>Boss spoils</small><h2>{collection.bossName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
|
||||
<div className="collection-heading"><span><small>Shared group drops · Core: {collection.coreMechanic}</small><h2>Group {collection.groupLetter} · {collection.groupName}</h2></span><b>{earned} / {collection.drops.length} discovered</b></div>
|
||||
<div className="collection-grid">
|
||||
{collection.drops.map((drop) => (
|
||||
<article key={drop.id} className={`collection-drop rarity-${drop.rarity.toLowerCase()} ${drop.count === 0 ? "is-missing" : ""}`}>
|
||||
<span className="drop-icon">{drop.icon}<b>{drop.count}</b></span>
|
||||
<small>{drop.rarity}</small><strong>{drop.name}</strong>
|
||||
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : "Defeat boss to reveal"}</p>
|
||||
<p>{drop.count ? `${drop.count} earned` : collection.defeated ? "Not yet earned" : `Defeat a Group ${collection.groupLetter} boss`}</p>
|
||||
<small>{drop.chance}{drop.itemLevel ? ` · iLvl ${drop.itemLevel}` : ""}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="collection-note"><i>✦</i><span><strong>Every drop stays counted.</strong><small>Duplicates increase quantity instead of disappearing.</small></span></div>
|
||||
<div className="collection-note"><i>✦</i><span><strong>Boss pets stay individual.</strong><small>{collection.bosses.map((boss) => `${boss.bossName}: ${boss.kills} kills · ${boss.pet.count} pets`).join(" · ")}</small></span></div>
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and boss list">
|
||||
<FrontSurface className="profile-context" bottom ariaLabel="Hunter statistics and mechanic group list">
|
||||
<header className="context-header"><span>{hunter.hunterName} · {activeHealer.name} stats</span><b>LEVEL {activeProgress.level}</b></header>
|
||||
<div className="profile-stats">
|
||||
<span><small>Total boss kills</small><strong>{hunter.stats.totalBossKills}</strong></span>
|
||||
@@ -464,9 +464,9 @@ function ProfileScreen() {
|
||||
<span><small>Allies saved</small><strong>{hunter.stats.alliesSaved}</strong></span>
|
||||
<span><small>Healing done</small><strong>{hunter.stats.healingDone.toLocaleString()}</strong></span>
|
||||
</div>
|
||||
<div className="boss-log"><span>Boss records</span>{collections.map((boss) => (
|
||||
<FocusButton key={boss.bossId} id={boss.bossId} focusedId={controller.focusedId} focus={controller.focus} className={boss.bossId === collection.bossId ? "is-selected" : ""} onClick={() => setBossId(boss.bossId)}>
|
||||
<i>{boss.defeated ? "♜" : "?"}</i><span><strong>{boss.bossName}</strong><small>{hunter.stats.bossKills[boss.bossId] ?? 0} kills</small></span><b>{boss.drops.filter((drop) => drop.count > 0).length}/{boss.drops.length}</b>
|
||||
<div className="boss-log"><span>Mechanic groups</span>{collections.map((group) => (
|
||||
<FocusButton key={group.groupId} id={`group-${group.groupId}`} focusedId={controller.focusedId} focus={controller.focus} className={group.groupId === collection.groupId ? "is-selected" : ""} onClick={() => setGroupId(group.groupId)}>
|
||||
<i>{group.defeated ? group.groupLetter : "?"}</i><span><strong>Group {group.groupLetter} · {group.groupName}</strong><small>{group.bosses.reduce((sum, boss) => sum + boss.kills, 0)} kills · {group.coreMechanic}</small></span><b>{group.drops.filter((drop) => drop.count > 0).length}/{group.drops.length}</b>
|
||||
</FocusButton>
|
||||
))}</div>
|
||||
</FrontSurface>
|
||||
@@ -558,7 +558,7 @@ function GearScreen() {
|
||||
<DualDisplayFrame
|
||||
top={
|
||||
<FrontSurface className="gear-surface" ariaLabel="Gear upgrade workshop">
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Boss coin workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<header className="front-screen-header"><BrandMark compact /><div><span>Group drop workshop</span><h1>Gear & Infusions</h1></div><div className="gear-mode-tabs"><FocusButton id="workshop-upgrade" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "upgrade" ? "is-selected" : ""} onClick={() => selectWorkshopMode("upgrade")}>Upgrade</FocusButton><FocusButton id="workshop-infusion" focusedId={controller.focusedId} focus={controller.focus} className={workshopMode === "infusion" ? "is-selected" : ""} onClick={() => selectWorkshopMode("infusion")}>Infusion</FocusButton></div><FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} className="header-back" onClick={() => navigate("home")}>B · Back</FocusButton></header>
|
||||
<div className="gear-workshop-layout">
|
||||
<section className="gear-owner-list" aria-label="Party gear owners">
|
||||
{GEAR_OWNER_ORDER.map((ownerId) => {
|
||||
@@ -576,7 +576,7 @@ function GearScreen() {
|
||||
{workshopMode === "upgrade" ? <article className="gear-preview">
|
||||
<span>Selected upgrade</span>
|
||||
<h2>{GEAR_OWNER_LABELS[selectedOwnerId]} · {GEAR_SLOT_LABELS[selectedSlotId]} +{slot.level}</h2>
|
||||
<p>{GEAR_STAT_LABELS[recipe.statId]} from {BOSS_DEFINITIONS[recipe.primaryBossId].name} and {BOSS_DEFINITIONS[recipe.secondaryBossId].name} coins.</p>
|
||||
<p>{GEAR_STAT_LABELS[recipe.statId]} from Group {BOSS_GROUP_BY_ID[recipe.primaryGroupId].letter} and Group {BOSS_GROUP_BY_ID[recipe.secondaryGroupId].letter} drops.</p>
|
||||
<div className="gear-stat-comparison"><span><small>Current</small><strong>{currentBonus}</strong></span><i>→</i><span><small>{slot.level >= MAX_GEAR_LEVEL ? "Maximum" : `Rank +${slot.level + 1}`}</small><strong>{nextBonus}</strong></span></div>
|
||||
</article> : <article className="gear-preview gear-infusion-preview">
|
||||
<span>Active infusion · unlock +{ACTIVE_INFUSION_MIN_GEAR_LEVEL}</span>
|
||||
@@ -593,7 +593,7 @@ function GearScreen() {
|
||||
}
|
||||
bottom={
|
||||
<FrontSurface className="gear-context" bottom ariaLabel="Gear recipe and material inventory">
|
||||
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} COINS</b></header>
|
||||
<header className="context-header"><span>{workshopMode === "upgrade" ? `${GEAR_SLOT_LABELS[selectedSlotId]} recipe` : `${selectedInfusion.name} infusion`}</span><b>{hunter.materials.reduce((sum, item) => sum + item.quantity, 0)} DROPS</b></header>
|
||||
<div className="gear-costs">
|
||||
<span>{workshopMode === "upgrade" ? "Upgrade requirements" : `Infusion requirements · ${GEAR_SLOT_LABELS[selectedSlotId]} +${slot.level} anchor`}</span>
|
||||
{(workshopMode === "upgrade" ? costs : selectedInfusionCosts).length ? (workshopMode === "upgrade" ? costs : selectedInfusionCosts).map((cost) => {
|
||||
@@ -601,7 +601,7 @@ function GearScreen() {
|
||||
return <article className={owned >= cost.quantity ? "is-met" : "is-missing"} key={cost.itemId}><i>{owned >= cost.quantity ? "✓" : "×"}</i><span><strong>{cost.itemName}</strong><small>{owned} owned · {cost.quantity} needed</small></span><b>{owned}/{cost.quantity}</b></article>;
|
||||
}) : <article className="is-met"><i>✓</i><span><strong>Maximum rank reached</strong><small>No more materials required.</small></span><b>+{MAX_GEAR_LEVEL}</b></article>}
|
||||
</div>
|
||||
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend coins · autosave" : "Collect required boss coins"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend coins · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required boss coins"}</small></FocusButton>}
|
||||
{workshopMode === "upgrade" ? <FocusButton id="upgrade" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canUpgrade} onClick={upgrade}><span>{slot.level >= MAX_GEAR_LEVEL ? "Maximum rank" : `Upgrade to +${slot.level + 1}`}</span><small>{canUpgrade ? "Spend group drops · autosave" : "Collect required group drops"}</small></FocusButton> : <FocusButton id="install-infusion" focusedId={controller.focusedId} focus={controller.focus} className="gear-upgrade-action" disabled={!canInstallInfusion} onClick={installInfusion}><span>{infusionEquipped ? "Infusion equipped" : `Install ${selectedInfusion.name}`}</span><small>{!activeUnlocked ? `Raise any ${GEAR_OWNER_LABELS[selectedOwnerId]} slot to +${ACTIVE_INFUSION_MIN_GEAR_LEVEL}` : !anchorUnlocked ? `Select a +${ACTIVE_INFUSION_MIN_GEAR_LEVEL} anchor slot` : canInstallInfusion ? "Spend group drops · autosave" : infusionEquipped ? "Applies next encounter" : "Collect required group drops"}</small></FocusButton>}
|
||||
<div className="front-notice is-lower">{notice || "Gear changes save locally and apply when next encounter starts."}</div>
|
||||
</FrontSurface>
|
||||
}
|
||||
@@ -664,6 +664,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
const selectDifficulty = useFrontendStore((state) => state.selectDifficulty);
|
||||
const navigate = useFrontendStore((state) => state.navigate);
|
||||
const [message, setMessage] = useState("");
|
||||
const bossPageSize = 12;
|
||||
const [bossPage, setBossPage] = useState(() => Math.max(0, Math.floor(AVAILABLE_BOSS_IDS.indexOf(selectedBossId) / bossPageSize)));
|
||||
const mode = MODE_COPY[modeId];
|
||||
const healer = hunter ? HEALER_CLASSES[hunter.activeClassId] : HEALER_CLASSES.priest;
|
||||
const progress = hunter?.healers[hunter.activeClassId];
|
||||
@@ -671,36 +673,46 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
const selectedDifficulty = DIFFICULTY_BY_SLUG[selectedDifficultySlug];
|
||||
const isPve = modeId === "roguelike-pve";
|
||||
const isDungeon = modeId === "dungeons";
|
||||
const bossGridRows = Math.min(8, Math.ceil(BOSS_ORDER.length / 3));
|
||||
const bossPageCount = Math.ceil(AVAILABLE_BOSS_IDS.length / bossPageSize);
|
||||
const visibleBossIds = AVAILABLE_BOSS_IDS.slice(bossPage * bossPageSize, (bossPage + 1) * bossPageSize);
|
||||
const bossGridRows = Math.ceil(visibleBossIds.length / 3);
|
||||
const bossGridColumns = Math.ceil(visibleBossIds.length / bossGridRows);
|
||||
const changeBossPage = (nextPage: number) => {
|
||||
const page = Math.max(0, Math.min(bossPageCount - 1, nextPage));
|
||||
setBossPage(page);
|
||||
selectBoss(AVAILABLE_BOSS_IDS[page * bossPageSize]);
|
||||
};
|
||||
const launch = () => {
|
||||
if (isPve) return onLaunch(selectRandomBossPair(), "initiate");
|
||||
if (isDungeon) return onLaunch([selectedBossId], selectedDifficultySlug);
|
||||
setMessage("Online matchmaking connects here when game server is configured.");
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
...(isDungeon ? BOSS_ORDER.map((bossId, index) => {
|
||||
...(isDungeon ? visibleBossIds.map((bossId, index) => {
|
||||
const column = Math.floor(index / bossGridRows);
|
||||
const row = index % bossGridRows;
|
||||
const neighborInColumn = (targetColumn: number) => {
|
||||
const columnStart = targetColumn * bossGridRows;
|
||||
if (columnStart >= BOSS_ORDER.length || targetColumn < 0) return undefined;
|
||||
const columnEnd = Math.min(columnStart + bossGridRows, BOSS_ORDER.length) - 1;
|
||||
return `boss-${BOSS_ORDER[Math.min(columnStart + row, columnEnd)]}`;
|
||||
if (columnStart >= visibleBossIds.length || targetColumn < 0) return undefined;
|
||||
const columnEnd = Math.min(columnStart + bossGridRows, visibleBossIds.length) - 1;
|
||||
return `boss-${visibleBossIds[Math.min(columnStart + row, columnEnd)]}`;
|
||||
};
|
||||
|
||||
return {
|
||||
id: `boss-${bossId}`,
|
||||
run: () => selectBoss(bossId),
|
||||
neighbors: {
|
||||
up: row > 0 ? `boss-${BOSS_ORDER[index - 1]}` : "back",
|
||||
down: index + 1 < Math.min((column + 1) * bossGridRows, BOSS_ORDER.length)
|
||||
? `boss-${BOSS_ORDER[index + 1]}`
|
||||
up: row > 0 ? `boss-${visibleBossIds[index - 1]}` : "back",
|
||||
down: index + 1 < Math.min((column + 1) * bossGridRows, visibleBossIds.length)
|
||||
? `boss-${visibleBossIds[index + 1]}`
|
||||
: `difficulty-${DIFFICULTIES[0].slug}`,
|
||||
left: neighborInColumn(column - 1),
|
||||
right: neighborInColumn(column + 1),
|
||||
left: neighborInColumn(column - 1) ?? (bossPage > 0 ? "boss-page-prev" : undefined),
|
||||
right: neighborInColumn(column + 1) ?? (bossPage < bossPageCount - 1 ? "boss-page-next" : undefined),
|
||||
},
|
||||
};
|
||||
}) : []),
|
||||
...(isDungeon && bossPage > 0 ? [{ id: "boss-page-prev", run: () => changeBossPage(bossPage - 1), neighbors: { right: `boss-${visibleBossIds[0]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
|
||||
...(isDungeon && bossPage < bossPageCount - 1 ? [{ id: "boss-page-next", run: () => changeBossPage(bossPage + 1), neighbors: { left: `boss-${visibleBossIds[visibleBossIds.length - 1]}`, down: `boss-${visibleBossIds[0]}`, up: "back" } }] : []),
|
||||
...(isDungeon ? DIFFICULTIES.map((difficulty, index) => ({
|
||||
id: `difficulty-${difficulty.slug}`,
|
||||
run: () => selectDifficulty(difficulty.slug),
|
||||
@@ -712,8 +724,8 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
},
|
||||
})) : []),
|
||||
{ id: "launch", run: launch, neighbors: isDungeon ? { up: `difficulty-${DIFFICULTIES[DIFFICULTIES.length - 1].slug}` } : { up: "back" } },
|
||||
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${BOSS_ORDER[0]}` } : { down: "launch" } },
|
||||
], [isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug]);
|
||||
{ id: "back", run: () => navigate("home"), neighbors: isDungeon ? { down: `boss-${AVAILABLE_BOSS_IDS[0]}` } : { down: "launch" } },
|
||||
], [bossGridRows, bossPage, bossPageCount, isDungeon, isPve, modeId, navigate, onLaunch, selectBoss, selectDifficulty, selectedBossId, selectedDifficultySlug, visibleBossIds]);
|
||||
const controller = useMenuController(actions, { onBack: () => navigate("home") });
|
||||
const launchLabel = isPve ? "Begin randomized run" : isDungeon ? `Challenge ${selectedBoss.name}` : "Enter matchmaking";
|
||||
const contextRules = isDungeon
|
||||
@@ -741,10 +753,17 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
{!isDungeon && <div className="mode-hero"><span>{mode.eyebrow}</span><h2>{mode.title}</h2><p>{mode.description}</p><b>{mode.detail}</b></div>}
|
||||
{isDungeon && (
|
||||
<div className="boss-picker" aria-label="Choose boss encounter">
|
||||
<span>Choose encounter</span>
|
||||
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows } as React.CSSProperties}>
|
||||
{BOSS_ORDER.map((bossId) => {
|
||||
<div className="boss-picker-heading">
|
||||
<span>Choose encounter · Page {bossPage + 1}/{bossPageCount}</span>
|
||||
<div>
|
||||
<FocusButton id="boss-page-prev" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === 0} onClick={() => changeBossPage(bossPage - 1)}>◀ Previous</FocusButton>
|
||||
<FocusButton id="boss-page-next" focusedId={controller.focusedId} focus={controller.focus} disabled={bossPage === bossPageCount - 1} onClick={() => changeBossPage(bossPage + 1)}>Next ▶</FocusButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="boss-choice-grid" style={{ "--boss-grid-rows": bossGridRows, "--boss-grid-columns": bossGridColumns } as React.CSSProperties}>
|
||||
{visibleBossIds.map((bossId) => {
|
||||
const boss = BOSS_DEFINITIONS[bossId];
|
||||
const group = BOSS_GROUP_BY_ID[boss.groupId];
|
||||
return (
|
||||
<FocusButton
|
||||
key={bossId}
|
||||
@@ -756,7 +775,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
aria-pressed={selectedBossId === bossId}
|
||||
onClick={() => selectBoss(bossId)}
|
||||
>
|
||||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>{boss.mechanics.join(" · ")}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||
<i>{boss.icon}</i><span><strong>{boss.name}</strong><small>Group {group.letter} · {group.name} · {boss.mechanics[0]}</small></span><b>{selectedBossId === bossId ? "✓" : ""}</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
@@ -778,7 +797,7 @@ function ModeScreen({ onLaunch }: { onLaunch: (bossIds: readonly BossId[], diffi
|
||||
<header className="context-header"><span>Run preparation</span><b>{isDungeon ? selectedDifficulty.name.toUpperCase() : mode.status.toUpperCase()}</b></header>
|
||||
{contextRules.map(([title, copy], index) => <div className="mode-rule" key={title}><i>0{index + 1}</i><span><strong>{title}</strong><small>{copy}</small></span></div>)}
|
||||
<div className="mode-loadout"><span>Equipped role</span><b>{healer.specialization} · Level {progress?.level ?? 1}</b><small>6 abilities · {progress?.inventory.length ?? 0} class items · Controller ready</small></div>
|
||||
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossCoinDrop(selectedBossId, selectedDifficultySlug).name}</b><small>1–3 coins · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||||
{isDungeon && <div className="mode-loot-preview"><span>Guaranteed reward</span><b>{bossGroupDrop(selectedBossId, selectedDifficultySlug).name}</b><small>1–3 group drops · {selectedDifficulty.rarity} · Pet chance 1 in 500</small></div>}
|
||||
</FrontSurface>
|
||||
}
|
||||
/>
|
||||
|
||||
+300
-253
@@ -1,53 +1,79 @@
|
||||
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations, useGLTF } from "@react-three/drei";
|
||||
import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
|
||||
import { ARENA_CENTER, clampToArena } from "../game/arena";
|
||||
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
|
||||
import {
|
||||
isActorAnimationOneShot,
|
||||
shouldStartActorAnimation,
|
||||
type ActorAnimationState,
|
||||
} from "../game/actorAnimation";
|
||||
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
|
||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||
import { BossRoom } from "./BossRoom";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
|
||||
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
|
||||
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
|
||||
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
|
||||
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
|
||||
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
|
||||
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
|
||||
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
|
||||
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
|
||||
const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
|
||||
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
|
||||
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
|
||||
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
|
||||
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
|
||||
const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
|
||||
| "bulldrome"
|
||||
| "sandglass-scorpion"
|
||||
| "cragclaw-crab"
|
||||
| "mournveil-ghost"
|
||||
| "crownshard-golem"
|
||||
| "crystal-bat-matriarch"
|
||||
>, string> = {
|
||||
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
|
||||
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
|
||||
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
|
||||
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
|
||||
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
|
||||
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
|
||||
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
|
||||
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
|
||||
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
|
||||
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
|
||||
"warcaller-orc": new URL("../assets/game/models/claudecraft/creatures/orc.glb", import.meta.url).href,
|
||||
"tuskmaw-orc": new URL("../assets/game/models/claudecraft/creatures/orcenemy.glb", import.meta.url).href,
|
||||
"broodfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
|
||||
"silkfang-spider": new URL("../assets/game/models/claudecraft/creatures/spider.glb", import.meta.url).href,
|
||||
"thorncrown-stag": new URL("../assets/game/models/claudecraft/creatures/stag.glb", import.meta.url).href,
|
||||
"sky-totem": new URL("../assets/game/models/claudecraft/creatures/tribal.glb", import.meta.url).href,
|
||||
"razorcrest-raptor": new URL("../assets/game/models/claudecraft/creatures/velociraptor.glb", import.meta.url).href,
|
||||
"bristlequake-boar": new URL("../assets/game/models/claudecraft/creatures/wild_boar.glb", import.meta.url).href,
|
||||
"moonfang-wolf": new URL("../assets/game/models/claudecraft/creatures/wolf.glb", import.meta.url).href,
|
||||
"frostmaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yeti.glb", import.meta.url).href,
|
||||
"rimeclaw-yeti": new URL("../assets/game/models/claudecraft/creatures/yetialt.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_MODEL_URLS: Record<MemberId, string> = {
|
||||
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
nia: new URL("../../game_assets/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
|
||||
orin: new URL("../../game_assets/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
|
||||
vale: new URL("../../game_assets/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
|
||||
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
|
||||
brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
|
||||
nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
|
||||
orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
|
||||
vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
|
||||
};
|
||||
const PARTY_WEAPON_URLS: Record<MemberId, { right: string; left?: string }> = {
|
||||
aelia: { right: new URL("../../game_assets/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||
aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
|
||||
brann: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
|
||||
},
|
||||
nia: { right: new URL("../../game_assets/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
|
||||
nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
|
||||
orin: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
|
||||
},
|
||||
vale: {
|
||||
right: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
left: new URL("../../game_assets/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
|
||||
},
|
||||
};
|
||||
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
|
||||
@@ -58,19 +84,8 @@ const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
|
||||
orin: "Spellcast_Shoot",
|
||||
vale: "Dualwield_Melee_Attack_Chop",
|
||||
};
|
||||
const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
|
||||
const angle = (index / 10) * Math.PI * 2;
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
|
||||
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
|
||||
const angle = (index / 16) * Math.PI * 2;
|
||||
return {
|
||||
angle,
|
||||
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
|
||||
};
|
||||
});
|
||||
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
|
||||
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
|
||||
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -244,77 +259,89 @@ function PartyCharacterModel({
|
||||
);
|
||||
}
|
||||
|
||||
function Arena() {
|
||||
const pillarInstances = useRef<THREE.InstancedMesh>(null);
|
||||
const torchInstances = useRef<THREE.InstancedMesh>(null);
|
||||
const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
|
||||
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
|
||||
const FRAME_INTERVAL_JITTER_MS = 1.5;
|
||||
const SIMULATION_STEP_SECONDS = 0.1;
|
||||
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
|
||||
const MIN_RENDER_DPR = 1;
|
||||
const MAX_RENDER_DPR = 1.25;
|
||||
|
||||
/**
|
||||
* Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
|
||||
* in the store and is advanced at its existing fixed 10 Hz cadence.
|
||||
*/
|
||||
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
|
||||
const { advance } = useThree();
|
||||
const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
|
||||
const frameId = useRef<number | null>(null);
|
||||
const lastRenderedAt = useRef<number | null>(null);
|
||||
const simulationAccumulator = useRef(0);
|
||||
const slowFrameMs = useRef(0);
|
||||
const stableFrameMs = useRef(0);
|
||||
const dprRef = useRef(dpr);
|
||||
dprRef.current = dpr;
|
||||
|
||||
useEffect(() => {
|
||||
const pillars = pillarInstances.current;
|
||||
const torches = torchInstances.current;
|
||||
if (!pillars || !torches) return;
|
||||
const matrix = new THREE.Matrix4();
|
||||
ARENA_COLUMNS.forEach(([x, z], index) => {
|
||||
matrix.makeTranslation(x, 1.1, z - 1);
|
||||
pillars.setMatrixAt(index, matrix);
|
||||
matrix.makeTranslation(x, 2.6, z - 1);
|
||||
torches.setMatrixAt(index, matrix);
|
||||
torches.setColorAt(index, ARENA_TORCH_COLORS[index % ARENA_TORCH_COLORS.length]);
|
||||
});
|
||||
pillars.instanceMatrix.needsUpdate = true;
|
||||
torches.instanceMatrix.needsUpdate = true;
|
||||
if (torches.instanceColor) torches.instanceColor.needsUpdate = true;
|
||||
}, []);
|
||||
const schedule = (now: number) => {
|
||||
const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
|
||||
const previous = lastRenderedAt.current;
|
||||
if (previous === null) {
|
||||
lastRenderedAt.current = now;
|
||||
advance(now / 1000, true);
|
||||
} else {
|
||||
const elapsedMs = now - previous;
|
||||
if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
|
||||
lastRenderedAt.current = now;
|
||||
const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, -0.45, -1]} receiveShadow>
|
||||
<cylinderGeometry args={[10.5, 11.2, 0.8, 48]} />
|
||||
<meshStandardMaterial color="#182420" roughness={0.92} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.015, -1]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<ringGeometry args={[4.8, 5.05, 64]} />
|
||||
<meshBasicMaterial color="#765c32" transparent opacity={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.01, -1]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[2.1, 48]} />
|
||||
<meshStandardMaterial color="#27342d" roughness={1} />
|
||||
</mesh>
|
||||
<instancedMesh ref={pillarInstances} args={[undefined, undefined, ARENA_COLUMNS.length]} castShadow receiveShadow>
|
||||
<cylinderGeometry args={[0.38, 0.5, 2.4, 6]} />
|
||||
<meshStandardMaterial color="#26342f" roughness={0.8} />
|
||||
</instancedMesh>
|
||||
<instancedMesh ref={torchInstances} args={[undefined, undefined, ARENA_COLUMNS.length]}>
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<ArenaWalls />
|
||||
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
|
||||
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
if (gameplayActive) {
|
||||
simulationAccumulator.current += elapsedSeconds;
|
||||
let steps = 0;
|
||||
while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
|
||||
const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
|
||||
useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
|
||||
if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
|
||||
simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
|
||||
steps += 1;
|
||||
}
|
||||
|
||||
function ArenaWalls() {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
useFrame(({ camera }) => {
|
||||
if (!walls.current) return;
|
||||
for (const child of walls.current.children) {
|
||||
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
|
||||
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
|
||||
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
|
||||
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
|
||||
<boxGeometry args={[3.86, 2.3, 0.18]} />
|
||||
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
if (elapsedMs > 20) {
|
||||
slowFrameMs.current += elapsedMs;
|
||||
stableFrameMs.current = 0;
|
||||
if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
|
||||
onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
|
||||
slowFrameMs.current = 0;
|
||||
}
|
||||
} else {
|
||||
slowFrameMs.current = 0;
|
||||
stableFrameMs.current += elapsedMs;
|
||||
if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
|
||||
onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
|
||||
stableFrameMs.current = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
simulationAccumulator.current = 0;
|
||||
slowFrameMs.current = 0;
|
||||
stableFrameMs.current = 0;
|
||||
}
|
||||
advance(now / 1000, true);
|
||||
}
|
||||
}
|
||||
frameId.current = requestAnimationFrame(schedule);
|
||||
};
|
||||
|
||||
frameId.current = requestAnimationFrame(schedule);
|
||||
return () => {
|
||||
if (frameId.current !== null) cancelAnimationFrame(frameId.current);
|
||||
frameId.current = null;
|
||||
lastRenderedAt.current = null;
|
||||
simulationAccumulator.current = 0;
|
||||
};
|
||||
}, [advance, gameplayActive, onDprChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
|
||||
@@ -517,25 +544,35 @@ function PlayerCharacter() {
|
||||
|
||||
function Party() {
|
||||
const selected = useGameStore((state) => state.selectedMemberId);
|
||||
const [loadSupportModels, setLoadSupportModels] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerCharacter />
|
||||
{PARTY_MEMBER_IDS.map((memberId) => (
|
||||
<Character
|
||||
key={memberId}
|
||||
memberId={memberId}
|
||||
selected={selected === memberId}
|
||||
/>
|
||||
))}
|
||||
<Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
|
||||
<PlayerCharacter />
|
||||
<Character memberId="brann" selected={selected === "brann"} />
|
||||
</Suspense>
|
||||
{loadSupportModels ? (
|
||||
<Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
|
||||
{SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
|
||||
<Character key={memberId} memberId={memberId} selected={selected === memberId} />
|
||||
))}
|
||||
</Suspense>
|
||||
) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PartyFallback() {
|
||||
function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
|
||||
const positions = useGameStore((state) => state.partyPositions);
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(positions) as MemberId[]).map((memberId) => (
|
||||
{memberIds.map((memberId) => (
|
||||
<mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
|
||||
<capsuleGeometry args={[0.3, 0.75, 4, 8]} />
|
||||
<meshStandardMaterial color="#79998c" roughness={0.8} />
|
||||
@@ -554,7 +591,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
|
||||
return (
|
||||
<mesh castShadow position={[position[0], 1.1, position[1]]}>
|
||||
<dodecahedronGeometry args={[1.1, 0]} />
|
||||
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -625,8 +662,8 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
facingX = motion.chargeEnd[0] - motion.chargeStart[0];
|
||||
facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
|
||||
} else if (motion.mode === "returning") {
|
||||
facingX = state.partyPositions.brann[0] + motion.formationOffsetX - motion.position[0];
|
||||
facingZ = state.partyPositions.brann[1] - 4.25 - motion.position[1];
|
||||
facingX = ARENA_CENTER[0] + motion.formationOffsetX - motion.position[0];
|
||||
facingZ = ARENA_CENTER[1] - motion.position[1];
|
||||
}
|
||||
if (Math.hypot(facingX, facingZ) > 0.01) {
|
||||
const targetAngle = Math.atan2(facingX, facingZ);
|
||||
@@ -645,67 +682,21 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
|
||||
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG = {
|
||||
vexa: {
|
||||
url: INSECT_QUEEN_URL,
|
||||
scale: 9,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#bb67ff",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
cindermaw: {
|
||||
url: BLUE_EYES_WHITE_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8742",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
url: GATE_GUARDIAN_URL,
|
||||
scale: 4.3,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff5a24",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"obsidian-ram-golem": {
|
||||
url: GANDORA_URL,
|
||||
scale: 6.2,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff7438",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"cinderback-ricochet": {
|
||||
url: RED_EYES_BLACK_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8b3d",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
interface AlternateBossConfig {
|
||||
url: string;
|
||||
scale: number;
|
||||
idle: string;
|
||||
move: string;
|
||||
attack: string;
|
||||
special: string;
|
||||
death: string;
|
||||
light: string;
|
||||
rotationOffset: number;
|
||||
prototype?: boolean;
|
||||
floating?: boolean;
|
||||
}
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfig> = {
|
||||
"sandglass-scorpion": {
|
||||
url: SANDGLASS_URL,
|
||||
scale: 0.7,
|
||||
@@ -728,30 +719,6 @@ const ALTERNATE_BOSS_CONFIG = {
|
||||
light: "#49d5df",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"pumpking-king-of-ghosts": {
|
||||
url: PUMPKING_URL,
|
||||
scale: 1.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#d87842",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"blue-eyes-ultimate-dragon": {
|
||||
url: BLUE_EYES_ULTIMATE_URL,
|
||||
scale: 4.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#8fc8ff",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"mournveil-ghost": {
|
||||
url: MOURNVEIL_URL,
|
||||
scale: 1.1,
|
||||
@@ -762,6 +729,7 @@ const ALTERNATE_BOSS_CONFIG = {
|
||||
death: "Death",
|
||||
light: "#9d72ff",
|
||||
rotationOffset: 0,
|
||||
floating: true,
|
||||
},
|
||||
"crownshard-golem": {
|
||||
url: CROWNSHARD_URL,
|
||||
@@ -773,14 +741,94 @@ const ALTERNATE_BOSS_CONFIG = {
|
||||
death: "Death",
|
||||
light: "#e0bd45",
|
||||
rotationOffset: 0,
|
||||
floating: true,
|
||||
},
|
||||
} as const;
|
||||
"crystal-bat-matriarch": {
|
||||
url: CRYSTAL_BAT_MATRIARCH_URL,
|
||||
scale: 1.04,
|
||||
idle: "Idle",
|
||||
move: "Swoop",
|
||||
attack: "SonicPulse",
|
||||
special: "MirrorShatter",
|
||||
death: "Death",
|
||||
light: "#8eeaff",
|
||||
rotationOffset: 0,
|
||||
floating: true,
|
||||
},
|
||||
"stormwool-alpaca": {
|
||||
url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"cluckhorn-colossus": {
|
||||
url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"ashwing-demon": {
|
||||
url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, prototype: true, floating: true,
|
||||
},
|
||||
"riftclaw-demon": {
|
||||
url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"tempestscale-dragon": {
|
||||
url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, prototype: true, floating: true,
|
||||
},
|
||||
emberfox: {
|
||||
url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"mirelord-frog": {
|
||||
url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"stonebreaker-giant": {
|
||||
url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"glub-sovereign": {
|
||||
url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, prototype: true, floating: true,
|
||||
},
|
||||
"scrapking-goblin": {
|
||||
url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"warcaller-orc": {
|
||||
url: CLAUDE_BOSS_URLS["warcaller-orc"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#e4533f", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"tuskmaw-orc": {
|
||||
url: CLAUDE_BOSS_URLS["tuskmaw-orc"], scale: 1.45, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#9eb25d", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"broodfang-spider": {
|
||||
url: CLAUDE_BOSS_URLS["broodfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#b56cff", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"silkfang-spider": {
|
||||
url: CLAUDE_BOSS_URLS["silkfang-spider"], scale: 1.35, idle: "Spider_Idle", move: "Spider_Walk", attack: "Spider_Attack", special: "Spider_Jump", death: "Spider_Death", light: "#9d68d8", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"thorncrown-stag": {
|
||||
url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"sky-totem": {
|
||||
url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, prototype: true, floating: true,
|
||||
},
|
||||
"razorcrest-raptor": {
|
||||
url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"bristlequake-boar": {
|
||||
url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"moonfang-wolf": {
|
||||
url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"frostmaw-yeti": {
|
||||
url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
"rimeclaw-yeti": {
|
||||
url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0, prototype: true,
|
||||
},
|
||||
};
|
||||
|
||||
const PROTOTYPE_MOVE_MODES = [
|
||||
"skyfall",
|
||||
"mantis_sidestep",
|
||||
"ram_charging",
|
||||
"cinderback_ricochet",
|
||||
"charging",
|
||||
"returning",
|
||||
"sandglass_burrowing",
|
||||
"crab_scuttling",
|
||||
] as const;
|
||||
|
||||
const PROTOTYPE_ATTACK_MODES = [
|
||||
@@ -800,33 +848,23 @@ const PROTOTYPE_ATTACK_MODES = [
|
||||
"ghost_haunting",
|
||||
"golem_shockwave",
|
||||
"golem_crownfall",
|
||||
"telegraph",
|
||||
"stacking",
|
||||
"pouncing",
|
||||
"sandglass_burrow_telegraph",
|
||||
"sandglass_eruption",
|
||||
"sandglass_hourglass",
|
||||
"crab_scuttle_telegraph",
|
||||
"crab_tidal_burst",
|
||||
] as const;
|
||||
|
||||
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
if ("prototype" in config && config.prototype) {
|
||||
if (config.prototype) {
|
||||
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
|
||||
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
if (kind === "ember-mantis-duelist") {
|
||||
if (motionMode === "mantis_sidestep") return config.move;
|
||||
if (motionMode === "mantis_line_telegraph") return config.attack;
|
||||
if (motionMode === "mantis_cross_telegraph") return config.special;
|
||||
if (motionMode === "mantis_recover") return "Recover";
|
||||
}
|
||||
if (kind === "obsidian-ram-golem") {
|
||||
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
|
||||
if (motionMode === "ram_quake") return config.special;
|
||||
if (motionMode === "ram_shatter") return "ArmorShatter";
|
||||
if (motionMode === "ram_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "cinderback-ricochet") {
|
||||
if (motionMode === "cinderback_curl") return config.attack;
|
||||
if (motionMode === "cinderback_ricochet") return config.move;
|
||||
if (motionMode === "cinderback_slam") return config.special;
|
||||
if (motionMode === "cinderback_recover") return "Recover";
|
||||
}
|
||||
if (kind === "sandglass-scorpion") {
|
||||
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
|
||||
if (motionMode === "sandglass_eruption") return config.attack;
|
||||
@@ -846,16 +884,18 @@ function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeo
|
||||
if (motionMode === "golem_shockwave") return config.attack;
|
||||
if (motionMode === "golem_crownfall") return config.special;
|
||||
}
|
||||
if (kind === "cindermaw") {
|
||||
if (motionMode === "skyfall") return config.move;
|
||||
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
|
||||
if (kind === "crystal-bat-matriarch") {
|
||||
if (motionMode === "golem_shockwave") return config.attack;
|
||||
if (motionMode === "golem_crownfall") return config.special;
|
||||
if (motionMode === "golem_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
|
||||
if (BOSS_ARCHETYPE_BY_ID[kind] === "web-caster" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
|
||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
const archetype = BOSS_ARCHETYPE_BY_ID[kind];
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motionMode = useGameStore((state) => (bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion)?.mode ?? "holding");
|
||||
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
|
||||
@@ -881,13 +921,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
for (const action of Object.values(actions)) action?.fadeOut(0.16);
|
||||
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
|
||||
const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph"
|
||||
? 0.55
|
||||
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
|
||||
: archetype === "duelist" && motionMode === "mantis_cross_telegraph"
|
||||
? 0.6
|
||||
: 1;
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
|
||||
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
|
||||
const authoredOneShot = ![config.idle, config.move].includes(clipName);
|
||||
if (phase === "victory" || defeated || authoredOneShot) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
@@ -895,7 +935,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
return () => { next.fadeOut(0.16); };
|
||||
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
|
||||
}, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
@@ -903,9 +943,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
const current = encounterBossAt(state, bossIndex);
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
|
||||
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
|
||||
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
|
||||
const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
|
||||
const burrowed = archetype === "burrower" && motion.mode === "sandglass_burrowing";
|
||||
const floatingHeight = config.floating ? 0.2 : 0.03;
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
|
||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||
|
||||
@@ -913,9 +953,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
state.partyPositions.brann[0] - motion.position[0],
|
||||
state.partyPositions.brann[1] - motion.position[1],
|
||||
);
|
||||
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
||||
if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
|
||||
targetAngle = motion.breathAngle;
|
||||
} else if (kind === "ember-mantis-duelist" && (
|
||||
} else if (archetype === "duelist" && (
|
||||
motion.mode === "mantis_sidestep"
|
||||
|| motion.mode === "mantis_line_telegraph"
|
||||
|| motion.mode === "mantis_cross_telegraph"
|
||||
@@ -1181,6 +1221,8 @@ function PerformanceProbe() {
|
||||
}
|
||||
document.documentElement.dataset.gamePerf = JSON.stringify({
|
||||
frame: {
|
||||
targetFps: 60,
|
||||
budgetMs: 16.67,
|
||||
averageMs: total / samples.length,
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
p99Ms: percentile(sorted, 0.99),
|
||||
@@ -1192,6 +1234,9 @@ function PerformanceProbe() {
|
||||
triangles: gl.info.render.triangles,
|
||||
geometries: gl.info.memory.geometries,
|
||||
textures: gl.info.memory.textures,
|
||||
pixelRatio: gl.getPixelRatio(),
|
||||
renderWidth: gl.domElement.width,
|
||||
renderHeight: gl.domElement.height,
|
||||
},
|
||||
simulation: simulationTickSnapshot(),
|
||||
memory: memory.memory ? {
|
||||
@@ -1237,24 +1282,25 @@ function CombatFx() {
|
||||
}
|
||||
|
||||
export function GameScene() {
|
||||
const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
|
||||
const setRenderDpr = useCallback((next: number) => {
|
||||
setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="never"
|
||||
shadows="basic"
|
||||
dpr={dpr}
|
||||
camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
|
||||
gl={{ antialias: true, powerPreference: "high-performance" }}
|
||||
gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
|
||||
>
|
||||
<color attach="background" args={["#07110f"]} />
|
||||
<fog attach="fog" args={["#07110f", 17, 32]} />
|
||||
<hemisphereLight args={["#8ac4b5", "#15100b", 1.25]} />
|
||||
<directionalLight castShadow position={[5, 10, 8]} intensity={2.2} color="#ffe2a9" shadow-mapSize={[1024, 1024]} />
|
||||
<Arena />
|
||||
<SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
|
||||
<BossRoom />
|
||||
<BossMechanicIndicators />
|
||||
<BarrierField />
|
||||
<TankAuraField />
|
||||
<Suspense fallback={<PartyFallback />}>
|
||||
<Party />
|
||||
</Suspense>
|
||||
<Party />
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
@@ -1263,8 +1309,9 @@ export function GameScene() {
|
||||
);
|
||||
}
|
||||
|
||||
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
|
||||
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
|
||||
for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
|
||||
useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const loadout = PARTY_WEAPON_URLS[memberId];
|
||||
useGLTF.preload(loadout.right, false, true);
|
||||
if (loadout.left) useGLTF.preload(loadout.left, false, true);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { barrierProtects, upcomingEncounterMechanic, useGameStore } from "../game/store";
|
||||
import { HEALER_CLASSES } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS } from "../game/bossCatalog";
|
||||
import { GameScene } from "./GameScene";
|
||||
import { bossRoomFor } from "../game/bossRooms";
|
||||
import { tankAuraProtects } from "../game/partyCombat";
|
||||
import { BuffDraftPanel } from "./BuffDraftPanel";
|
||||
|
||||
const GameScene = lazy(() => import("./GameScene").then((module) => ({ default: module.GameScene })));
|
||||
|
||||
function CompactParty() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const time = useGameStore((state) => state.time);
|
||||
@@ -110,15 +113,16 @@ function PhaseOverlay() {
|
||||
if (phase === "intermission") return <BuffDraftPanel className="top-buff-draft" />;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const definitions = bosses.map((boss) => BOSS_DEFINITIONS[boss.id]);
|
||||
const room = bossRoomFor(primaryBoss.id);
|
||||
const bossNames = bosses.map((boss) => boss.name).join(" & ");
|
||||
if (phase === "combat") return null;
|
||||
const title = phase === "briefing"
|
||||
? definitions.map((boss) => boss.title).join(" & ")
|
||||
? room.name
|
||||
: phase === "victory"
|
||||
? `${bossNames} Broken`
|
||||
: "Party Broken";
|
||||
const eyebrow = phase === "briefing"
|
||||
? (bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial)
|
||||
? `${bosses.length > 1 ? "Roguelike PVE · Dual Encounter" : definitions[0].trial} · ${room.biome}`
|
||||
: phase === "victory"
|
||||
? "Encounter Complete"
|
||||
: "Encounter Failed";
|
||||
@@ -182,7 +186,9 @@ export function TopScreen({ onExit }: { onExit?: () => void }) {
|
||||
const setPaused = useGameStore((state) => state.setPaused);
|
||||
return (
|
||||
<section className="display top-display" aria-label="Main game viewport">
|
||||
<GameScene />
|
||||
<Suspense fallback={<div className="scene-loading" aria-label="Loading 3D scene" />}>
|
||||
<GameScene />
|
||||
</Suspense>
|
||||
<div className="top-vignette" />
|
||||
<div className="top-hud">
|
||||
<CompactParty />
|
||||
|
||||
@@ -2,17 +2,31 @@ import { useFrame } from "@react-three/fiber";
|
||||
import { useRef, type ComponentType } from "react";
|
||||
import * as THREE from "three";
|
||||
import { BULL_CHARGE, BULL_POUNCE } from "../../game/bossMechanics";
|
||||
import { CINDER_BREATH } from "../../game/bosses/cindermaw";
|
||||
import { MEMORY_SEQUENCE, MEMORY_SYMBOLS } from "../../game/bosses/mechanicPool";
|
||||
import { SKY_SWEEPER_BREATH } from "../../game/bosses/skySweeper";
|
||||
import { useGameStore } from "../../game/store";
|
||||
import type { MemorySymbolId, MemoryTile, PoolTelegraph } from "../../game/types";
|
||||
|
||||
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||
const EMPTY_HAZARDS: never[] = [];
|
||||
const EMPTY_SLASH_LANES: never[] = [];
|
||||
const EMPTY_POOL_TELEGRAPHS: never[] = [];
|
||||
const ACTIVE_LANE_MODES = new Set(["mantis_recover", "ram_charging", "ram_recover", "cinderback_ricochet", "cinderback_recover", "sandglass_burrowing", "sandglass_recover", "crab_scuttling", "crab_recover", "ghost_recover"]);
|
||||
const DANGER_WARNING_COLOR = "#ff3b30";
|
||||
const DANGER_ACTIVE_COLOR = "#d4142a";
|
||||
const DANGER_HIGHLIGHT_COLOR = "#ff8a80";
|
||||
const SOAK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
|
||||
const WARD_ARROW_DIRECTIONS = [0, Math.PI / 2, Math.PI, Math.PI * 1.5] as const;
|
||||
const INWARD_ARROW_SHAPE = new THREE.Shape()
|
||||
.moveTo(-0.13, -0.32)
|
||||
.lineTo(0.13, -0.32)
|
||||
.lineTo(0.13, 0.04)
|
||||
.lineTo(0.29, 0.04)
|
||||
.lineTo(0, 0.38)
|
||||
.lineTo(-0.29, 0.04)
|
||||
.lineTo(-0.13, 0.04)
|
||||
.lineTo(-0.13, -0.32);
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function motionAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -212,12 +226,12 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
return (
|
||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - CINDER_BREATH.halfAngle]}>
|
||||
<circleGeometry args={[CINDER_BREATH.range, 64, 0, CINDER_BREATH.halfAngle * 2]} />
|
||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial ref={material} color={color} transparent opacity={0.25} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, CINDER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[CINDER_BREATH.range * 0.47, CINDER_BREATH.range * 0.49, 48, 1, -CINDER_BREATH.halfAngle, CINDER_BREATH.halfAngle * 2]} />
|
||||
<mesh position={[0, 0.02, SKY_SWEEPER_BREATH.range * 0.48]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[SKY_SWEEPER_BREATH.range * 0.47, SKY_SWEEPER_BREATH.range * 0.49, 48, 1, -SKY_SWEEPER_BREATH.halfAngle, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
@@ -276,6 +290,274 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
|
||||
return <>{hazards.map((hazard) => <CircleHazardIndicator key={hazard.id} hazardId={hazard.id} bossIndex={bossIndex} />)}</>;
|
||||
}
|
||||
|
||||
/** Tall gold ward plus a lightweight spectral pursuer for Mournveil's healer run. */
|
||||
export function SoulSiphonIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const motion = useGameStore((state) => motionAt(state, bossIndex));
|
||||
const wardGroup = useRef<THREE.Group>(null);
|
||||
const ghostGroup = useRef<THREE.Group>(null);
|
||||
const outerColumn = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const innerColumn = useRef<THREE.MeshBasicMaterial>(null);
|
||||
const groundRing = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const current = motionAt(useGameStore.getState(), bossIndex);
|
||||
const siphon = current?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
|
||||
const active = useGameStore.getState().phase === "combat" && siphon;
|
||||
if (!active || !siphon) return;
|
||||
const pulse = (Math.sin(clock.elapsedTime * 5.5) + 1) * 0.5;
|
||||
if (wardGroup.current) {
|
||||
wardGroup.current.position.set(siphon.wardPosition[0], 0, siphon.wardPosition[1]);
|
||||
wardGroup.current.rotation.y += 0.003;
|
||||
}
|
||||
if (ghostGroup.current) {
|
||||
ghostGroup.current.position.set(siphon.ghostPosition[0], 0.85 + Math.sin(clock.elapsedTime * 4.2) * 0.12, siphon.ghostPosition[1]);
|
||||
ghostGroup.current.rotation.y += 0.018;
|
||||
}
|
||||
if (outerColumn.current) outerColumn.current.opacity = 0.15 + pulse * 0.09;
|
||||
if (innerColumn.current) innerColumn.current.opacity = 0.2 + pulse * 0.16;
|
||||
if (groundRing.current) groundRing.current.opacity = 0.62 + pulse * 0.28;
|
||||
});
|
||||
|
||||
const siphon = motion?.poolTelegraphs.find((telegraph) => telegraph.kind === "soul-siphon")?.soulSiphon;
|
||||
if (!siphon || phase !== "combat") return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<group ref={wardGroup} position={[siphon.wardPosition[0], 0, siphon.wardPosition[1]]}>
|
||||
<mesh position={[0, 2.45, 0]}>
|
||||
<cylinderGeometry args={[0.78, 1.05, 4.9, 28, 1, true]} />
|
||||
<meshBasicMaterial ref={outerColumn} color="#f8c94e" transparent opacity={0.2} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.45, 0]}>
|
||||
<cylinderGeometry args={[0.38, 0.58, 4.9, 24, 1, true]} />
|
||||
<meshBasicMaterial ref={innerColumn} color="#fff0a3" transparent opacity={0.32} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.045, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[siphon.wardRadius - 0.13, siphon.wardRadius, 40]} />
|
||||
<meshBasicMaterial ref={groundRing} color="#ffe47d" transparent opacity={0.9} depthWrite={false} />
|
||||
</mesh>
|
||||
{WARD_ARROW_DIRECTIONS.map((angle) => (
|
||||
<group
|
||||
key={angle}
|
||||
position={[Math.sin(angle) * 1.58, 0.062, Math.cos(angle) * 1.58]}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
|
||||
<shapeGeometry args={[INWARD_ARROW_SHAPE]} />
|
||||
<meshBasicMaterial color="#54ff90" transparent opacity={1} depthWrite={false} depthTest={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
<group ref={ghostGroup} position={[siphon.ghostPosition[0], 0.85, siphon.ghostPosition[1]]}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.34, 16, 12]} />
|
||||
<meshBasicMaterial color="#b48aff" transparent opacity={0.68} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.52, 0]} scale={[0.82, 1.45, 0.82]}>
|
||||
<coneGeometry args={[0.34, 0.78, 12, 1, true]} />
|
||||
<meshBasicMaterial color="#8d62d2" transparent opacity={0.36} depthWrite={false} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.48, 0.026, 8, 24]} />
|
||||
<meshBasicMaterial color="#e1c4ff" transparent opacity={0.72} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MemorySymbolMark({ symbol, size = 1, opacity = 1 }: { symbol: MemorySymbolId; size?: number; opacity?: number }) {
|
||||
const color = MEMORY_SYMBOLS[symbol].color;
|
||||
if (symbol === "cross") {
|
||||
return (
|
||||
<group position={[0, 0.012, 0]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[size * 0.22, 0.035, size]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<boxGeometry args={[size, 0.035, size * 0.22]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, symbol === "square" ? Math.PI / 4 : -Math.PI / 2]}>
|
||||
<ringGeometry args={[size * 0.34, size * 0.54, symbol === "circle" ? 36 : symbol === "triangle" ? 3 : 4]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={opacity} depthWrite={false} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryTileIndicator({ tile, inputActive }: { tile: MemoryTile; inputActive: boolean }) {
|
||||
const color = MEMORY_SYMBOLS[tile.symbol].color;
|
||||
const halfSize = MEMORY_SEQUENCE.tileSize * 0.5;
|
||||
const gridOffsets = [-0.48, 0, 0.48] as const;
|
||||
return (
|
||||
<group position={[tile.center[0], 0.075, tile.center[1]]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[MEMORY_SEQUENCE.tileSize, 0.04, MEMORY_SEQUENCE.tileSize]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={inputActive ? 0.28 : 0.16} depthWrite={false} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<group key={side}>
|
||||
<mesh position={[side * halfSize, 0.03, 0]}>
|
||||
<boxGeometry args={[0.08, 0.06, MEMORY_SEQUENCE.tileSize + 0.08]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.03, side * halfSize]}>
|
||||
<boxGeometry args={[MEMORY_SEQUENCE.tileSize + 0.08, 0.06, 0.08]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.96} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
{gridOffsets.map((offset) => (
|
||||
<group key={offset}>
|
||||
<mesh position={[0, 0.026, offset * MEMORY_SEQUENCE.tileSize]}>
|
||||
<boxGeometry args={[MEMORY_SEQUENCE.tileSize * 0.84, 0.018, 0.026]} />
|
||||
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[offset * MEMORY_SEQUENCE.tileSize, 0.027, 0]}>
|
||||
<boxGeometry args={[0.026, 0.018, MEMORY_SEQUENCE.tileSize * 0.84]} />
|
||||
<meshBasicMaterial color="#fff6df" transparent opacity={0.38} depthWrite={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
<MemorySymbolMark symbol={tile.symbol} size={0.78} opacity={1} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function MemorySequenceIndicator({ telegraph, bossPosition, time }: { telegraph: PoolTelegraph; bossPosition?: [number, number]; time: number }) {
|
||||
if (!telegraph.sequence || !telegraph.tiles || telegraph.inputStartsAt === undefined) return null;
|
||||
const showingSequence = time < telegraph.inputStartsAt;
|
||||
const flashIndex = Math.min(
|
||||
telegraph.sequence.length - 1,
|
||||
Math.max(0, Math.floor((time - telegraph.activatesAt) / MEMORY_SEQUENCE.flashDuration)),
|
||||
);
|
||||
const flashSymbol = telegraph.sequence[flashIndex];
|
||||
const source = bossPosition ?? telegraph.center;
|
||||
return (
|
||||
<>
|
||||
{telegraph.tiles.map((tile) => <MemoryTileIndicator key={tile.symbol} tile={tile} inputActive={!showingSequence} />)}
|
||||
{showingSequence && (
|
||||
<group position={[source[0], 2.5, source[1]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<circleGeometry args={[0.88, 32]} />
|
||||
<meshBasicMaterial color="#111827" transparent opacity={0.9} depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.015, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.8, 0.9, 32]} />
|
||||
<meshBasicMaterial color={MEMORY_SYMBOLS[flashSymbol].color} transparent opacity={1} depthWrite={false} />
|
||||
</mesh>
|
||||
<MemorySymbolMark symbol={flashSymbol} size={1.05} />
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: string; bossIndex: number }) {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const time = useGameStore((state) => state.time);
|
||||
const telegraph = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId));
|
||||
const bossPosition = useGameStore((state) => motionAt(state, bossIndex)?.position);
|
||||
const fillMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||
useFrame(({ clock }) => {
|
||||
if (!fillMaterial.current) return;
|
||||
const current = motionAt(useGameStore.getState(), bossIndex)?.poolTelegraphs.find((entry) => entry.id === telegraphId);
|
||||
if (!current) return;
|
||||
const active = useGameStore.getState().time >= current.activatesAt;
|
||||
fillMaterial.current.opacity = active ? 0.46 : 0.14 + (Math.sin(clock.elapsedTime * 10) + 1) * 0.08;
|
||||
});
|
||||
if (!telegraph || phase !== "combat") return null;
|
||||
|
||||
if (telegraph.kind === "memory") return <MemorySequenceIndicator telegraph={telegraph} bossPosition={bossPosition} time={time} />;
|
||||
if (telegraph.kind === "soul-siphon") return null;
|
||||
|
||||
const active = time >= telegraph.activatesAt;
|
||||
const color = telegraph.kind === "soak" ? "#57e8ff"
|
||||
: telegraph.kind === "spread" ? "#e869ff"
|
||||
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||
|
||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
||||
const dx = telegraph.end[0] - telegraph.start[0];
|
||||
const dz = telegraph.end[1] - telegraph.start[1];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const angle = Math.atan2(dx, dz);
|
||||
return (
|
||||
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => (
|
||||
<mesh key={side} position={[side * (telegraph.width ?? 1) * 0.5, 0.022, 0]}>
|
||||
<boxGeometry args={[0.06, 0.035, length]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.95} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
{CHARGE_MARKERS.map((index) => (
|
||||
<mesh key={index} position={[0, 0.034, -length * 0.44 + (index / 6) * length * 0.88]}>
|
||||
<boxGeometry args={[(telegraph.width ?? 1) * 0.56, 0.035, 0.13]} />
|
||||
<meshBasicMaterial color="#ffe1b3" transparent opacity={active ? 1 : 0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
const innerRadius = telegraph.innerRadius ?? 0;
|
||||
return (
|
||||
<group position={[telegraph.center[0], 0.07, telegraph.center[1]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{telegraph.kind === "donut"
|
||||
? <ringGeometry args={[innerRadius, telegraph.radius, 56]} />
|
||||
: <circleGeometry args={[telegraph.radius, 48]} />}
|
||||
<meshBasicMaterial ref={fillMaterial} color={color} transparent depthWrite={false} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[telegraph.radius - 0.1, telegraph.radius + 0.04, 48]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.94} depthWrite={false} />
|
||||
</mesh>
|
||||
{telegraph.kind === "donut" && (
|
||||
<mesh position={[0, 0.021, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[innerRadius - 0.05, innerRadius + 0.05, 40]} />
|
||||
<meshBasicMaterial color="#f9efcf" transparent opacity={0.8} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
{telegraph.kind === "spread" && (
|
||||
<>
|
||||
<mesh position={[0, 0.028, 0]}>
|
||||
<boxGeometry args={[telegraph.radius * 1.35, 0.032, 0.07]} />
|
||||
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.03, 0]}>
|
||||
<boxGeometry args={[0.07, 0.032, telegraph.radius * 1.35]} />
|
||||
<meshBasicMaterial color="#ffd9ff" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
{telegraph.kind === "soak" && SOAK_DIRECTIONS.map((angle, index) => (
|
||||
<group key={index} rotation={[0, angle, 0]} position={[0, 0.035, 0]}>
|
||||
<mesh position={[0, 0, -telegraph.radius + 0.32]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<coneGeometry args={[0.16, 0.34, 3]} />
|
||||
<meshBasicMaterial color="#c9fbff" transparent opacity={0.96} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||
const telegraphs = useGameStore((state) => motionAt(state, bossIndex)?.poolTelegraphs ?? EMPTY_POOL_TELEGRAPHS);
|
||||
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
||||
}
|
||||
|
||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||
ChargeLaneIndicator,
|
||||
SlashLaneIndicators,
|
||||
@@ -283,6 +565,8 @@ const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[]
|
||||
BindingWebIndicator,
|
||||
BreathConeIndicator,
|
||||
CircleHazardIndicators,
|
||||
SoulSiphonIndicator,
|
||||
PooledMechanicIndicators,
|
||||
];
|
||||
|
||||
export function BossMechanicIndicators() {
|
||||
|
||||
Reference in New Issue
Block a user