new rpg mode
This commit is contained in:
+431
-21
@@ -3,9 +3,37 @@ 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;
|
||||
@@ -170,15 +198,15 @@ function LegacyDungeonAssetInstances({
|
||||
return <DungeonMeshInstances mesh={mesh} {...props} />;
|
||||
}
|
||||
|
||||
function arenaWalls(room: BossRoomDefinition) {
|
||||
return ARENA_WALL_SEGMENTS.map((fixture) => ({
|
||||
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 }: { room: BossRoomDefinition }) {
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
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} />
|
||||
@@ -196,9 +224,9 @@ function namedMesh(scene: THREE.Object3D, name: string) {
|
||||
throw new Error(`Dungeon kit is missing mesh ${name}.`);
|
||||
}
|
||||
|
||||
function DungeonKitArchitecture({ room }: { room: BossRoomDefinition }) {
|
||||
function DungeonKitArchitecture({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
const gltf = useGameGLTF(KAYKIT_DUNGEON_KIT_URL);
|
||||
const walls = useMemo(() => arenaWalls(room), [room]);
|
||||
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),
|
||||
@@ -232,9 +260,13 @@ class DungeonAssetErrorBoundary extends Component<{
|
||||
}
|
||||
}
|
||||
|
||||
function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
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;
|
||||
@@ -252,7 +284,7 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map((fixture, index) => (
|
||||
{fixtures.map((fixture, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[fixture.position[0], room.wallHeight / 2, fixture.position[2]]}
|
||||
@@ -267,28 +299,28 @@ function RoomWallFallback({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function LegacyRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<LegacyArenaArchitecture room={room} />
|
||||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||||
<LegacyArenaArchitecture room={room} portalOpenings={portalOpenings} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function OptimizedRoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function OptimizedRoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} />}>
|
||||
<DungeonKitArchitecture room={room} />
|
||||
<DungeonAssetErrorBoundary fallback={<LegacyRoomWalls room={room} portalOpenings={portalOpenings} />}>
|
||||
<Suspense fallback={<RoomWallFallback room={room} portalOpenings={portalOpenings} />}>
|
||||
<DungeonKitArchitecture room={room} portalOpenings={portalOpenings} />
|
||||
</Suspense>
|
||||
</DungeonAssetErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomWalls({ room }: { room: BossRoomDefinition }) {
|
||||
function RoomWalls({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return LEGACY_GAME_ASSETS_FORCED
|
||||
? <LegacyRoomWalls room={room} />
|
||||
: <OptimizedRoomWalls room={room} />;
|
||||
? <LegacyRoomWalls room={room} portalOpenings={portalOpenings} />
|
||||
: <OptimizedRoomWalls room={room} portalOpenings={portalOpenings} />;
|
||||
}
|
||||
|
||||
function RoomMarks({ room }: { room: BossRoomDefinition }) {
|
||||
@@ -384,7 +416,7 @@ function RoomScenery({ room }: { room: BossRoomDefinition }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
||||
function RoomFloor({ room, portalOpenings = false }: { room: BossRoomDefinition; portalOpenings?: boolean }) {
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, -0.45, ROOM_CENTER_Z]} receiveShadow>
|
||||
@@ -401,14 +433,384 @@ function RoomFloor({ room }: { room: BossRoomDefinition }) {
|
||||
</mesh>
|
||||
<RoomMarks room={room} />
|
||||
<RoomScenery room={room} />
|
||||
<RoomWalls 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}>
|
||||
@@ -418,7 +820,15 @@ export function BossRoom() {
|
||||
<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} />
|
||||
<RoomFloor room={room} portalOpenings={rpgBossRoom} />
|
||||
{rpgBossRoom && rpgPhase && (
|
||||
<RpgRoomPortals
|
||||
entryOpen
|
||||
exitOpen={rpgPhase === "boss-cleared" || rpgPhase === "reward"}
|
||||
accent={room.accent}
|
||||
wallColor={room.wallColor}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user