feat: Add cheat menu with hitbox toggle and car-relative ramp spawning functionality.

This commit is contained in:
KaseToatz1337
2025-12-16 17:01:25 +01:00
parent 87bf9d7d38
commit 12a4e777f7
5 changed files with 312 additions and 81 deletions

View File

@@ -6,45 +6,87 @@ import { Car } from './Car'
import { WorldBorder } from './WorldBorder' import { WorldBorder } from './WorldBorder'
import { Mesh } from 'three' import { Mesh } from 'three'
import { CheatMenu } from './CheatMenu'
import { Ramp } from './Ramp'
import { Vector3, Euler } from 'three'
export default function App() { export default function App() {
const [isDriving, setIsDriving] = useState(false) const [isDriving, setIsDriving] = useState(false)
const [showHitboxes, setShowHitboxes] = useState(false)
const [ramps, setRamps] = useState<{ id: number; position: [number, number, number]; rotation: [number, number, number] }[]>([])
const carRef = useRef<Mesh>(null) const carRef = useRef<Mesh>(null)
const spawnRamp = () => {
if (carRef.current) {
const carPos = carRef.current.position
const carRot = carRef.current.rotation
// Calculate position in front of car
const forward = new Vector3(0, 0, 1).applyEuler(carRot)
const spawnPos = carPos.clone().add(forward.multiplyScalar(10))
// Rotation: same as car but pitched up slightly? Or just flat?
// Let's make it flat but rotated to face the car's direction
// Actually, a ramp usually goes UP. If we use a box, we need to pitch it.
// Let's pitch it up by 15 degrees (approx 0.26 rad)
const spawnRot: [number, number, number] = [0.26, carRot.y, 0]
setRamps(prev => [...prev, {
id: Date.now(),
position: [spawnPos.x, 0.5, spawnPos.z], // Ensure it's on ground
rotation: spawnRot
}])
}
}
return ( return (
<Canvas camera={{ position: [0, 5, 10] }} gl={{ antialias: true }}> <>
<Sky sunPosition={[100, 20, 100]} distance={5000} /> <Canvas camera={{ position: [0, 5, 10] }} gl={{ antialias: true }}>
<ambientLight intensity={0.5} /> <Sky sunPosition={[100, 20, 100]} distance={5000} />
<pointLight position={[10, 10, 10]} /> <ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} />
{/* Ground */} {/* Ground */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]}> <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]} userData={{ isGround: true }}>
<planeGeometry args={[2500, 2500]} /> <planeGeometry args={[2500, 2500]} />
<meshStandardMaterial color="#55aaff" /> <meshStandardMaterial color="#55aaff" />
</mesh> </mesh>
<Grid <Grid
args={[2500, 2500]} args={[2500, 2500]}
fadeDistance={2000} fadeDistance={2000}
sectionSize={10} sectionSize={10}
sectionThickness={1} sectionThickness={1}
sectionColor="#ffffff" sectionColor="#ffffff"
cellSize={1} cellSize={1}
cellThickness={0.5} cellThickness={0.5}
cellColor="#aaccff" cellColor="#aaccff"
/>
<WorldBorder />
{ramps.map(ramp => (
<Ramp key={ramp.id} position={ramp.position} rotation={ramp.rotation} />
))}
<Character
isDriving={isDriving}
onEnter={() => setIsDriving(true)}
carRef={carRef}
showHitboxes={showHitboxes}
/>
<Car
ref={carRef}
isDriving={isDriving}
onExit={() => setIsDriving(false)}
showHitboxes={showHitboxes}
/>
</Canvas>
<CheatMenu
showHitboxes={showHitboxes}
setShowHitboxes={setShowHitboxes}
onSpawnRamp={spawnRamp}
/> />
</>
<WorldBorder />
<Character
isDriving={isDriving}
onEnter={() => setIsDriving(true)}
carRef={carRef}
/>
<Car
ref={carRef}
isDriving={isDriving}
onExit={() => setIsDriving(false)}
/>
</Canvas>
) )
} }

View File

@@ -2,15 +2,17 @@
import { useRef, useEffect, forwardRef, useImperativeHandle } from 'react' import { useRef, useEffect, forwardRef, useImperativeHandle } from 'react'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { Vector3, Mesh, Euler } from 'three' import { Vector3, Mesh, Euler } from 'three'
import * as THREE from 'three'
import { useGLTF } from '@react-three/drei' import { useGLTF } from '@react-three/drei'
interface CarProps { interface CarProps {
isDriving: boolean isDriving: boolean
onExit: () => void onExit: () => void
position?: [number, number, number] position?: [number, number, number]
showHitboxes?: boolean
} }
export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [5, 0.5, 5] }, ref) => { export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [5, 0.5, 5], showHitboxes = false }, ref) => {
const internalRef = useRef<Mesh>(null) const internalRef = useRef<Mesh>(null)
useImperativeHandle(ref, () => internalRef.current!) useImperativeHandle(ref, () => internalRef.current!)
@@ -26,6 +28,13 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
distance: 15 distance: 15
}) })
// Initial position setup
useEffect(() => {
if (internalRef.current) {
internalRef.current.position.set(...position)
}
}, []) // Run only once on mount
useEffect(() => { useEffect(() => {
// Reset camera yaw and pitch when entering vehicle // Reset camera yaw and pitch when entering vehicle
if (isDriving) { if (isDriving) {
@@ -77,56 +86,107 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
useFrame((_state, delta) => { useFrame((_state, delta) => {
if (!internalRef.current) return if (!internalRef.current) return
if (isDriving) { // Car Physics / Movement
// Car Physics / Movement const maxSpeed = 60
const maxSpeed = 60 const acceleration = 30
const acceleration = 30 const friction = 5
const friction = 5 const turnSpeed = 2
const turnSpeed = 2
// Input handling (only when driving)
if (isDriving) {
if (keys.current.w) speed.current += acceleration * delta if (keys.current.w) speed.current += acceleration * delta
if (keys.current.s) speed.current -= acceleration * delta if (keys.current.s) speed.current -= acceleration * delta
}
// Friction // Friction (always apply if not accelerating)
if (!keys.current.w && !keys.current.s) { if (!isDriving || (!keys.current.w && !keys.current.s)) {
if (speed.current > 0) speed.current -= friction * delta if (speed.current > 0) speed.current -= friction * delta
if (speed.current < 0) speed.current += friction * delta if (speed.current < 0) speed.current += friction * delta
if (Math.abs(speed.current) < 0.1) speed.current = 0 if (Math.abs(speed.current) < 0.1) speed.current = 0
} }
// Clamp speed // Clamp speed
speed.current = Math.max(-maxSpeed / 2, Math.min(maxSpeed, speed.current)) speed.current = Math.max(-maxSpeed / 2, Math.min(maxSpeed, speed.current))
// Steering // Steering (only when driving and moving)
if (Math.abs(speed.current) > 0.1) { if (isDriving && Math.abs(speed.current) > 0.1) {
if (keys.current.a) internalRef.current.rotation.y += turnSpeed * delta * (speed.current > 0 ? 1 : -1) if (keys.current.a) internalRef.current.rotation.y += turnSpeed * delta * (speed.current > 0 ? 1 : -1)
if (keys.current.d) internalRef.current.rotation.y -= turnSpeed * delta * (speed.current > 0 ? 1 : -1) if (keys.current.d) internalRef.current.rotation.y -= turnSpeed * delta * (speed.current > 0 ? 1 : -1)
} }
// Move // Move (always apply speed)
internalRef.current.translateZ(speed.current * delta) internalRef.current.translateZ(speed.current * delta)
// Clamp position to map boundaries with rotation-aware hitbox // Clamp position to map boundaries with rotation-aware hitbox
const mapSize = 2500 const mapSize = 2500
const halfMapSize = mapSize / 2 const halfMapSize = mapSize / 2
const carWidth = 3 const carWidth = 3
const carLength = 7 const carLength = 7
const halfWidth = carWidth / 2 const halfWidth = carWidth / 2
const halfLength = carLength / 2 const halfLength = carLength / 2
const yaw = internalRef.current.rotation.y const yaw = internalRef.current.rotation.y
const absSin = Math.abs(Math.sin(yaw)) const absSin = Math.abs(Math.sin(yaw))
const absCos = Math.abs(Math.cos(yaw)) const absCos = Math.abs(Math.cos(yaw))
const extentX = absSin * halfLength + absCos * halfWidth const extentX = absSin * halfLength + absCos * halfWidth
const extentZ = absCos * halfLength + absSin * halfWidth const extentZ = absCos * halfLength + absSin * halfWidth
const boundaryX = halfMapSize - extentX const boundaryX = halfMapSize - extentX
const boundaryZ = halfMapSize - extentZ const boundaryZ = halfMapSize - extentZ
internalRef.current.position.x = Math.max(-boundaryX, Math.min(boundaryX, internalRef.current.position.x)) internalRef.current.position.x = Math.max(-boundaryX, Math.min(boundaryX, internalRef.current.position.x))
internalRef.current.position.z = Math.max(-boundaryZ, Math.min(boundaryZ, internalRef.current.position.z)) internalRef.current.position.z = Math.max(-boundaryZ, Math.min(boundaryZ, internalRef.current.position.z))
// Ground Collision & Pitch
// Cast rays from front and back to determine pitch and height
const carPos = internalRef.current.position
const carRot = internalRef.current.rotation
const forwardDir = new Vector3(0, 0, 1).applyEuler(carRot)
const frontOffset = forwardDir.clone().multiplyScalar(2.5) // Half length approx
const backOffset = forwardDir.clone().multiplyScalar(-2.5)
const rayOriginFront = carPos.clone().add(frontOffset).add(new Vector3(0, 5, 0))
const rayOriginBack = carPos.clone().add(backOffset).add(new Vector3(0, 5, 0))
const rayDir = new Vector3(0, -1, 0)
const raycasterFront = new THREE.Raycaster(rayOriginFront, rayDir, 0, 10)
const raycasterBack = new THREE.Raycaster(rayOriginBack, rayDir, 0, 10)
const intersectsFront = raycasterFront.intersectObjects(_state.scene.children, true)
const intersectsBack = raycasterBack.intersectObjects(_state.scene.children, true)
const groundHitFront = intersectsFront.find(hit => hit.object.userData.isGround)
const groundHitBack = intersectsBack.find(hit => hit.object.userData.isGround)
let frontY = 0.5
let backY = 0.5
if (groundHitFront) frontY = groundHitFront.point.y + 0.5 // Car half height
if (groundHitBack) backY = groundHitBack.point.y + 0.5
// Average height
const targetY = (frontY + backY) / 2
// Apply gravity if in air (simple approach: just lerp to targetY)
// For ramps, we want to snap to it
internalRef.current.position.y = THREE.MathUtils.lerp(internalRef.current.position.y, targetY, 0.2)
// Calculate pitch
// atan2(deltaY, length)
const deltaY = frontY - backY
const dist = 5 // Distance between ray points
const targetPitch = Math.atan2(deltaY, dist)
// Smoothly interpolate pitch
// Note: We need to preserve the yaw, so we can't just set rotation
// But rotation.x is pitch? No, it depends on order. Default is XYZ.
// If we rotate Y (yaw), then X is pitch.
internalRef.current.rotation.x = THREE.MathUtils.lerp(internalRef.current.rotation.x, targetPitch, 0.2)
if (isDriving) {
// Camera Follow // Camera Follow
const { yaw: camYaw, pitch, distance } = cameraState.current const { yaw: camYaw, pitch, distance } = cameraState.current
@@ -164,14 +224,16 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
}) })
return ( return (
<group ref={internalRef} position={new Vector3(...position)}> <group ref={internalRef}>
<primitive object={gltf.scene} scale={1.5} rotation={[0, 0, 0]} /> <primitive object={gltf.scene} scale={1.5} rotation={[0, 0, 0]} />
{/* Debug Hitbox */} {/* Debug Hitbox */}
<mesh position={[0, 0.75, 0]}> {showHitboxes && (
<boxGeometry args={[3, 1.5, 7]} /> <mesh position={[0, 1, 0]}>
<meshBasicMaterial wireframe color="yellow" /> <boxGeometry args={[3, 2, 7]} />
</mesh> <meshBasicMaterial wireframe color="yellow" />
</mesh>
)}
</group> </group>
) )
}) })

View File

@@ -1,15 +1,17 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect } from 'react'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { Vector3, Mesh, Euler } from 'three' import { Vector3, Mesh, Euler } from 'three'
import * as THREE from 'three'
import { useGLTF } from '@react-three/drei' import { useGLTF } from '@react-three/drei'
interface CharacterProps { interface CharacterProps {
isDriving: boolean isDriving: boolean
onEnter: () => void onEnter: () => void
carRef: React.RefObject<Mesh | null> carRef: React.RefObject<Mesh | null>
showHitboxes?: boolean
} }
export function Character({ isDriving, onEnter, carRef }: CharacterProps) { export function Character({ isDriving, onEnter, carRef, showHitboxes = false }: CharacterProps) {
const ref = useRef<Mesh>(null) const ref = useRef<Mesh>(null)
const gltf = useGLTF('/character.glb') const gltf = useGLTF('/character.glb')
const { camera } = useThree() const { camera } = useThree()
@@ -137,10 +139,57 @@ export function Character({ isDriving, onEnter, carRef }: CharacterProps) {
ref.current.position.x = Math.max(-boundary, Math.min(boundary, ref.current.position.x)) ref.current.position.x = Math.max(-boundary, Math.min(boundary, ref.current.position.x))
ref.current.position.z = Math.max(-boundary, Math.min(boundary, ref.current.position.z)) ref.current.position.z = Math.max(-boundary, Math.min(boundary, ref.current.position.z))
// Car Collision
if (carRef.current) {
const carPos = carRef.current.position
const charPos = ref.current.position
// Simple box collision approximation
// Car is roughly 3x7, rotated
// We'll just use a distance check for simplicity first, or a rotated box check if needed
// Let's use a simple radius check that is slightly larger than the car
// Actually, let's do a proper local coordinate check
const localCharPos = charPos.clone().sub(carPos).applyEuler(new Euler(-carRef.current.rotation.x, -carRef.current.rotation.y, -carRef.current.rotation.z))
const carHalfWidth = 1.5 + 0.5 // + char radius
const carHalfLength = 3.5 + 0.5 // + char radius
if (Math.abs(localCharPos.x) < carHalfWidth && Math.abs(localCharPos.z) < carHalfLength) {
// Collision detected
// Push out along the axis of least penetration
const overlapX = carHalfWidth - Math.abs(localCharPos.x)
const overlapZ = carHalfLength - Math.abs(localCharPos.z)
if (overlapX < overlapZ) {
localCharPos.x = Math.sign(localCharPos.x) * carHalfWidth
} else {
localCharPos.z = Math.sign(localCharPos.z) * carHalfLength
}
// Convert back to world
const newWorldPos = localCharPos.applyEuler(carRef.current.rotation).add(carPos)
ref.current.position.x = newWorldPos.x
ref.current.position.z = newWorldPos.z
}
}
// Physics (Gravity & Jumping) // Physics (Gravity & Jumping)
const gravity = 30 const gravity = 30
const jumpForce = 12 const jumpForce = 12
const groundY = 0.5
// Raycast for ground
const rayOrigin = ref.current.position.clone().add(new Vector3(0, 2, 0))
const rayDir = new Vector3(0, -1, 0)
const raycaster = new THREE.Raycaster(rayOrigin, rayDir, 0, 10)
// Filter objects with userData.isGround
const intersects = raycaster.intersectObjects(_state.scene.children, true)
const groundHit = intersects.find(hit => hit.object.userData.isGround)
let groundY = 0.5 // Default ground
if (groundHit) {
groundY = groundHit.point.y + 0.5 // Character half-height
}
// Apply gravity // Apply gravity
velocity.current.y -= gravity * delta velocity.current.y -= gravity * delta
@@ -203,10 +252,12 @@ export function Character({ isDriving, onEnter, carRef }: CharacterProps) {
<primitive object={gltf.scene} rotation={[0, 0, 0]} /> <primitive object={gltf.scene} rotation={[0, 0, 0]} />
{/* Debug Hitbox */} {/* Debug Hitbox */}
<mesh position={[0, 0.9, 0]}> {showHitboxes && (
<cylinderGeometry args={[0.5, 0.5, 1.8, 16]} /> <mesh position={[0, 0.9, 0]}>
<meshBasicMaterial wireframe color="cyan" /> <cylinderGeometry args={[0.5, 0.5, 1.8, 16]} />
</mesh> <meshBasicMaterial wireframe color="cyan" />
</mesh>
)}
</group> </group>
) )
} }

58
src/CheatMenu.tsx Normal file
View File

@@ -0,0 +1,58 @@
import React from 'react'
interface CheatMenuProps {
showHitboxes: boolean
setShowHitboxes: (show: boolean) => void
onSpawnRamp: () => void
}
export function CheatMenu({ showHitboxes, setShowHitboxes, onSpawnRamp }: CheatMenuProps) {
return (
<div
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: '20px',
right: '20px',
background: 'rgba(0, 0, 0, 0.7)',
padding: '15px',
borderRadius: '8px',
color: 'white',
fontFamily: 'monospace',
zIndex: 1000,
border: '1px solid #444',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
<h3 style={{ margin: '0 0 5px 0', fontSize: '16px', color: '#ff00ff' }}>CHEAT MENU</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id="hitbox-toggle"
checked={showHitboxes}
onChange={(e) => setShowHitboxes(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<label htmlFor="hitbox-toggle" style={{ cursor: 'pointer', userSelect: 'none' }}>
Show Hitboxes
</label>
</div>
<button
onClick={onSpawnRamp}
style={{
background: '#444',
color: 'white',
border: '1px solid #666',
padding: '5px 10px',
borderRadius: '4px',
cursor: 'pointer',
fontFamily: 'inherit'
}}
>
Spawn Ramp
</button>
</div>
)
}

18
src/Ramp.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
import { Vector3, Euler } from 'three'
interface RampProps {
position: [number, number, number]
rotation: [number, number, number]
}
export function Ramp({ position, rotation }: RampProps) {
return (
<mesh position={new Vector3(...position)} rotation={new Euler(...rotation)} userData={{ isGround: true }}>
{/* A wedge shape using a box geometry rotated or a custom geometry */}
{/* For simplicity, let's use a box rotated to form a ramp */}
<boxGeometry args={[4, 1, 10]} />
<meshStandardMaterial color="orange" />
</mesh>
)
}