Spaces:
Sleeping
Sleeping
| 'use client' | |
| import { useEffect, useRef, useCallback } from 'react' | |
| import { usePlayerStore } from '@/stores/playerStore' | |
| import { useGameStore } from '@/stores/gameStore' | |
| import { useUIStore } from '@/stores/uiStore' | |
| import { useWorldStore } from '@/stores/worldStore' | |
| import { | |
| WALK_SPEED, SPRINT_SPEED, FLY_SPEED, SNEAK_SPEED, | |
| GRAVITY, JUMP_VELOCITY, PLAYER_HEIGHT, PLAYER_EYE_HEIGHT, | |
| SEA_LEVEL, MIN_BUILD_HEIGHT, REACH_DISTANCE, | |
| BLOCK_AIR, BLOCK_DEFS, CREATIVE_BLOCKS, | |
| } from '@/engine/constants' | |
| interface KeyState { | |
| forward: boolean | |
| backward: boolean | |
| left: boolean | |
| right: boolean | |
| jump: boolean | |
| sneak: boolean | |
| sprint: boolean | |
| } | |
| export function usePlayerControls() { | |
| const keys = useRef<KeyState>({ | |
| forward: false, backward: false, left: false, right: false, | |
| jump: false, sneak: false, sprint: false, | |
| }) | |
| const isPointerLocked = useRef(false) | |
| // Block interaction via raycasting (DDA algorithm) | |
| const raycastBlock = useCallback((maxDist: number = REACH_DISTANCE) => { | |
| const { player } = usePlayerStore.getState() | |
| if (!player) return null | |
| const [px, py, pz] = player.position | |
| const [yaw, pitch] = player.rotation | |
| const ox = px | |
| const oy = py + PLAYER_EYE_HEIGHT | |
| const oz = pz | |
| 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 | |
| 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 { getBlock } = useWorldStore.getState() | |
| for (let i = 0; i < maxDist * 3; i++) { | |
| const block = getBlock(x, y, z) | |
| if (block !== BLOCK_AIR) { | |
| const def = BLOCK_DEFS[block] | |
| if (def && (def.solid || def.transparent)) { | |
| return { | |
| blockPos: [x, y, z] as [number, number, number], | |
| placePos: [prevX, prevY, prevZ] as [number, number, number], | |
| blockId: block, | |
| } | |
| } | |
| } | |
| prevX = x; prevY = y; prevZ = z | |
| if (tMaxX < tMaxY) { | |
| if (tMaxX < tMaxZ) { | |
| if (tMaxX > maxDist) return null | |
| x += stepX; tMaxX += tDeltaX | |
| } else { | |
| if (tMaxZ > maxDist) return null | |
| z += stepZ; tMaxZ += tDeltaZ | |
| } | |
| } else { | |
| if (tMaxY < tMaxZ) { | |
| if (tMaxY > maxDist) return null | |
| y += stepY; tMaxY += tDeltaY | |
| } else { | |
| if (tMaxZ > maxDist) return null | |
| z += stepZ; tMaxZ += tDeltaZ | |
| } | |
| } | |
| } | |
| return null | |
| }, []) | |
| const handleKeyDown = useCallback((e: KeyboardEvent) => { | |
| const { setShowPause } = useUIStore.getState() | |
| const { setPaused } = useGameStore.getState() | |
| switch (e.code) { | |
| case 'KeyW': keys.current.forward = true; break | |
| case 'KeyS': keys.current.backward = true; break | |
| case 'KeyA': keys.current.left = true; break | |
| case 'KeyD': keys.current.right = true; break | |
| case 'Space': keys.current.jump = true; e.preventDefault(); break | |
| case 'ShiftLeft': case 'ShiftRight': keys.current.sneak = true; break | |
| case 'ControlLeft': keys.current.sprint = true; e.preventDefault(); break | |
| case 'Escape': | |
| if (isPointerLocked.current) { | |
| document.exitPointerLock() | |
| } else { | |
| setShowPause(true); setPaused(true) | |
| } | |
| break | |
| case 'KeyF': | |
| usePlayerStore.getState().setFlying(!usePlayerStore.getState().player?.flying) | |
| break | |
| case 'KeyE': | |
| useUIStore.getState().toggleInventory() | |
| break | |
| case 'F3': | |
| e.preventDefault() | |
| useGameStore.getState().setDebug(!useGameStore.getState().debug) | |
| break | |
| case 'KeyG': | |
| const current = usePlayerStore.getState().player?.gamemode || 'creative' | |
| usePlayerStore.getState().setGamemode(current === 'creative' ? 'survival' : 'creative') | |
| break | |
| case 'KeyT': | |
| const w = useWorldStore.getState().world | |
| if (w) useWorldStore.getState().initWorld(w.seed) // Reset time by re-init | |
| break | |
| case 'Digit1': case 'Digit2': case 'Digit3': case 'Digit4': case 'Digit5': | |
| case 'Digit6': case 'Digit7': case 'Digit8': case 'Digit9': | |
| useUIStore.getState().setHotbarSlot(parseInt(e.code.slice(5)) - 1) | |
| break | |
| } | |
| }, []) | |
| const handleKeyUp = useCallback((e: KeyboardEvent) => { | |
| switch (e.code) { | |
| case 'KeyW': keys.current.forward = false; break | |
| case 'KeyS': keys.current.backward = false; break | |
| case 'KeyA': keys.current.left = false; break | |
| case 'KeyD': keys.current.right = false; break | |
| case 'Space': keys.current.jump = false; break | |
| case 'ShiftLeft': case 'ShiftRight': keys.current.sneak = false; break | |
| case 'ControlLeft': keys.current.sprint = false; break | |
| } | |
| }, []) | |
| const handleMouseMove = useCallback((e: MouseEvent) => { | |
| if (!isPointerLocked.current) return | |
| const { player, updateRotation } = usePlayerStore.getState() | |
| if (!player) return | |
| const sensitivity = 0.002 | |
| const yaw = player.rotation[0] - e.movementX * sensitivity | |
| const pitch = Math.max(-Math.PI / 2 + 0.01, | |
| Math.min(Math.PI / 2 - 0.01, player.rotation[1] - e.movementY * sensitivity)) | |
| updateRotation([yaw, pitch]) | |
| }, []) | |
| const handlePointerLockChange = useCallback(() => { | |
| isPointerLocked.current = document.pointerLockElement !== null | |
| }, []) | |
| const handleMouseDown = useCallback((e: MouseEvent) => { | |
| if (!isPointerLocked.current) return | |
| e.preventDefault() | |
| const hit = raycastBlock() | |
| if (!hit) return | |
| const { setBlock } = useWorldStore.getState() | |
| const { player } = usePlayerStore.getState() | |
| if (e.button === 0) { | |
| // Left click = break block | |
| setBlock(hit.blockPos[0], hit.blockPos[1], hit.blockPos[2], BLOCK_AIR) | |
| } else if (e.button === 2 && player) { | |
| // Right click = place block | |
| const slot = useUIStore.getState().hotbarSlot | |
| const item = player.inventory.hotbar[slot] | |
| if (item && BLOCK_DEFS[item.id]) { | |
| setBlock(hit.placePos[0], hit.placePos[1], hit.placePos[2], item.id) | |
| } | |
| } | |
| }, [raycastBlock]) | |
| const handleContextMenu = useCallback((e: Event) => e.preventDefault(), []) | |
| const handleWheel = useCallback((e: WheelEvent) => { | |
| const { hotbarSlot, setHotbarSlot } = useUIStore.getState() | |
| if (e.deltaY > 0) { | |
| setHotbarSlot((hotbarSlot + 1) % 9) | |
| } else { | |
| setHotbarSlot((hotbarSlot + 8) % 9) | |
| } | |
| }, []) | |
| const requestPointerLock = useCallback(() => { | |
| document.body.requestPointerLock() | |
| useUIStore.getState().setShowPause(false) | |
| useGameStore.getState().setPaused(false) | |
| }, []) | |
| useEffect(() => { | |
| document.addEventListener('keydown', handleKeyDown) | |
| document.addEventListener('keyup', handleKeyUp) | |
| document.addEventListener('mousemove', handleMouseMove) | |
| document.addEventListener('pointerlockchange', handlePointerLockChange) | |
| document.addEventListener('mousedown', handleMouseDown) | |
| document.addEventListener('contextmenu', handleContextMenu) | |
| document.addEventListener('wheel', handleWheel) | |
| return () => { | |
| document.removeEventListener('keydown', handleKeyDown) | |
| document.removeEventListener('keyup', handleKeyUp) | |
| document.removeEventListener('mousemove', handleMouseMove) | |
| document.removeEventListener('pointerlockchange', handlePointerLockChange) | |
| document.removeEventListener('mousedown', handleMouseDown) | |
| document.removeEventListener('contextmenu', handleContextMenu) | |
| document.removeEventListener('wheel', handleWheel) | |
| } | |
| }, []) | |
| return { keys, isPointerLocked, requestPointerLock, raycastBlock } | |
| } | |
| /** | |
| * Update player position every frame | |
| */ | |
| export function updatePlayerMovement(keys: KeyState, delta: number) { | |
| const store = usePlayerStore.getState() | |
| const player = store.player | |
| if (!player) return | |
| const { paused } = useGameStore.getState() | |
| if (paused) return | |
| const [yaw, pitch] = player.rotation | |
| const speed = player.flying ? FLY_SPEED | |
| : player.sprinting ? SPRINT_SPEED | |
| : player.sneaking ? SNEAK_SPEED | |
| : WALK_SPEED | |
| // Keyboard movement | |
| const forward = [-Math.sin(yaw), -Math.cos(yaw)] | |
| const right = [Math.cos(yaw), -Math.sin(yaw)] | |
| let moveX = 0, moveZ = 0 | |
| if (keys.forward) { moveX += forward[0]; moveZ += forward[1] } | |
| if (keys.backward) { moveX -= forward[0]; moveZ -= forward[1] } | |
| if (keys.left) { moveX -= right[0]; moveZ -= right[1] } | |
| if (keys.right) { moveX += right[0]; moveZ += right[1] } | |
| // Add mobile movement | |
| if ((window as any).__MC_MOBILE_MOVE) { | |
| moveX += (window as any).__MC_MOBILE_MOVE.x | |
| moveZ += (window as any).__MC_MOBILE_MOVE.z | |
| } | |
| const len = Math.sqrt(moveX * moveX + moveZ * moveZ) | |
| if (len > 0) { | |
| moveX = (moveX / len) * speed | |
| moveZ = (moveZ / len) * speed | |
| } | |
| let [vx, vy, vz] = player.velocity | |
| let [px, py, pz] = player.position | |
| const jumping = keys.jump || (window as any).__MC_MOBILE_JUMP | |
| const sneaking = keys.sneak || (window as any).__MC_MOBILE_SNEAK | |
| if (player.flying) { | |
| vx = moveX; vz = moveZ; vy = 0 | |
| if (jumping) vy = speed | |
| if (sneaking) vy = -speed | |
| } else { | |
| vx = moveX; vz = moveZ | |
| vy -= GRAVITY * delta | |
| if (jumping && player.onGround) vy = JUMP_VELOCITY | |
| vy = Math.max(vy, -78.4) | |
| } | |
| // Apply velocity | |
| px += vx * delta | |
| py += vy * delta | |
| pz += vz * delta | |
| // Collision detection | |
| const { getBlock } = useWorldStore.getState() | |
| if (!player.flying) { | |
| // Ground collision | |
| const blockBelow = getBlock(Math.floor(px), Math.floor(py - 0.05), Math.floor(pz)) | |
| const defBelow = BLOCK_DEFS[blockBelow] | |
| if (defBelow && defBelow.solid && vy < 0) { | |
| // Snap to top of block | |
| const groundY = Math.floor(py - 0.05) + 1 | |
| if (py - groundY < 0.5) { | |
| py = groundY | |
| vy = 0 | |
| } | |
| } | |
| // Head collision | |
| const blockAbove = getBlock(Math.floor(px), Math.floor(py + PLAYER_HEIGHT + 0.1), Math.floor(pz)) | |
| const defAbove = BLOCK_DEFS[blockAbove] | |
| if (defAbove && defAbove.solid && vy > 0) { | |
| vy = 0 | |
| } | |
| } | |
| // X/Z collision | |
| const playerWidth = 0.3 | |
| const feetY = Math.floor(py) | |
| const headY = Math.floor(py + PLAYER_HEIGHT) | |
| for (const [dx, dz] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as [number, number][]) { | |
| for (let checkY = feetY; checkY <= headY; checkY++) { | |
| const checkX = Math.floor(px + dx * playerWidth) | |
| const checkZ = Math.floor(pz + dz * playerWidth) | |
| const block = getBlock(checkX, checkY, checkZ) | |
| if (BLOCK_DEFS[block]?.solid) { | |
| if (dx !== 0) px -= vx * delta | |
| if (dz !== 0) pz -= vz * delta | |
| break | |
| } | |
| } | |
| } | |
| // World bounds | |
| if (py < MIN_BUILD_HEIGHT + PLAYER_HEIGHT) { | |
| py = MIN_BUILD_HEIGHT + PLAYER_HEIGHT; vy = 0 | |
| } | |
| // Fall damage (survival only) | |
| const onGround = !!(BLOCK_DEFS[getBlock(Math.floor(px), Math.floor(py - 0.05), Math.floor(pz))]?.solid) | |
| if (player.gamemode === 'survival' && !player.flying && player.onGround === false && onGround && vy < -15) { | |
| const fallDistance = (vy * delta) * -1 | |
| if (fallDistance > 3) { | |
| store.damage(Math.floor(fallDistance - 3)) | |
| } | |
| } | |
| store.updatePosition([px, py, pz]) | |
| store.updateVelocity([vx, vy, vz]) | |
| if (player.onGround !== onGround) { | |
| const p = usePlayerStore.getState().player | |
| if (p) usePlayerStore.setState({ player: { ...p, onGround } }) | |
| } | |
| } | |
| declare global { | |
| interface Window { | |
| __MC_MOBILE_MOVE: { x: number; z: number } | null | |
| __MC_MOBILE_JUMP: boolean | |
| __MC_MOBILE_SNEAK: boolean | |
| __MC_MOBILE_BREAK: boolean | |
| __MC_MOBILE_PLACE: boolean | |
| } | |
| } | |