Spaces:
Sleeping
Sleeping
| 'use client' | |
| import { useRef, useEffect, useState, useMemo } from 'react' | |
| import { useFrame, useThree } from '@react-three/fiber' | |
| import * as THREE from 'three' | |
| import { usePlayerStore } from '@/stores/playerStore' | |
| import { useWorldStore } from '@/stores/worldStore' | |
| import { useGameStore } from '@/stores/gameStore' | |
| import { useSettingsStore } from '@/stores/settingsStore' | |
| import { TerrainGenerator } from '@/engine/terrain/generator' | |
| import { buildChunkMesh, setAtlasMap } from '@/engine/voxel/meshBuilder' | |
| import { ChunkMesh } from '@/components/world/ChunkMesh' | |
| import { | |
| PLAYER_EYE_HEIGHT, CHUNK_WIDTH, CHUNK_DEPTH, BLOCK_AIR, | |
| BLOCK_DEFS, REACH_DISTANCE, DAY_LENGTH_TICKS, ATLAS_SIZE, | |
| TILES_PER_ROW, TEXTURE_SIZE, SEA_LEVEL, | |
| } from '@/engine/constants' | |
| // ───────────────────────────────────────────────────────────── | |
| // Shared raycast result ref – other components can import this | |
| // ───────────────────────────────────────────────────────────── | |
| export interface RaycastResult { | |
| blockPos: [number, number, number] | |
| placePos: [number, number, number] | |
| blockId: number | |
| } | |
| export const raycastResultRef: { current: RaycastResult | null } = { current: null } | |
| // ───────────────────────────────────────────────────────────── | |
| // 1. Texture Atlas Loading | |
| // ───────────────────────────────────────────────────────────── | |
| function useTextureAtlas() { | |
| const [texture, setTexture] = useState<THREE.Texture | null>(null) | |
| const [loaded, setLoaded] = useState(false) | |
| useEffect(() => { | |
| let cancelled = false | |
| const load = async () => { | |
| try { | |
| // Load atlas mapping JSON first | |
| const res = await fetch('/textures/atlas.json') | |
| const map = await res.json() | |
| setAtlasMap(map) | |
| // Then load the atlas texture image | |
| const loader = new THREE.TextureLoader() | |
| loader.load( | |
| '/textures/atlas.png', | |
| (tex) => { | |
| if (cancelled) return | |
| tex.magFilter = THREE.NearestFilter | |
| tex.minFilter = THREE.NearestFilter | |
| tex.wrapS = THREE.ClampToEdgeWrapping | |
| tex.wrapT = THREE.ClampToEdgeWrapping | |
| tex.colorSpace = THREE.SRGBColorSpace | |
| tex.generateMipmaps = false | |
| setTexture(tex) | |
| setLoaded(true) | |
| }, | |
| undefined, | |
| () => { | |
| // On error, continue without textures | |
| if (!cancelled) setLoaded(true) | |
| } | |
| ) | |
| } catch { | |
| if (!cancelled) setLoaded(true) | |
| } | |
| } | |
| load() | |
| return () => { cancelled = true } | |
| }, []) | |
| return { texture, loaded } | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 2. First-Person Camera | |
| // ───────────────────────────────────────────────────────────── | |
| function FirstPersonCamera() { | |
| const { camera } = useThree() | |
| const playerRef = useRef(usePlayerStore.getState().player) | |
| useEffect(() => { | |
| const unsub = usePlayerStore.subscribe((s) => { | |
| playerRef.current = s.player | |
| }) | |
| return unsub | |
| }, []) | |
| useFrame(() => { | |
| const player = playerRef.current | |
| if (!player) return | |
| const [px, py, pz] = player.position | |
| const [yaw, pitch] = player.rotation | |
| camera.position.set(px, py + PLAYER_EYE_HEIGHT, pz) | |
| camera.rotation.order = 'YXZ' | |
| camera.rotation.set(pitch, yaw, 0) | |
| }) | |
| return null | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 3. World Renderer – chunk loading / unloading around player | |
| // ───────────────────────────────────────────────────────────── | |
| function WorldRenderer({ atlasTexture }: { atlasTexture: THREE.Texture | null }) { | |
| const terrainGen = useRef<TerrainGenerator | null>(null) | |
| const loadedChunks = useRef<Set<string>>(new Set()) | |
| const playerRef = useRef(usePlayerStore.getState().player) | |
| const chunkDataRef = useRef<Map<string, any>>(new Map()) | |
| const [renderChunks, setRenderChunks] = useState<Array<{ key: string; data: any }>>([]) | |
| useEffect(() => { | |
| const { world } = useWorldStore.getState() | |
| if (world && !terrainGen.current) { | |
| terrainGen.current = new TerrainGenerator(world.seed) | |
| } | |
| const unsub = usePlayerStore.subscribe((s) => { | |
| playerRef.current = s.player | |
| }) | |
| return unsub | |
| }, []) | |
| useFrame(() => { | |
| const player = playerRef.current | |
| if (!player || !terrainGen.current) return | |
| const [px, , pz] = player.position | |
| const pcx = Math.floor(px / CHUNK_WIDTH) | |
| const pcz = Math.floor(pz / CHUNK_DEPTH) | |
| const rd = useSettingsStore.getState().renderDistance | |
| let needsUpdate = false | |
| const newChunks = new Map(chunkDataRef.current) | |
| let loadedThisFrame = 0 | |
| const maxPerFrame = 2 | |
| // Spiral-outward ordering for better visual loading | |
| // Load chunks in concentric rings around the player | |
| for (let ring = 0; ring <= rd && loadedThisFrame < maxPerFrame; ring++) { | |
| for (let dx = -ring; dx <= ring && loadedThisFrame < maxPerFrame; dx++) { | |
| for (let dz = -ring; dz <= ring && loadedThisFrame < maxPerFrame; dz++) { | |
| // Only process the outer ring | |
| if (ring > 0 && Math.abs(dx) < ring && Math.abs(dz) < ring) continue | |
| // Circular render distance check | |
| if (dx * dx + dz * dz > rd * rd) continue | |
| const cx = pcx + dx | |
| const cz = pcz + dz | |
| const key = `${cx},${cz}` | |
| if (!loadedChunks.current.has(key)) { | |
| const chunk = terrainGen.current.generateChunk(cx, cz) | |
| useWorldStore.getState().setChunk(key, chunk) | |
| loadedChunks.current.add(key) | |
| newChunks.set(key, chunk) | |
| needsUpdate = true | |
| loadedThisFrame++ | |
| } | |
| } | |
| } | |
| } | |
| // Unload distant chunks (circular check with 2-chunk buffer) | |
| const unloadThreshold = (rd + 2) * (rd + 2) | |
| for (const key of [...loadedChunks.current]) { | |
| const [cx, cz] = key.split(',').map(Number) | |
| const dx = cx - pcx | |
| const dz = cz - pcz | |
| if (dx * dx + dz * dz > unloadThreshold) { | |
| loadedChunks.current.delete(key) | |
| useWorldStore.getState().removeChunk(key) | |
| newChunks.delete(key) | |
| needsUpdate = true | |
| } | |
| } | |
| if (needsUpdate) { | |
| chunkDataRef.current = newChunks | |
| setRenderChunks( | |
| Array.from(newChunks.entries()).map(([key, data]) => ({ key, data })) | |
| ) | |
| } | |
| }) | |
| return ( | |
| <> | |
| {renderChunks.map(({ key, data }) => ( | |
| <ChunkMesh key={key} chunk={data} atlasTexture={atlasTexture} /> | |
| ))} | |
| </> | |
| ) | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 7. Block Highlight – wireframe cube around the targeted block | |
| // ───────────────────────────────────────────────────────────── | |
| function BlockHighlight({ position }: { position: [number, number, number] | null }) { | |
| const geometry = useMemo(() => new THREE.EdgesGeometry(new THREE.BoxGeometry(1.005, 1.005, 1.005)), []) | |
| if (!position) return null | |
| return ( | |
| <lineSegments position={position} geometry={geometry}> | |
| <lineBasicMaterial | |
| color="#000000" | |
| transparent | |
| opacity={0.6} | |
| depthTest={false} | |
| /> | |
| </lineSegments> | |
| ) | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 4. Block Raycaster – DDA voxel raycasting | |
| // ───────────────────────────────────────────────────────────── | |
| function BlockRaycaster() { | |
| const { camera } = useThree() | |
| const [highlightPos, setHighlightPos] = useState<[number, number, number] | null>(null) | |
| const resultRef = useRef<RaycastResult | null>(null) | |
| useFrame(() => { | |
| const player = usePlayerStore.getState().player | |
| if (!player) { | |
| setHighlightPos(null) | |
| raycastResultRef.current = null | |
| return | |
| } | |
| const [px, py, pz] = player.position | |
| const [yaw, pitch] = player.rotation | |
| // Eye position | |
| const ox = px | |
| const oy = py + PLAYER_EYE_HEIGHT | |
| const oz = pz | |
| // Direction from yaw/pitch (matches FirstPersonCamera YXZ rotation) | |
| const dirX = -Math.sin(yaw) * Math.cos(pitch) | |
| const dirY = -Math.sin(pitch) | |
| const dirZ = -Math.cos(yaw) * Math.cos(pitch) | |
| let x = Math.floor(ox) | |
| let y = Math.floor(oy) | |
| let z = Math.floor(oz) | |
| const stepX = dirX >= 0 ? 1 : -1 | |
| const stepY = dirY >= 0 ? 1 : -1 | |
| const stepZ = dirZ >= 0 ? 1 : -1 | |
| // Avoid division by zero with epsilon | |
| const eps = 1e-10 | |
| const tDeltaX = Math.abs(1 / (dirX || eps)) | |
| const tDeltaY = Math.abs(1 / (dirY || eps)) | |
| const tDeltaZ = Math.abs(1 / (dirZ || eps)) | |
| let tMaxX = dirX >= 0 | |
| ? (x + 1 - ox) / (dirX || eps) | |
| : (ox - x) / (-dirX || eps) | |
| let tMaxY = dirY >= 0 | |
| ? (y + 1 - oy) / (dirY || eps) | |
| : (oy - y) / (-dirY || eps) | |
| let tMaxZ = dirZ >= 0 | |
| ? (z + 1 - oz) / (dirZ || eps) | |
| : (oz - z) / (-dirZ || eps) | |
| let prevX = x, prevY = y, prevZ = z | |
| const maxDist = REACH_DISTANCE | |
| const { getBlock } = useWorldStore.getState() | |
| const maxSteps = Math.ceil(maxDist * 3) | |
| for (let i = 0; i < maxSteps; i++) { | |
| const block = getBlock(x, y, z) | |
| if (block !== BLOCK_AIR) { | |
| const def = BLOCK_DEFS[block] | |
| if (def && def.solid) { | |
| const result: RaycastResult = { | |
| blockPos: [x, y, z], | |
| placePos: [prevX, prevY, prevZ], | |
| blockId: block, | |
| } | |
| resultRef.current = result | |
| raycastResultRef.current = result | |
| setHighlightPos([x + 0.5, y + 0.5, z + 0.5]) | |
| return | |
| } | |
| } | |
| prevX = x; prevY = y; prevZ = z | |
| // Step to next voxel boundary (DDA) | |
| if (tMaxX < tMaxY) { | |
| if (tMaxX < tMaxZ) { | |
| if (tMaxX > maxDist) break | |
| x += stepX | |
| tMaxX += tDeltaX | |
| } else { | |
| if (tMaxZ > maxDist) break | |
| z += stepZ | |
| tMaxZ += tDeltaZ | |
| } | |
| } else { | |
| if (tMaxY < tMaxZ) { | |
| if (tMaxY > maxDist) break | |
| y += stepY | |
| tMaxY += tDeltaY | |
| } else { | |
| if (tMaxZ > maxDist) break | |
| z += stepZ | |
| tMaxZ += tDeltaZ | |
| } | |
| } | |
| } | |
| // Nothing hit | |
| resultRef.current = null | |
| raycastResultRef.current = null | |
| setHighlightPos(null) | |
| }) | |
| return <BlockHighlight position={highlightPos} /> | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 5. Sky – day/night cycle with proper colors and lighting | |
| // ───────────────────────────────────────────────────────────── | |
| const DAY_COLOR = new THREE.Color('#87CEEB') | |
| const SUNSET_COLOR = new THREE.Color('#FF6B35') | |
| const NIGHT_COLOR = new THREE.Color('#0A0A2E') | |
| function Sky() { | |
| const dirLightRef = useRef<THREE.DirectionalLight>(null) | |
| const sceneRef = useRef<THREE.Scene | null>(null) | |
| const tempColor = useRef(new THREE.Color()) | |
| const { scene } = useThree() | |
| useEffect(() => { | |
| sceneRef.current = scene | |
| }, [scene]) | |
| useFrame(() => { | |
| const world = useWorldStore.getState().world | |
| if (!world || !dirLightRef.current) return | |
| const timeOfDay = world.time / DAY_LENGTH_TICKS // 0-1 | |
| const sunAngle = timeOfDay * Math.PI * 2 | |
| // Sun position – rotates around the horizon | |
| const sunX = Math.cos(sunAngle) * 100 | |
| const sunY = Math.sin(sunAngle) * 100 | |
| dirLightRef.current.position.set(sunX, sunY, 50) | |
| // Sun height determines lighting intensity and sky color | |
| const sunHeight = Math.sin(sunAngle) // -1 to 1 | |
| // Light intensity | |
| const intensity = Math.max(0.05, sunHeight * 0.8 + 0.2) | |
| dirLightRef.current.intensity = intensity | |
| // Sky color interpolation | |
| const c = tempColor.current | |
| if (sunHeight > 0.2) { | |
| // Day | |
| c.copy(DAY_COLOR) | |
| } else if (sunHeight > -0.1) { | |
| // Sunset / sunrise transition | |
| const t = (sunHeight + 0.1) / 0.3 | |
| c.copy(SUNSET_COLOR).lerp(DAY_COLOR, t) | |
| } else { | |
| // Night with possible sunset tint near horizon | |
| const t = Math.max(0, (sunHeight + 0.3) / 0.2) | |
| c.copy(NIGHT_COLOR).lerp(SUNSET_COLOR, t) | |
| } | |
| // Apply sky color and fog | |
| if (sceneRef.current) { | |
| sceneRef.current.background = c | |
| const fog = sceneRef.current.fog as THREE.Fog | undefined | |
| if (fog) fog.color.copy(c) | |
| } | |
| }) | |
| // Fog distance based on render distance | |
| const rd = useSettingsStore.getState().renderDistance | |
| const fogNear = rd * CHUNK_WIDTH * 0.5 | |
| const fogFar = rd * CHUNK_WIDTH * 1.1 | |
| return ( | |
| <> | |
| <color attach="background" args={['#87CEEB']} /> | |
| <fog attach="fog" args={['#87CEEB', fogNear, fogFar]} /> | |
| <ambientLight intensity={0.3} /> | |
| <directionalLight | |
| ref={dirLightRef} | |
| position={[100, 200, 100]} | |
| intensity={0.8} | |
| color="#FFF5E0" | |
| /> | |
| <hemisphereLight args={['#87CEEB', '#4A7023', 0.25]} /> | |
| </> | |
| ) | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 6. Weather – rain and thunder particles | |
| // ───────────────────────────────────────────────────────────── | |
| const RAIN_COUNT = 3000 | |
| const THUNDER_COUNT = 200 | |
| const RAIN_SPEED = 1.5 | |
| const RAIN_AREA = 60 | |
| function WeatherParticles() { | |
| const rainRef = useRef<THREE.Points>(null) | |
| const thunderRef = useRef<THREE.Points>(null) | |
| const rainPositions = useRef<Float32Array | null>(null) | |
| const thunderPositions = useRef<Float32Array | null>(null) | |
| const thunderOpacities = useRef<Float32Array | null>(null) | |
| const playerRef = useRef(usePlayerStore.getState().player) | |
| const thunderFlash = useRef(0) | |
| useEffect(() => { | |
| const unsub = usePlayerStore.subscribe((s) => { | |
| playerRef.current = s.player | |
| }) | |
| return unsub | |
| }, []) | |
| // Initialize rain geometry | |
| const rainGeometry = useMemo(() => { | |
| const positions = new Float32Array(RAIN_COUNT * 3) | |
| for (let i = 0; i < RAIN_COUNT; i++) { | |
| positions[i * 3] = (Math.random() - 0.5) * RAIN_AREA | |
| positions[i * 3 + 1] = Math.random() * 40 | |
| positions[i * 3 + 2] = (Math.random() - 0.5) * RAIN_AREA | |
| } | |
| rainPositions.current = positions | |
| const geo = new THREE.BufferGeometry() | |
| geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) | |
| return geo | |
| }, []) | |
| // Initialize thunder geometry | |
| const thunderGeometry = useMemo(() => { | |
| const positions = new Float32Array(THUNDER_COUNT * 3) | |
| const opacities = new Float32Array(THUNDER_COUNT) | |
| for (let i = 0; i < THUNDER_COUNT; i++) { | |
| positions[i * 3] = (Math.random() - 0.5) * RAIN_AREA | |
| positions[i * 3 + 1] = Math.random() * 50 | |
| positions[i * 3 + 2] = (Math.random() - 0.5) * RAIN_AREA | |
| opacities[i] = Math.random() | |
| } | |
| thunderPositions.current = positions | |
| thunderOpacities.current = opacities | |
| const geo = new THREE.BufferGeometry() | |
| geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) | |
| return geo | |
| }, []) | |
| // Rain material – elongated vertical look via sizeAttenuation | |
| const rainMaterial = useMemo(() => { | |
| return new THREE.PointsMaterial({ | |
| color: '#AACCEE', | |
| size: 0.4, | |
| transparent: true, | |
| opacity: 0.5, | |
| depthWrite: false, | |
| sizeAttenuation: true, | |
| }) | |
| }, []) | |
| // Thunder material | |
| const thunderMaterial = useMemo(() => { | |
| return new THREE.PointsMaterial({ | |
| color: '#FFFFFF', | |
| size: 2.0, | |
| transparent: true, | |
| opacity: 0.0, | |
| depthWrite: false, | |
| sizeAttenuation: true, | |
| }) | |
| }, []) | |
| useFrame((_, delta) => { | |
| const world = useWorldStore.getState().world | |
| const isRaining = world?.weather?.raining ?? false | |
| const isThundering = world?.weather?.thundering ?? false | |
| // Update rain visibility | |
| if (rainRef.current) { | |
| rainRef.current.visible = isRaining | |
| } | |
| if (thunderRef.current) { | |
| thunderRef.current.visible = isThundering | |
| } | |
| // Animate rain | |
| if (isRaining && rainRef.current && rainPositions.current) { | |
| const player = playerRef.current | |
| const px = player ? player.position[0] : 0 | |
| const py = player ? player.position[1] : 80 | |
| const pz = player ? player.position[2] : 0 | |
| const pos = rainPositions.current | |
| for (let i = 0; i < RAIN_COUNT; i++) { | |
| // Fall | |
| pos[i * 3 + 1] -= RAIN_SPEED * delta * 60 | |
| // Reset when below player | |
| if (pos[i * 3 + 1] + py < py - 5) { | |
| pos[i * 3] = (Math.random() - 0.5) * RAIN_AREA | |
| pos[i * 3 + 1] = 40 | |
| pos[i * 3 + 2] = (Math.random() - 0.5) * RAIN_AREA | |
| } | |
| } | |
| // Center rain around player | |
| rainRef.current.position.set(px, py, pz) | |
| rainRef.current.geometry.attributes.position.needsUpdate = true | |
| } | |
| // Animate thunder | |
| if (isThundering && thunderRef.current) { | |
| const player = playerRef.current | |
| const px = player ? player.position[0] : 0 | |
| const py = player ? player.position[1] : 80 | |
| const pz = player ? player.position[2] : 0 | |
| // Flash effect | |
| thunderFlash.current -= delta * 3 | |
| if (Math.random() < 0.005) { | |
| thunderFlash.current = 0.5 + Math.random() * 0.5 | |
| } | |
| thunderMaterial.opacity = Math.max(0, thunderFlash.current) * 0.8 | |
| thunderRef.current.position.set(px, py + 20, pz) | |
| } | |
| }) | |
| return ( | |
| <> | |
| <points ref={rainRef} geometry={rainGeometry} material={rainMaterial} visible={false} /> | |
| <points ref={thunderRef} geometry={thunderGeometry} material={thunderMaterial} visible={false} /> | |
| </> | |
| ) | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // Main Scene Export | |
| // ───────────────────────────────────────────────────────────── | |
| export function GameScene() { | |
| const { texture, loaded } = useTextureAtlas() | |
| if (!loaded) { | |
| return ( | |
| <> | |
| <color attach="background" args={['#333333']} /> | |
| <ambientLight intensity={0.5} /> | |
| <mesh position={[0, 0, -2]}> | |
| <planeGeometry args={[2, 0.5]} /> | |
| <meshBasicMaterial color="#222222" /> | |
| </mesh> | |
| </> | |
| ) | |
| } | |
| return ( | |
| <> | |
| <FirstPersonCamera /> | |
| <Sky /> | |
| <WorldRenderer atlasTexture={texture} /> | |
| <BlockRaycaster /> | |
| <WeatherParticles /> | |
| </> | |
| ) | |
| } | |