'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(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(null) const loadedChunks = useRef>(new Set()) const playerRef = useRef(usePlayerStore.getState().player) const chunkDataRef = useRef>(new Map()) const [renderChunks, setRenderChunks] = useState>([]) 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 }) => ( ))} ) } // ───────────────────────────────────────────────────────────── // 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 ( ) } // ───────────────────────────────────────────────────────────── // 4. Block Raycaster – DDA voxel raycasting // ───────────────────────────────────────────────────────────── function BlockRaycaster() { const { camera } = useThree() const [highlightPos, setHighlightPos] = useState<[number, number, number] | null>(null) const resultRef = useRef(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 } // ───────────────────────────────────────────────────────────── // 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(null) const sceneRef = useRef(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 ( <> ) } // ───────────────────────────────────────────────────────────── // 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(null) const thunderRef = useRef(null) const rainPositions = useRef(null) const thunderPositions = useRef(null) const thunderOpacities = useRef(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 ( <> ) } // ───────────────────────────────────────────────────────────── // Main Scene Export // ───────────────────────────────────────────────────────────── export function GameScene() { const { texture, loaded } = useTextureAtlas() if (!loaded) { return ( <> ) } return ( <> ) }