feat: Add cheat menu with hitbox toggle and car-relative ramp spawning functionality.
This commit is contained in:
106
src/App.tsx
106
src/App.tsx
@@ -6,45 +6,87 @@ import { Car } from './Car'
|
||||
import { WorldBorder } from './WorldBorder'
|
||||
import { Mesh } from 'three'
|
||||
|
||||
import { CheatMenu } from './CheatMenu'
|
||||
import { Ramp } from './Ramp'
|
||||
import { Vector3, Euler } from 'three'
|
||||
|
||||
export default function App() {
|
||||
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 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 (
|
||||
<Canvas camera={{ position: [0, 5, 10] }} gl={{ antialias: true }}>
|
||||
<Sky sunPosition={[100, 20, 100]} distance={5000} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} />
|
||||
<>
|
||||
<Canvas camera={{ position: [0, 5, 10] }} gl={{ antialias: true }}>
|
||||
<Sky sunPosition={[100, 20, 100]} distance={5000} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} />
|
||||
|
||||
{/* Ground */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]}>
|
||||
<planeGeometry args={[2500, 2500]} />
|
||||
<meshStandardMaterial color="#55aaff" />
|
||||
</mesh>
|
||||
{/* Ground */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]} userData={{ isGround: true }}>
|
||||
<planeGeometry args={[2500, 2500]} />
|
||||
<meshStandardMaterial color="#55aaff" />
|
||||
</mesh>
|
||||
|
||||
<Grid
|
||||
args={[2500, 2500]}
|
||||
fadeDistance={2000}
|
||||
sectionSize={10}
|
||||
sectionThickness={1}
|
||||
sectionColor="#ffffff"
|
||||
cellSize={1}
|
||||
cellThickness={0.5}
|
||||
cellColor="#aaccff"
|
||||
<Grid
|
||||
args={[2500, 2500]}
|
||||
fadeDistance={2000}
|
||||
sectionSize={10}
|
||||
sectionThickness={1}
|
||||
sectionColor="#ffffff"
|
||||
cellSize={1}
|
||||
cellThickness={0.5}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
148
src/Car.tsx
148
src/Car.tsx
@@ -2,15 +2,17 @@
|
||||
import { useRef, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { Vector3, Mesh, Euler } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { useGLTF } from '@react-three/drei'
|
||||
|
||||
interface CarProps {
|
||||
isDriving: boolean
|
||||
onExit: () => void
|
||||
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)
|
||||
useImperativeHandle(ref, () => internalRef.current!)
|
||||
|
||||
@@ -26,6 +28,13 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
|
||||
distance: 15
|
||||
})
|
||||
|
||||
// Initial position setup
|
||||
useEffect(() => {
|
||||
if (internalRef.current) {
|
||||
internalRef.current.position.set(...position)
|
||||
}
|
||||
}, []) // Run only once on mount
|
||||
|
||||
useEffect(() => {
|
||||
// Reset camera yaw and pitch when entering vehicle
|
||||
if (isDriving) {
|
||||
@@ -77,56 +86,107 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
|
||||
useFrame((_state, delta) => {
|
||||
if (!internalRef.current) return
|
||||
|
||||
if (isDriving) {
|
||||
// Car Physics / Movement
|
||||
const maxSpeed = 60
|
||||
const acceleration = 30
|
||||
const friction = 5
|
||||
const turnSpeed = 2
|
||||
// Car Physics / Movement
|
||||
const maxSpeed = 60
|
||||
const acceleration = 30
|
||||
const friction = 5
|
||||
const turnSpeed = 2
|
||||
|
||||
// Input handling (only when driving)
|
||||
if (isDriving) {
|
||||
if (keys.current.w) speed.current += acceleration * delta
|
||||
if (keys.current.s) speed.current -= acceleration * delta
|
||||
}
|
||||
|
||||
// Friction
|
||||
if (!keys.current.w && !keys.current.s) {
|
||||
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
|
||||
}
|
||||
// Friction (always apply if not accelerating)
|
||||
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 (Math.abs(speed.current) < 0.1) speed.current = 0
|
||||
}
|
||||
|
||||
// Clamp speed
|
||||
speed.current = Math.max(-maxSpeed / 2, Math.min(maxSpeed, speed.current))
|
||||
// Clamp speed
|
||||
speed.current = Math.max(-maxSpeed / 2, Math.min(maxSpeed, speed.current))
|
||||
|
||||
// Steering
|
||||
if (Math.abs(speed.current) > 0.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)
|
||||
}
|
||||
// Steering (only when driving and moving)
|
||||
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.d) internalRef.current.rotation.y -= turnSpeed * delta * (speed.current > 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
// Move
|
||||
internalRef.current.translateZ(speed.current * delta)
|
||||
// Move (always apply speed)
|
||||
internalRef.current.translateZ(speed.current * delta)
|
||||
|
||||
// Clamp position to map boundaries with rotation-aware hitbox
|
||||
const mapSize = 2500
|
||||
const halfMapSize = mapSize / 2
|
||||
const carWidth = 3
|
||||
const carLength = 7
|
||||
const halfWidth = carWidth / 2
|
||||
const halfLength = carLength / 2
|
||||
// Clamp position to map boundaries with rotation-aware hitbox
|
||||
const mapSize = 2500
|
||||
const halfMapSize = mapSize / 2
|
||||
const carWidth = 3
|
||||
const carLength = 7
|
||||
const halfWidth = carWidth / 2
|
||||
const halfLength = carLength / 2
|
||||
|
||||
const yaw = internalRef.current.rotation.y
|
||||
const absSin = Math.abs(Math.sin(yaw))
|
||||
const absCos = Math.abs(Math.cos(yaw))
|
||||
const yaw = internalRef.current.rotation.y
|
||||
const absSin = Math.abs(Math.sin(yaw))
|
||||
const absCos = Math.abs(Math.cos(yaw))
|
||||
|
||||
const extentX = absSin * halfLength + absCos * halfWidth
|
||||
const extentZ = absCos * halfLength + absSin * halfWidth
|
||||
const extentX = absSin * halfLength + absCos * halfWidth
|
||||
const extentZ = absCos * halfLength + absSin * halfWidth
|
||||
|
||||
const boundaryX = halfMapSize - extentX
|
||||
const boundaryZ = halfMapSize - extentZ
|
||||
const boundaryX = halfMapSize - extentX
|
||||
const boundaryZ = halfMapSize - extentZ
|
||||
|
||||
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.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))
|
||||
|
||||
// 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
|
||||
const { yaw: camYaw, pitch, distance } = cameraState.current
|
||||
|
||||
@@ -164,14 +224,16 @@ export const Car = forwardRef<Mesh, CarProps>(({ isDriving, onExit, position = [
|
||||
})
|
||||
|
||||
return (
|
||||
<group ref={internalRef} position={new Vector3(...position)}>
|
||||
<group ref={internalRef}>
|
||||
<primitive object={gltf.scene} scale={1.5} rotation={[0, 0, 0]} />
|
||||
|
||||
{/* Debug Hitbox */}
|
||||
<mesh position={[0, 0.75, 0]}>
|
||||
<boxGeometry args={[3, 1.5, 7]} />
|
||||
<meshBasicMaterial wireframe color="yellow" />
|
||||
</mesh>
|
||||
{showHitboxes && (
|
||||
<mesh position={[0, 1, 0]}>
|
||||
<boxGeometry args={[3, 2, 7]} />
|
||||
<meshBasicMaterial wireframe color="yellow" />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { Vector3, Mesh, Euler } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { useGLTF } from '@react-three/drei'
|
||||
|
||||
interface CharacterProps {
|
||||
isDriving: boolean
|
||||
onEnter: () => void
|
||||
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 gltf = useGLTF('/character.glb')
|
||||
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.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)
|
||||
const gravity = 30
|
||||
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
|
||||
velocity.current.y -= gravity * delta
|
||||
@@ -203,10 +252,12 @@ export function Character({ isDriving, onEnter, carRef }: CharacterProps) {
|
||||
<primitive object={gltf.scene} rotation={[0, 0, 0]} />
|
||||
|
||||
{/* Debug Hitbox */}
|
||||
<mesh position={[0, 0.9, 0]}>
|
||||
<cylinderGeometry args={[0.5, 0.5, 1.8, 16]} />
|
||||
<meshBasicMaterial wireframe color="cyan" />
|
||||
</mesh>
|
||||
{showHitboxes && (
|
||||
<mesh position={[0, 0.9, 0]}>
|
||||
<cylinderGeometry args={[0.5, 0.5, 1.8, 16]} />
|
||||
<meshBasicMaterial wireframe color="cyan" />
|
||||
</mesh>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
58
src/CheatMenu.tsx
Normal file
58
src/CheatMenu.tsx
Normal 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
18
src/Ramp.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user