TomatitoToho's picture
Upload src/components/game/Scene.tsx with huggingface_hub
a701e52 verified
Raw
History Blame Contribute Delete
20.2 kB
'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 />
</>
)
}