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 = { 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 = { 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 | 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; } function DungeonMeshInstances({ mesh, fixtures, tint, opacity = 1, colors, }: { mesh: THREE.Mesh; 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(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 ; } function LegacyDungeonAssetInstances({ url, ...props }: Omit[0], "mesh"> & { url: string }) { const gltf = useGLTF(url, false, true); const mesh = useMemo(() => firstMesh(gltf.scene), [gltf.scene]); return ; } 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 ( ); } function namedMesh(scene: THREE.Object3D, name: string) { const object = scene.getObjectByName(name); if (object instanceof THREE.Mesh) { return object as THREE.Mesh; } 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 ( ); } 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(null); const previousCameraPosition = useRef(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).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 ( {fixtures.map((fixture, index) => ( ))} ); } function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return ( }> ); } function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return ( }> }> ); } function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return LEGACY_GAME_ASSETS_FORCED ? : ; } function RoomMarks({ room }: { room: BossRoomDefinition }) { const rays = useRef(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 ( ); } if (pattern === "pools") { return ( {[[0, 0, 2.5], [4, -2.4, 1.5], [-4.4, 2.8, 1.25]].map(([x, z, radius], index) => ( ))} ); } return ( ); } function RoomScenery({ room }: { room: BossRoomDefinition }) { const instances = useRef(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 ( {kind === "crystal" || kind === "shard" || kind === "ice" || kind === "lightning" ? : kind === "obelisk" || kind === "grave" || kind === "moonstone" ? : kind === "coral" || kind === "reed" ? : kind === "crown" ? : kind === "tree" ? : kind === "rift" ? : kind === "boulder" || kind === "cloud" ? : kind === "scrap" ? : kind === "web" ? : } ); } function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) { return ( ); } function HockeyGoal({ z, color }: { z: number; color: string }) { const goalWidth = HOCKEY_GOAL_HALF_WIDTH * 2; return ( ); } 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 = ; return ( {wallMaterial} {wallMaterial} {[HOCKEY_ARENA_MIN_Z - 0.15, HOCKEY_ARENA_MAX_Z + 0.15].flatMap((z) => [leftGoalSideX, rightGoalSideX].map((x) => ( )))} ); } 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 ; if (fixture === "forge") return ; if (fixture === "spire") return ; if (fixture === "monolith") return ; return ; } function BlockbreakerBiomeFixtures({ biome }: { biome: BlockbreakerArenaBiome }) { const mesh = useRef(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 ( ); } 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 = ( ); return ( {[HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MAX_X].map((x) => ( ))} {[HOCKEY_ARENA_MIN_Z, HOCKEY_ARENA_MAX_Z].map((z) => ( ))} {variant === "blockbreaker" ? ( ) : ( {[-6.7, -3.35, 0, 3.35, 6.7].map((x) => ( ))} {[-10.7, -8.45, -6.2, -3.95].map((z) => ( ))} )} {[roomMinX - 0.18, roomMaxX + 0.18].map((x) => ( {wallMaterial} ))} {[roomMinZ - 0.18, roomMaxZ + 0.18].map((z) => ( {wallMaterial} ))} {[roomMinX - 0.36, roomMaxX + 0.36].map((x) => ( ))} {[roomMinZ - 0.36, roomMaxZ + 0.36].map((z) => ( ))} {variant === "blockbreaker" && } ); } 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 ( {[-11, 11].map((z) => 0 ? "#64e7ff" : "#ff7688"} transparent opacity={0.38} /> )} {[HOCKEY_PVP_ARENA_MIN_X - 0.15, HOCKEY_PVP_ARENA_MAX_X + 0.15].map((x) => ( ))} {[-HOCKEY_PVP_ARENA_MAX_Z - 0.15, HOCKEY_PVP_ARENA_MAX_Z + 0.15].flatMap((z) => sideCenters.map((x) => ( )))} ); } 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) => ( ))}; } /** 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 ; if (aetherAssaultMode) return ; if (blockbreakerMode) return ; if (hockeyMode) return ; const room = bossRoomFor(bossId); return ( {rpgBossRoom && rpgPhase && ( )} ); } 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); }