841 lines
36 KiB
TypeScript
841 lines
36 KiB
TypeScript
import { useFrame } from "@react-three/fiber";
|
||
import { useGLTF } from "@react-three/drei";
|
||
import { Component, Suspense, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
|
||
import * as THREE from "three";
|
||
import { ARENA_CENTER, ARENA_SIZE_MULTIPLIER, ARENA_WALL_RADIUS } from "../game/arena";
|
||
import {
|
||
BLOCKBREAKER_BIOMES,
|
||
blockbreakerBiomeForSeed,
|
||
type BlockbreakerArenaBiome,
|
||
type BlockbreakerBiomeFixture,
|
||
} from "../game/blockbreakerBiomes";
|
||
import { bossRoomFor, type BossRoomDefinition, type BossRoomFloor } from "../game/bossRooms";
|
||
import {
|
||
HOCKEY_ARENA_CENTER_Z,
|
||
HOCKEY_ARENA_LENGTH,
|
||
HOCKEY_ARENA_MAX_X,
|
||
HOCKEY_ARENA_MAX_Z,
|
||
HOCKEY_ARENA_MIN_X,
|
||
HOCKEY_ARENA_MIN_Z,
|
||
HOCKEY_ARENA_WIDTH,
|
||
HOCKEY_GOAL_HALF_WIDTH,
|
||
HOCKEY_HEALER_GOAL_Z,
|
||
HOCKEY_MIDLINE_Z,
|
||
HOCKEY_NPC_GOAL_Z,
|
||
} from "../game/hockeyHealing";
|
||
import { useGameStore } from "../game/store";
|
||
import { LEGACY_GAME_ASSETS_FORCED, useGameGLTF } from "./GameAssetProvider";
|
||
import {
|
||
HOCKEY_PVP_ARENA_MAX_X,
|
||
HOCKEY_PVP_ARENA_MAX_Z,
|
||
HOCKEY_PVP_ARENA_MIN_X,
|
||
HOCKEY_PVP_ARENA_MIN_Z,
|
||
HOCKEY_PVP_GOAL_HALF_WIDTH,
|
||
HOCKEY_PVP_GOAL_Z,
|
||
} from "../game/hockeyHealingPvp";
|
||
import { RpgRoomPortals } from "./rpgRoguelike/RpgRoomPortals";
|
||
|
||
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;
|
||
const KAYKIT_DUNGEON_KIT_URL = new URL("../assets/game/models/original/environment/kaykit-dungeon/dungeon-kit-uastc.glb", import.meta.url).href;
|
||
const DUNGEON_MESH_NAMES = {
|
||
pillar: "pillar_decorated",
|
||
wall: "wall_pillar",
|
||
torch: "torch_lit",
|
||
} as const;
|
||
|
||
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 DungeonMeshInstances({
|
||
mesh,
|
||
fixtures,
|
||
tint,
|
||
opacity = 1,
|
||
colors,
|
||
}: {
|
||
mesh: THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||
fixtures: readonly ArenaFixture[];
|
||
tint: string;
|
||
opacity?: number;
|
||
colors?: readonly THREE.Color[];
|
||
}) {
|
||
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 LegacyDungeonAssetInstances({
|
||
url,
|
||
...props
|
||
}: Omit<Parameters<typeof DungeonMeshInstances>[0], "mesh"> & { url: string }) {
|
||
const gltf = useGLTF(url, false, true);
|
||
const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]);
|
||
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||
}
|
||
|
||
function arenaWalls(room: BossRoomDefinition, portalOpenings = false) {
|
||
return ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8).map((fixture) => ({
|
||
...fixture,
|
||
scaleY: room.wallHeight / 4,
|
||
}));
|
||
}
|
||
|
||
function LegacyArenaArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
|
||
return (
|
||
<group>
|
||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_WALL_URL} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_PILLAR_URL} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||
<LegacyDungeonAssetInstances url={KAYKIT_DUNGEON_TORCH_URL} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function namedMesh(scene: THREE.Object3D, name: string) {
|
||
const object = scene.getObjectByName(name);
|
||
if (object instanceof THREE.Mesh) {
|
||
return object as THREE.Mesh<THREE.BufferGeometry, THREE.Material | THREE.Material[]>;
|
||
}
|
||
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||
}
|
||
|
||
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||
const walls = useMemo(() => arenaWalls(room, portalOpenings), [portalOpenings, room]);
|
||
const meshes = useMemo(() => ({
|
||
pillar: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.pillar),
|
||
wall: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.wall),
|
||
torch: namedMesh(gltf.scene, DUNGEON_MESH_NAMES.torch),
|
||
}), [gltf.scene]);
|
||
return (
|
||
<group>
|
||
<DungeonMeshInstances mesh={meshes.wall} fixtures={walls} tint={room.wallColor} opacity={0.54} />
|
||
<DungeonMeshInstances mesh={meshes.pillar} fixtures={ARENA_COLUMNS} tint={room.wallColor} />
|
||
<DungeonMeshInstances mesh={meshes.torch} fixtures={ARENA_TORCHES} tint="#ffffff" colors={ARENA_TORCH_COLORS} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
class DungeonAssetErrorBoundary extends Component<{
|
||
children: ReactNode;
|
||
fallback: ReactNode;
|
||
}, { failed: boolean }> {
|
||
state = { failed: false };
|
||
|
||
static getDerivedStateFromError() {
|
||
return { failed: true };
|
||
}
|
||
|
||
componentDidCatch(error: unknown) {
|
||
console.warn("Optimized dungeon asset failed; using legacy GLBs.", error);
|
||
}
|
||
|
||
render() {
|
||
return this.state.failed ? this.props.fallback : this.props.children;
|
||
}
|
||
}
|
||
|
||
function RoomWallFallback({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
const walls = useRef<THREE.Group>(null);
|
||
const previousCameraPosition = useRef<THREE.Vector3 | null>(null);
|
||
const fixtures = useMemo(
|
||
() => ARENA_WALL_SEGMENTS.filter((_, index) => !portalOpenings || index !== 0 && index !== 8),
|
||
[portalOpenings],
|
||
);
|
||
|
||
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}>
|
||
{fixtures.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 LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
return (
|
||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
return (
|
||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
|
||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
|
||
</Suspense>
|
||
</DungeonAssetErrorBoundary>
|
||
);
|
||
}
|
||
|
||
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
return LEGACY_GAME_ASSETS_FORCED
|
||
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
|
||
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
|
||
}
|
||
|
||
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, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||
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} portalOpenings={portalOpenings} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function HockeyGoal({ z, color }: { z: number; color: string }) {
|
||
const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2;
|
||
return (
|
||
<group position={[0, 0, z]}>
|
||
<mesh position={[-HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
|
||
<boxGeometry args={[0.18, 2.1, 0.24]} />
|
||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
|
||
</mesh>
|
||
<mesh position={[HOCKEY_GOAL_HALF_WIDTH, 1.05, 0]} castShadow>
|
||
<boxGeometry args={[0.18, 2.1, 0.24]} />
|
||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.7} metalness={0.45} roughness={0.32} />
|
||
</mesh>
|
||
<mesh position={[0, 2.03, 0]} castShadow>
|
||
<boxGeometry args={[goalWidth, 0.16, 0.22]} />
|
||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={1.2} metalness={0.45} roughness={0.32} />
|
||
</mesh>
|
||
<mesh position={[0, 1.05, z === HOCKEY_HEALER_GOAL_Z ? 0.08 : -0.08]}>
|
||
<planeGeometry args={[goalWidth, 2]} />
|
||
<meshBasicMaterial color={color} transparent opacity={0.14} wireframe depthWrite={false} />
|
||
</mesh>
|
||
<pointLight color={color} intensity={2.4} distance={7} position={[0, 1.3, 0]} />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function HockeyHealingRoom() {
|
||
const goalSideWidth = (HOCKEY_ARENA_WIDTH - HOCKEY_GOAL_HALF_WIDTH * 2) * 0.5;
|
||
const leftGoalSideX = HOCKEY_ARENA_MIN_X + goalSideWidth * 0.5;
|
||
const rightGoalSideX = HOCKEY_ARENA_MAX_X - goalSideWidth * 0.5;
|
||
const wallMaterial = <meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />;
|
||
return (
|
||
<group>
|
||
<color attach="background" args={["#020b11"]} />
|
||
<fog attach="fog" args={["#061a24", 18, 46]} />
|
||
<hemisphereLight args={["#80dfff", "#061015", 1.08]} />
|
||
<directionalLight castShadow position={[6, 12, 8]} intensity={1.8} color="#d6f7ff" shadow-mapSize={[512, 512]} />
|
||
<pointLight color="#50dfff" intensity={2.2} distance={14} position={[0, 4, HOCKEY_MIDLINE_Z]} />
|
||
<mesh position={[0, -0.42, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
|
||
<boxGeometry args={[HOCKEY_ARENA_WIDTH + 1.2, 0.8, HOCKEY_ARENA_LENGTH + 1.2]} />
|
||
<meshStandardMaterial color="#061117" roughness={0.9} />
|
||
</mesh>
|
||
<mesh position={[0, -0.01, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
|
||
<meshStandardMaterial color="#102731" roughness={0.68} metalness={0.3} />
|
||
</mesh>
|
||
<mesh position={[0, 0.012, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 0.5, 0.09]} />
|
||
<meshBasicMaterial color="#64e7ff" transparent opacity={0.62} />
|
||
</mesh>
|
||
<mesh position={[0, 0.018, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<ringGeometry args={[2.45, 2.55, 48]} />
|
||
<meshBasicMaterial color="#64e7ff" transparent opacity={0.44} />
|
||
</mesh>
|
||
<mesh position={[HOCKEY_ARENA_MIN_X - 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
|
||
{wallMaterial}
|
||
</mesh>
|
||
<mesh position={[HOCKEY_ARENA_MAX_X + 0.15, 1.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||
<boxGeometry args={[0.3, 3, HOCKEY_ARENA_LENGTH + 0.6]} />
|
||
{wallMaterial}
|
||
</mesh>
|
||
{[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => (
|
||
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
|
||
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
|
||
<meshStandardMaterial color="#172d36" roughness={0.58} metalness={0.48} />
|
||
</mesh>
|
||
)))}
|
||
<HockeyGoal z={HOCKEY_NPC_GOAL_Z} color="#ff6e5c" />
|
||
<HockeyGoal z={HOCKEY_HEALER_GOAL_Z} color="#67e8ff" />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
const BLOCKBREAKER_BIOME_FIXTURES = [
|
||
[-12.75, -12.5, 0.94],
|
||
[-12.75, -1, 1.08],
|
||
[-12.75, 10.5, 0.9],
|
||
[12.75, -12.5, 1.02],
|
||
[12.75, -1, 0.88],
|
||
[12.75, 10.5, 1.12],
|
||
] as const;
|
||
|
||
function BlockbreakerFixtureGeometry({ fixture }: { fixture: BlockbreakerBiomeFixture }) {
|
||
if (fixture === "crystal") return <octahedronGeometry args={[1.25, 0]} />;
|
||
if (fixture === "forge") return <cylinderGeometry args={[0.72, 1.18, 2.8, 6]} />;
|
||
if (fixture === "spire") return <coneGeometry args={[1.05, 3.7, 5]} />;
|
||
if (fixture === "monolith") return <boxGeometry args={[1.25, 3.5, 1.25]} />;
|
||
return <torusGeometry args={[1.08, 0.3, 8, 18]} />;
|
||
}
|
||
|
||
function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) {
|
||
const mesh = useRef<THREE.InstancedMesh>(null);
|
||
const transform = useMemo(() => new THREE.Object3D(), []);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!mesh.current) return;
|
||
BLOCKBREAKER_BIOME_FIXTURES.forEach(([x, z, scale], index) => {
|
||
transform.position.set(x, biome.fixture === "reactor" ? 1.45 : 1.75, z);
|
||
transform.rotation.set(
|
||
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? -0.08 : 0.08,
|
||
index * 0.73,
|
||
biome.fixture === "reactor" ? 0 : index % 2 === 0 ? 0.06 : -0.06,
|
||
);
|
||
transform.scale.setScalar(scale);
|
||
transform.updateMatrix();
|
||
mesh.current!.setMatrixAt(index, transform.matrix);
|
||
});
|
||
mesh.current.instanceMatrix.needsUpdate = true;
|
||
mesh.current.computeBoundingSphere();
|
||
}, [biome.fixture, transform]);
|
||
|
||
return (
|
||
<instancedMesh ref={mesh} args={[undefined, undefined, BLOCKBREAKER_BIOME_FIXTURES.length]} castShadow receiveShadow>
|
||
<BlockbreakerFixtureGeometry fixture={biome.fixture} />
|
||
<meshStandardMaterial
|
||
color={biome.fixtureColor}
|
||
emissive={biome.fixtureEmissive}
|
||
emissiveIntensity={0.72}
|
||
roughness={biome.fixture === "monolith" ? 0.26 : 0.48}
|
||
metalness={biome.fixture === "forge" || biome.fixture === "reactor" ? 0.42 : 0.12}
|
||
/>
|
||
</instancedMesh>
|
||
);
|
||
}
|
||
|
||
function BrightArcadeRoom({
|
||
variant,
|
||
biome = BLOCKBREAKER_BIOMES[0],
|
||
}: {
|
||
variant: "blockbreaker" | "aether-assault";
|
||
biome?: BlockbreakerArenaBiome;
|
||
}) {
|
||
const roomWidth = HOCKEY_ARENA_WIDTH + 10;
|
||
const roomLength = HOCKEY_ARENA_LENGTH + 12;
|
||
const roomMinX = HOCKEY_ARENA_MIN_X - 5;
|
||
const roomMaxX = HOCKEY_ARENA_MAX_X + 5;
|
||
const roomMinZ = HOCKEY_ARENA_MIN_Z - 6;
|
||
const roomMaxZ = HOCKEY_ARENA_MAX_Z + 6;
|
||
const wallHeight = 4.8;
|
||
const wallMaterial = (
|
||
<meshStandardMaterial
|
||
color={biome.wall}
|
||
emissive={biome.wallEmissive}
|
||
emissiveIntensity={biome.wallEmissiveIntensity}
|
||
roughness={0.62}
|
||
metalness={0.12}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<group key={variant === "blockbreaker" ? biome.id : variant}>
|
||
<color attach="background" args={[biome.background]} />
|
||
<fog attach="fog" args={[biome.fog, 34, 72]} />
|
||
<ambientLight color={biome.ambient} intensity={biome.ambientIntensity} />
|
||
<hemisphereLight args={[biome.sky, biome.ground, biome.hemisphereIntensity]} />
|
||
<directionalLight
|
||
castShadow
|
||
position={[8, 15, 10]}
|
||
intensity={biome.keyLightIntensity}
|
||
color={biome.keyLight}
|
||
shadow-mapSize={[512, 512]}
|
||
/>
|
||
<pointLight color={biome.fillLightA} intensity={biome.fillLightIntensityA} distance={30} position={[-8, 7, 7]} />
|
||
<pointLight color={biome.fillLightB} intensity={biome.fillLightIntensityB} distance={30} position={[8, 7, -9]} />
|
||
|
||
<mesh position={[0, -0.48, HOCKEY_ARENA_CENTER_Z]} receiveShadow>
|
||
<boxGeometry args={[roomWidth + 1.2, 0.9, roomLength + 1.2]} />
|
||
<meshStandardMaterial color={biome.foundation} roughness={0.92} metalness={0.04} />
|
||
</mesh>
|
||
<mesh position={[0, -0.015, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||
<planeGeometry args={[roomWidth, roomLength]} />
|
||
<meshStandardMaterial color={biome.floor} roughness={biome.floorRoughness} metalness={biome.floorMetalness} />
|
||
</mesh>
|
||
|
||
<mesh position={[0, 0.006, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, HOCKEY_ARENA_LENGTH]} />
|
||
<meshStandardMaterial color={biome.playfield} transparent opacity={0.72} roughness={0.52} metalness={0.05} />
|
||
</mesh>
|
||
{[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => (
|
||
<mesh key={`lane-x-${x}`} position={[x, 0.025, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[0.1, HOCKEY_ARENA_LENGTH]} />
|
||
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
{[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => (
|
||
<mesh key={`lane-z-${z}`} position={[0, 0.025, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.1]} />
|
||
<meshBasicMaterial color={biome.boundary} transparent opacity={0.72} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
{variant === "blockbreaker" ? (
|
||
<mesh position={[0, 0.028, HOCKEY_MIDLINE_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH, 0.12]} />
|
||
<meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
|
||
</mesh>
|
||
) : (
|
||
<group>
|
||
{[-6.7, -3.35, 0, 3.35, 6.7].map((x) => (
|
||
<mesh key={`aether-lane-${x}`} position={[x, 0.029, HOCKEY_ARENA_CENTER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[0.055, HOCKEY_ARENA_LENGTH - 0.8]} />
|
||
<meshBasicMaterial color="#c6fbff" transparent opacity={x === 0 ? 0.58 : 0.3} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
{[-10.7, -8.45, -6.2, -3.95].map((z) => (
|
||
<mesh key={`aether-rank-${z}`} position={[0, 0.03, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[HOCKEY_ARENA_WIDTH - 1, 0.045]} />
|
||
<meshBasicMaterial color="#fff2bf" transparent opacity={0.36} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
</group>
|
||
)}
|
||
|
||
{[roomMinX - 0.18, roomMaxX + 0.18].map((x) => (
|
||
<mesh key={`room-x-${x}`} position={[x, wallHeight * 0.5, HOCKEY_ARENA_CENTER_Z]} castShadow receiveShadow>
|
||
<boxGeometry args={[0.36, wallHeight, roomLength + 0.7]} />
|
||
{wallMaterial}
|
||
</mesh>
|
||
))}
|
||
{[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => (
|
||
<mesh key={`room-z-${z}`} position={[0, wallHeight * 0.5, z]} castShadow receiveShadow>
|
||
<boxGeometry args={[roomWidth + 0.7, wallHeight, 0.36]} />
|
||
{wallMaterial}
|
||
</mesh>
|
||
))}
|
||
{[roomMinX - 0.36, roomMaxX + 0.36].map((x) => (
|
||
<mesh key={`light-x-${x}`} position={[x, 3.7, HOCKEY_ARENA_CENTER_Z]}>
|
||
<boxGeometry args={[0.12, 0.18, roomLength - 1]} />
|
||
<meshBasicMaterial color={biome.railA} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
{[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => (
|
||
<mesh key={`light-z-${z}`} position={[0, 3.7, z]}>
|
||
<boxGeometry args={[roomWidth - 1, 0.18, 0.12]} />
|
||
<meshBasicMaterial color={biome.railB} toneMapped={false} />
|
||
</mesh>
|
||
))}
|
||
{variant === "blockbreaker" && <BlockbreakerBiomeFixtures biome={biome} />}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function HealingHockeyPvpRoom() {
|
||
const width = HOCKEY_PVP_ARENA_MAX_X - HOCKEY_PVP_ARENA_MIN_X;
|
||
const length = HOCKEY_PVP_ARENA_MAX_Z - HOCKEY_PVP_ARENA_MIN_Z;
|
||
const goalSideWidth = (width - HOCKEY_PVP_GOAL_HALF_WIDTH * 2) * 0.5;
|
||
const sideCenters = [
|
||
HOCKEY_PVP_ARENA_MIN_X + goalSideWidth * 0.5,
|
||
HOCKEY_PVP_ARENA_MAX_X - goalSideWidth * 0.5,
|
||
];
|
||
return (
|
||
<group>
|
||
<color attach="background" args={["#040912"]} />
|
||
<fog attach="fog" args={["#071522", 28, 68]} />
|
||
<hemisphereLight args={["#8deaff", "#130812", 1.1]} />
|
||
<directionalLight castShadow position={[7, 14, 10]} intensity={1.9} color="#d8fbff" shadow-mapSize={[512, 512]} />
|
||
<pointLight color="#62e7ff" intensity={2.5} distance={18} position={[0, 4, 13]} />
|
||
<pointLight color="#ff6f83" intensity={2.5} distance={18} position={[0, 4, -13]} />
|
||
<mesh position={[0, -0.42, 0]} receiveShadow>
|
||
<boxGeometry args={[width + 1.2, 0.8, length + 1.2]} />
|
||
<meshStandardMaterial color="#070d17" roughness={0.9} />
|
||
</mesh>
|
||
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||
<planeGeometry args={[width, length]} />
|
||
<meshStandardMaterial color="#112632" roughness={0.7} metalness={0.32} />
|
||
</mesh>
|
||
<mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[width - 0.5, 0.11]} />
|
||
<meshBasicMaterial color="#f4d978" transparent opacity={0.7} />
|
||
</mesh>
|
||
{[-11, 11].map((z) => <mesh key={z} position={[0, 0.014, z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||
<planeGeometry args={[width - 0.8, 0.07]} />
|
||
<meshBasicMaterial color={z > 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} />
|
||
</mesh>)}
|
||
{[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => (
|
||
<mesh key={x} position={[x, 1.5, 0]} castShadow receiveShadow>
|
||
<boxGeometry args={[0.3, 3, length + 0.6]} />
|
||
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
|
||
</mesh>
|
||
))}
|
||
{[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => (
|
||
<mesh key={`${x}-${z}`} position={[x, 1.5, z]} castShadow receiveShadow>
|
||
<boxGeometry args={[goalSideWidth, 3, 0.3]} />
|
||
<meshStandardMaterial color="#172b38" roughness={0.58} metalness={0.48} />
|
||
</mesh>
|
||
)))}
|
||
<HockeyGoal z={-HOCKEY_PVP_GOAL_Z} color="#ff647b" />
|
||
<HockeyGoal z={HOCKEY_PVP_GOAL_Z} color="#62e7ff" />
|
||
<HockeyPvpScoreboards />
|
||
</group>
|
||
);
|
||
}
|
||
|
||
function HockeyPvpScoreboards() {
|
||
const localGoals = useGameStore((state) => state.hockeyPvp.opponentGoalsConceded);
|
||
const opponentGoals = useGameStore((state) => state.hockeyPvp.localGoalsConceded);
|
||
const texture = useMemo(() => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 512;
|
||
canvas.height = 192;
|
||
const next = new THREE.CanvasTexture(canvas);
|
||
next.colorSpace = THREE.SRGBColorSpace;
|
||
next.minFilter = THREE.LinearMipmapLinearFilter;
|
||
next.magFilter = THREE.LinearFilter;
|
||
return next;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const canvas = texture.image as HTMLCanvasElement;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return;
|
||
const gradient = context.createLinearGradient(0, 0, canvas.width, canvas.height);
|
||
gradient.addColorStop(0, "#061d28");
|
||
gradient.addColorStop(0.5, "#05080d");
|
||
gradient.addColorStop(1, "#2b0a17");
|
||
context.fillStyle = gradient;
|
||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||
context.strokeStyle = "#89efff";
|
||
context.lineWidth = 5;
|
||
context.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
|
||
context.fillStyle = "#a8c4c9";
|
||
context.font = "700 20px Inter, sans-serif";
|
||
context.textAlign = "center";
|
||
context.fillText("HEALING HOCKEY", canvas.width / 2, 31);
|
||
context.fillStyle = "#74ecff";
|
||
context.font = "700 18px Inter, sans-serif";
|
||
context.fillText("YOU", 132, 58);
|
||
context.fillStyle = "#ff819f";
|
||
context.fillText("RIVAL", 380, 58);
|
||
context.font = "700 94px Impact, Inter, sans-serif";
|
||
context.fillStyle = "#eaffff";
|
||
context.fillText(String(Math.min(99, localGoals)).padStart(2, "0"), 132, 151);
|
||
context.fillStyle = "#ffedf4";
|
||
context.fillText(String(Math.min(99, opponentGoals)).padStart(2, "0"), 380, 151);
|
||
context.fillStyle = "#f3d87c";
|
||
context.font = "700 56px Inter, sans-serif";
|
||
context.fillText("–", 256, 137);
|
||
texture.needsUpdate = true;
|
||
}, [localGoals, opponentGoals, texture]);
|
||
|
||
useEffect(() => () => texture.dispose(), [texture]);
|
||
|
||
return <>{([-1, 1] as const).map((side) => (
|
||
<group
|
||
key={side}
|
||
position={[side * 9.78, 4.05, 5.5]}
|
||
rotation={[0, side < 0 ? Math.PI / 2 : -Math.PI / 2, 0]}
|
||
>
|
||
<mesh castShadow>
|
||
<boxGeometry args={[7.6, 3.25, 0.24]} />
|
||
<meshStandardMaterial color="#101923" roughness={0.32} metalness={0.72} />
|
||
</mesh>
|
||
<mesh position={[0, 0, 0.126]}>
|
||
<planeGeometry args={[7.28, 2.93]} />
|
||
<meshBasicMaterial map={texture} toneMapped={false} />
|
||
</mesh>
|
||
</group>
|
||
))}</>;
|
||
}
|
||
|
||
/** Main-display room projection. Gameplay stays in the shared arena domain. */
|
||
export function BossRoom() {
|
||
const bossId = useGameStore((state) => state.boss.id);
|
||
const blockbreakerSeed = useGameStore((state) => state.blockbreaker.seed);
|
||
const hockeyMode = useGameStore((state) => state.activityMode === "hockey-healing");
|
||
const blockbreakerMode = useGameStore((state) => state.activityMode === "blockbreaker");
|
||
const aetherAssaultMode = useGameStore((state) => state.activityMode === "aether-assault");
|
||
const hockeyPvpMode = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
|
||
const rpgPhase = useGameStore((state) => state.rpgRun?.phase ?? null);
|
||
const rpgBossRoom = useGameStore((state) => state.runMode === "rpg-roguelike" && state.activityMode === "boss");
|
||
if (hockeyPvpMode) return <HealingHockeyPvpRoom />;
|
||
if (aetherAssaultMode) return <BrightArcadeRoom variant="aether-assault" />;
|
||
if (blockbreakerMode) return <BrightArcadeRoom variant="blockbreaker" biome={blockbreakerBiomeForSeed(blockbreakerSeed)} />;
|
||
if (hockeyMode) return <HockeyHealingRoom />;
|
||
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} portalOpenings={rpgBossRoom} />
|
||
{rpgBossRoom && rpgPhase && (
|
||
<RpgRoomPortals
|
||
entryOpen
|
||
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
|
||
accent={room.accent}
|
||
wallColor={room.wallColor}
|
||
/>
|
||
)}
|
||
</group>
|
||
);
|
||
}
|
||
|
||
if (LEGACY_GAME_ASSETS_FORCED) {
|
||
useGLTF.preload(KAYKIT_DUNGEON_PILLAR_URL, false, true);
|
||
useGLTF.preload(KAYKIT_DUNGEON_WALL_URL, false, true);
|
||
useGLTF.preload(KAYKIT_DUNGEON_TORCH_URL, false, true);
|
||
}
|