├── .gitignore
├── .nvmrc
├── README.md
├── assets
    ├── images
    │   ├── icons
    │   │   ├── Chevron.svg
    │   │   ├── Close.svg
    │   │   ├── Controller.svg
    │   │   ├── Gear.svg
    │   │   ├── GitHub.svg
    │   │   ├── Hamburger.svg
    │   │   ├── Logo.svg
    │   │   ├── Paint.svg
    │   │   ├── Rim.svg
    │   │   ├── Suspension.svg
    │   │   ├── Tire.svg
    │   │   ├── Tool.svg
    │   │   ├── Trash.svg
    │   │   └── Vehicle.svg
    │   └── screenshot.png
    └── styles
    │   └── global.css
├── components
    ├── Actions.jsx
    ├── App.jsx
    ├── CameraControls.jsx
    ├── Canvas.jsx
    ├── Drawer.jsx
    ├── Editor.jsx
    ├── EditorSection.jsx
    ├── Environment.jsx
    ├── Header.jsx
    ├── Loader.jsx
    ├── Logo.jsx
    ├── Notification.jsx
    ├── Screenshot.jsx
    ├── Sidebar.jsx
    ├── TerrainManager.jsx
    ├── Vehicle.jsx
    ├── VehicleManager.jsx
    └── VehicleSwitcher.jsx
├── hooks
    ├── useAnimateHeight.js
    ├── useInput.js
    ├── useLoadingManager.js
    ├── useMaterialProperties.js
    └── useVehiclePhysics.js
├── index.html
├── main.jsx
├── package-lock.json
├── package.json
├── public
    ├── assets
    │   ├── images
    │   │   ├── envmap
    │   │   │   └── gainmap.webp
    │   │   └── ground
    │   │   │   ├── dirt_01.png
    │   │   │   ├── dirt_01_nrm.png
    │   │   │   ├── ground_tile.png
    │   │   │   ├── sand.jpg
    │   │   │   └── sand_normal.jpg
    │   └── models
    │   │   ├── vehicles
    │   │       ├── ford
    │   │       │   └── bronco
    │   │       │   │   └── 6g
    │   │       │   │       └── bronco.glb
    │   │       ├── jeep
    │   │       │   ├── jk
    │   │       │   │   └── jku.glb
    │   │       │   ├── xj
    │   │       │   │   └── xj.glb
    │   │       │   └── yj
    │   │       │   │   └── yj.glb
    │   │       └── toyota
    │   │       │   ├── 4runner
    │   │       │       ├── 3g
    │   │       │       │   ├── 4runner.glb
    │   │       │       │   ├── shrockworks_bumper.glb
    │   │       │       │   ├── steel_sliders.glb
    │   │       │       │   ├── stock_bumper.glb
    │   │       │       │   ├── stock_rack.glb
    │   │       │       │   ├── stock_sliders.glb
    │   │       │       │   └── whitson_rack.glb
    │   │       │       ├── 4g
    │   │       │       │   └── 4runner.glb
    │   │       │       └── 5g
    │   │       │       │   ├── 4runner.glb
    │   │       │       │   └── 4runner_late.glb
    │   │       │   ├── land_cruiser
    │   │       │       ├── j250
    │   │       │       │   └── j250.glb
    │   │       │       └── j80
    │   │       │       │   └── j80.glb
    │   │       │   └── tacoma
    │   │       │       └── 2g
    │   │       │           └── tacoma.glb
    │   │   └── wheels
    │   │       ├── rims
    │   │           ├── ar_mojave.glb
    │   │           ├── cragar_soft_8.glb
    │   │           ├── ford_bronco.glb
    │   │           ├── konig_countersteer.glb
    │   │           ├── level_8_strike_6.glb
    │   │           ├── moto_metal_mO951.glb
    │   │           ├── toyota_4runner.glb
    │   │           ├── toyota_trd.glb
    │   │           ├── xd_grenade.glb
    │   │           └── xd_machete.glb
    │   │       └── tires
    │   │           ├── bfg_at.glb
    │   │           ├── bfg_km2.glb
    │   │           ├── bfg_km3.glb
    │   │           ├── maxxis_trepador.glb
    │   │           ├── mud_grappler.glb
    │   │           ├── thornbird.glb
    │   │           └── toyo_open_country_mt.glb
    └── icon.svg
├── store
    ├── gameStore.js
    └── inputStore.js
├── vehicleConfigs.js
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
 1 | # Logs
 2 | logs
 3 | *.log
 4 | npm-debug.log*
 5 | yarn-debug.log*
 6 | yarn-error.log*
 7 | pnpm-debug.log*
 8 | lerna-debug.log*
 9 | 
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | 
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v22.6.0
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # 4x4Builder.com 🚙
 2 | 
 3 | 
 4 | 
 5 | 4x4Builder.com is an online 3D application that allows users to construct their ideal 4x4 vehicle in the browser. Whether you're a car enthusiast or just someone who enjoys exploring the world of 3D, 4x4 Builder provides an interactive and immersive experience for building your dream off-road vehicle.
 6 | 
 7 | ## Tech Stack 🛠️
 8 | 
 9 | -   React
10 | -   Three.js
11 | -   React Three Fiber
12 | -   Vite
13 | 
14 | ## Features 🌟
15 | 
16 | -   Interactive 3D environment
17 | -   Wide selection of vehicles with parts and accessories
18 | -   User-friendly interface for easy customization
19 | -   Real-time rendering and visualization
20 | 
21 | ## Quick Setup ⚙️
22 | 
23 | To get started with 4x4 Builder, follow these steps:
24 | 
25 | 1. Clone the repository:
26 | 
27 | `git clone https://github.com/theshanergy/4x4builder.git`
28 | 
29 | 2. Change directory to the project folder:
30 | 
31 | `cd 4x4builder`
32 | 
33 | 3. Install dependencies:
34 | 
35 | `npm install`
36 | 
37 | 4. Start the development server:
38 | 
39 | `npm run dev`
40 | 
41 | 5. Open your browser and navigate to `http://localhost:5173` to start building your 4x4 vehicle!
42 | 
43 | ## Contributing 🤝
44 | 
45 | Contributions are welcome - If you'd like to contribute, please feel free to submit a pull request or open an issue on GitHub.
46 | 
47 | ## Acknowledgements 🙏
48 | 
49 | -   [React](https://react.dev/)
50 | -   [Three.js](https://threejs.org/)
51 | -   [React Three Fiber](https://github.com/pmndrs/react-three-fiber)
52 | -   [Vite](https://vitejs.dev/)
53 | 
--------------------------------------------------------------------------------
/assets/images/icons/Chevron.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Close.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Controller.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Gear.svg:
--------------------------------------------------------------------------------
1 | 
4 | 
--------------------------------------------------------------------------------
/assets/images/icons/GitHub.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Hamburger.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Logo.svg:
--------------------------------------------------------------------------------
1 | 
5 | 
--------------------------------------------------------------------------------
/assets/images/icons/Paint.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Rim.svg:
--------------------------------------------------------------------------------
1 | 
4 | 
--------------------------------------------------------------------------------
/assets/images/icons/Suspension.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Tire.svg:
--------------------------------------------------------------------------------
1 | 
5 | 
--------------------------------------------------------------------------------
/assets/images/icons/Tool.svg:
--------------------------------------------------------------------------------
1 | 
5 | 
--------------------------------------------------------------------------------
/assets/images/icons/Trash.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/assets/images/icons/Vehicle.svg:
--------------------------------------------------------------------------------
1 | 
4 | 
--------------------------------------------------------------------------------
/assets/images/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/assets/images/screenshot.png
--------------------------------------------------------------------------------
/assets/styles/global.css:
--------------------------------------------------------------------------------
 1 | @import 'tailwindcss';
 2 | 
 3 | @layer utilities {
 4 |     /* Chrome, Safari and Opera */
 5 |     .scrollbar-none::-webkit-scrollbar {
 6 |         display: none;
 7 |     }
 8 | 
 9 |     .scrollbar-none {
10 |         scrollbar-width: none; /* Firefox */
11 |         -ms-overflow-style: none; /* IE and Edge */
12 |     }
13 | }
14 | 
15 | /* links */
16 | a {
17 |     @apply underline text-blue-500;
18 | }
19 | 
20 | /* forms */
21 | .field {
22 |     @apply overflow-hidden;
23 | }
24 | .field .field {
25 |     @apply inline-block space-x-5;
26 | }
27 | 
28 | /* defaults */
29 | label {
30 |     @apply block mb-2 font-medium;
31 | }
32 | input + label {
33 |     @apply inline-block;
34 | }
35 | input,
36 | button,
37 | select {
38 |     @apply p-2 max-w-full border border-stone-800 bg-stone-900 text-stone-200 text-sm rounded shadow-sm outline-none;
39 | }
40 | select {
41 |     @apply bg-zinc-900 border-none;
42 | }
43 | select option {
44 |     @apply max-w-none;
45 | }
46 | input[type='checkbox'] {
47 |     @apply mr-2 mt-1;
48 | }
49 | input[type='range'] {
50 |     @apply shadow-none;
51 | }
52 | input[type='color'] {
53 |     @apply !appearance-none p-0 bg-transparent border-none rounded cursor-pointer;
54 | }
55 | input[type='color']::-webkit-color-swatch-wrapper {
56 |     @apply p-0;
57 | }
58 | input[type='color']::-webkit-color-swatch {
59 |     @apply border-0;
60 | }
61 | 
62 | /*buttons*/
63 | button,
64 | .button {
65 |     @apply flex items-center gap-2 h-11 px-5 py-0 text-white font-medium text-sm bg-red-700 hover:bg-red-600 border-none rounded shadow-md cursor-pointer;
66 | }
67 | button.secondary,
68 | .button.secondary {
69 |     @apply bg-stone-700 hover:bg-stone-600;
70 | }
71 | button.success,
72 | .button.success {
73 |     @apply bg-green-700 hover:bg-green-600;
74 | }
75 | 
76 | /* icons */
77 | .icon {
78 |     @apply inline-block w-5 h-auto align-middle;
79 | }
80 | 
81 | /* loader animation */
82 | @-webkit-keyframes rotate {
83 |     100% {
84 |         -webkit-transform: rotateY(180deg) rotateX(0deg) rotateZ(180deg);
85 |         transform: rotateY(180deg) rotateX(0deg) rotateZ(180deg);
86 |     }
87 | }
88 | @keyframes rotate {
89 |     100% {
90 |         -webkit-transform: rotateY(180deg) rotateX(0deg) rotateZ(180deg);
91 |         transform: rotateY(180deg) rotateX(0deg) rotateZ(180deg);
92 |     }
93 | }
94 | 
--------------------------------------------------------------------------------
/components/Actions.jsx:
--------------------------------------------------------------------------------
  1 | import { useCallback } from 'react'
  2 | import useGameStore from '../store/gameStore'
  3 | 
  4 | const Actions = () => {
  5 |     // Get vehicle state from store using selectors
  6 |     const currentVehicle = useGameStore((state) => state.currentVehicle)
  7 |     const savedVehicles = useGameStore((state) => state.savedVehicles)
  8 |     const setSavedVehicles = useGameStore((state) => state.setSavedVehicles)
  9 |     const showNotification = useGameStore((state) => state.showNotification)
 10 | 
 11 |     // Save current vehicle to local storage.
 12 |     const saveVehicle = () => {
 13 |         // Get the name of the existing vehicle, if available.
 14 |         const vehicleName = savedVehicles.current ? savedVehicles[savedVehicles.current]?.name : ''
 15 | 
 16 |         // Prompt the user for a name for their vehicle.
 17 |         showNotification({
 18 |             title: 'Save Your Vehicle',
 19 |             text: 'Enter a name for your vehicle:',
 20 |             input: true,
 21 |             inputValue: vehicleName,
 22 |             showCancelButton: true,
 23 |             confirmButtonText: 'Submit',
 24 |             cancelButtonText: 'Cancel',
 25 |             onConfirm: (result) => {
 26 |                 if (result.isDismissed) {
 27 |                     return
 28 |                 }
 29 | 
 30 |                 // Get submitted vehicle name.
 31 |                 const name = result.value
 32 | 
 33 |                 // No name provided.
 34 |                 if (!name) {
 35 |                     showNotification({
 36 |                         title: 'Error',
 37 |                         text: 'Please enter a name for your vehicle.',
 38 |                         type: 'error',
 39 |                         onConfirm: () => {
 40 |                             // Reopen the original save dialog
 41 |                             saveVehicle()
 42 |                         }
 43 |                     })
 44 |                     return
 45 |                 }
 46 | 
 47 |                 // Check if we are updating an existing vehicle or saving a new one.
 48 |                 // If the name has been changed, save as a new vehicle.
 49 |                 const vehicleId = savedVehicles.current && name === vehicleName ? savedVehicles.current : Date.now()
 50 | 
 51 |                 // Create an object to represent the vehicle.
 52 |                 const vehicle = {
 53 |                     name: name,
 54 |                     config: currentVehicle,
 55 |                 }
 56 | 
 57 |                 // Save the vehicle to local storage and set current.
 58 |                 const newSavedVehicles = {
 59 |                     ...savedVehicles,
 60 |                     current: vehicleId,
 61 |                     [vehicleId]: vehicle,
 62 |                 }
 63 |                 setSavedVehicles(newSavedVehicles)
 64 | 
 65 |                 // Notify the user that the vehicle has been saved.
 66 |                 showNotification({
 67 |                     title: 'Saved!',
 68 |                     text: 'Your vehicle has been saved.',
 69 |                     type: 'success',
 70 |                 })
 71 |             },
 72 |         })
 73 |     }
 74 | 
 75 |     // Share current config.
 76 |     const shareVehicle = useCallback(() => {
 77 |         // Generate shareable URL.
 78 |         const jsonString = JSON.stringify(currentVehicle)
 79 |         const encodedConfig = encodeURIComponent(jsonString)
 80 |         const shareableUrl = `${window.location.origin}?config=${encodedConfig}`
 81 | 
 82 |         // Notify user with the link element and copy button.
 83 |         showNotification({
 84 |             title: 'Share Your Vehicle',
 85 |             text: 'Copy this link to save or share your vehicle configuration:',
 86 |             html: `Shareable link`,
 87 |             showCancelButton: true,
 88 |             confirmButtonText: 'Copy Link',
 89 |             cancelButtonText: 'Cancel',
 90 |             onConfirm: (result) => {
 91 |                 if (result.isConfirmed) {
 92 |                     // Copy the shareable URL to the clipboard.
 93 |                     navigator.clipboard
 94 |                         .writeText(shareableUrl)
 95 |                         .then(() => {
 96 |                             // Notify the user that the link has been copied.
 97 |                             showNotification({
 98 |                                 title: 'Copied!',
 99 |                                 text: 'The shareable link has been copied to your clipboard.',
100 |                                 type: 'success',
101 |                             })
102 |                         })
103 |                         .catch((error) => {
104 |                             // Handle error.
105 |                             showNotification({
106 |                                 title: 'Error',
107 |                                 text: 'An error occurred while copying the link to the clipboard.',
108 |                                 type: 'error',
109 |                             })
110 |                         })
111 |                 }
112 |             },
113 |         })
114 |     }, [currentVehicle, showNotification])
115 | 
116 |     // Trigger screenshot.
117 |     const takeScreenshot = () => {
118 |         window.dispatchEvent(new Event('takeScreenshot'))
119 |     }
120 | 
121 |     return (
122 |         
123 |             
124 |             
125 |             
126 |         
127 |     )
128 | }
129 | 
130 | export default Actions
131 | 
--------------------------------------------------------------------------------
/components/App.jsx:
--------------------------------------------------------------------------------
 1 | import { useEffect } from 'react'
 2 | 
 3 | import useGameStore from '../store/gameStore'
 4 | 
 5 | import Header from './Header'
 6 | import Sidebar from './Sidebar'
 7 | import Canvas from './Canvas'
 8 | import Actions from './Actions'
 9 | import Notification from './Notification'
10 | 
11 | export default function App() {
12 |     // Get vehicle state from game store
13 |     const loadVehicleFromUrl = useGameStore((state) => state.loadVehicleFromUrl)
14 | 
15 |     // Run once to load vehicle from URL if present
16 |     useEffect(() => {
17 |         loadVehicleFromUrl()
18 |     }, [loadVehicleFromUrl])
19 | 
20 |     return (
21 |         
22 |             
23 |             
24 |             
25 |             
26 |             
27 |         
 
28 |     )
29 | }
30 | 
--------------------------------------------------------------------------------
/components/CameraControls.jsx:
--------------------------------------------------------------------------------
 1 | import { useRef } from 'react'
 2 | import { useFrame, useThree } from '@react-three/fiber'
 3 | import { OrbitControls, PerspectiveCamera } from '@react-three/drei'
 4 | import { Vector3, Raycaster } from 'three'
 5 | 
 6 | import useGameStore from '../store/gameStore'
 7 | 
 8 | // Camera controls and chase cam logic
 9 | const CameraControls = ({ followSpeed = 0.1, minGroundDistance = 0.5 }) => {
10 |     const cameraAutoRotate = useGameStore((state) => state.cameraAutoRotate)
11 | 
12 |     //const camera = useThree((state) => state.camera)
13 |     const scene = useThree((state) => state.scene)
14 | 
15 |     const cameraRef = useRef()
16 |     const cameraControlsRef = useRef()
17 | 
18 |     const raycaster = useRef(new Raycaster())
19 |     const downDirection = useRef(new Vector3(0, -1, 0))
20 |     const cameraPosition = useRef(new Vector3())
21 | 
22 |     // Set default camera position based on aspect ratio
23 |     const isPortrait = window.innerWidth / window.innerHeight < 1
24 |     const defaultCameraPosition = isPortrait ? [-2, 1, 12] : [-4, 1, 6.5]
25 | 
26 |     useFrame(() => {
27 |         if (!cameraControlsRef.current) return
28 | 
29 |         // Smoothly update the orbit controls target
30 |         cameraControlsRef.current.target.lerp(useGameStore.getState().cameraTarget, followSpeed)
31 |         cameraControlsRef.current.update()
32 | 
33 |         // Ground avoidance logic
34 |         cameraRef.current.getWorldPosition(cameraPosition.current)
35 |         raycaster.current.set(cameraPosition.current, downDirection.current)
36 | 
37 |         // Filter for terrain objects
38 |         const terrainObjects = scene.children.filter(
39 |             (obj) => obj.name === 'TerrainManager' || obj.name.includes('Terrain') || (obj.children && obj.children.some((child) => child.name.includes('Terrain')))
40 |         )
41 | 
42 |         // Get all meshes from terrain objects
43 |         const terrainMeshes = []
44 |         terrainObjects.forEach((obj) => {
45 |             obj.traverse((child) => {
46 |                 if (child.isMesh) {
47 |                     terrainMeshes.push(child)
48 |                 }
49 |             })
50 |         })
51 | 
52 |         // Check for intersections with terrain
53 |         const intersects = raycaster.current.intersectObjects(terrainMeshes, true)
54 | 
55 |         if (intersects.length > 0) {
56 |             // Get the distance to the ground
57 |             const groundDistance = intersects[0].distance
58 | 
59 |             // If camera is too close to the ground, move it up
60 |             if (groundDistance < minGroundDistance) {
61 |                 cameraRef.current.position.y += minGroundDistance - groundDistance
62 |             }
63 |         }
64 |     })
65 | 
66 |     return (
67 |         <>
68 |             
79 |             
80 |         >
81 |     )
82 | }
83 | 
84 | export default CameraControls
85 | 
--------------------------------------------------------------------------------
/components/Canvas.jsx:
--------------------------------------------------------------------------------
 1 | import { Suspense } from 'react'
 2 | import { Canvas } from '@react-three/fiber'
 3 | import { PerformanceMonitor } from '@react-three/drei'
 4 | import { Physics } from '@react-three/rapier'
 5 | 
 6 | import useGameStore from '../store/gameStore'
 7 | import useInput from '../hooks/useInput'
 8 | import Environment from './Environment'
 9 | import CameraControls from './CameraControls'
10 | import Loader from './Loader'
11 | import VehicleManager from './VehicleManager'
12 | import Screenshot from './Screenshot'
13 | 
14 | // Canvas component
15 | const ThreeCanvas = () => {
16 |     const physicsEnabled = useGameStore((state) => state.physicsEnabled)
17 |     const setPerformanceDegraded = useGameStore((state) => state.setPerformanceDegraded)
18 |     
19 |     // Initialize input handling
20 |     useInput()
21 | 
22 |     return (
23 |         
24 |             
25 | 
26 |             
41 |         
 
42 |     )
43 | }
44 | 
45 | export default ThreeCanvas
46 | 
--------------------------------------------------------------------------------
/components/Drawer.jsx:
--------------------------------------------------------------------------------
 1 | import { useState, useRef, useLayoutEffect } from 'react'
 2 | import classNames from 'classnames'
 3 | 
 4 | import GearIcon from '../assets/images/icons/Gear.svg'
 5 | import CloseIcon from '../assets/images/icons/Close.svg'
 6 | 
 7 | // Drawer component
 8 | const Drawer = ({ id, open: controlledOpen, defaultOpen = true, onToggle, className = '', isVertical = true, children }) => {
 9 |     const drawerRef = useRef(null)
10 |     const isControlled = controlledOpen !== undefined
11 |     const [internalOpen, setInternalOpen] = useState(defaultOpen)
12 |     const [drawerSize, setDrawerSize] = useState(0)
13 |     const [dragging, setDragging] = useState(false)
14 |     const [dragStart, setDragStart] = useState(0)
15 |     const [dragTranslate, setDragTranslate] = useState(null)
16 | 
17 |     // Get open state
18 |     const open = isControlled ? controlledOpen : internalOpen
19 | 
20 |     // Get drawer size
21 |     useLayoutEffect(() => {
22 |         if (drawerRef.current) {
23 |             const { width, height } = drawerRef.current.getBoundingClientRect()
24 |             setDrawerSize(isVertical ? width : height)
25 |         }
26 |     }, [isVertical])
27 | 
28 |     // Toggle drawer
29 |     const toggleDrawer = (newState) => (isControlled ? onToggle?.(newState) : setInternalOpen(newState))
30 | 
31 |     // Get pointer coordinate
32 |     const getCoord = (e) => (isVertical ? e.clientX : e.clientY)
33 | 
34 |     // Handle pointer down
35 |     const handlePointerDown = (e) => {
36 |         if (e.button !== 0) return
37 |         e.preventDefault()
38 |         setDragging(true)
39 |         setDragStart(getCoord(e))
40 |         setDragTranslate(open ? 0 : isVertical ? -drawerSize : drawerSize)
41 |         e.currentTarget.setPointerCapture(e.pointerId)
42 |     }
43 | 
44 |     // Handle pointer move
45 |     const handlePointerMove = (e) => {
46 |         if (!dragging) return
47 |         const delta = getCoord(e) - dragStart
48 |         const base = open ? 0 : isVertical ? -drawerSize : drawerSize
49 |         const newTranslate = isVertical ? Math.max(-drawerSize, Math.min(base + delta, 0)) : Math.max(0, Math.min(base + delta, drawerSize))
50 |         setDragTranslate(newTranslate)
51 |     }
52 | 
53 |     // Handle pointer up and cancel
54 |     const handlePointerUp = (e) => {
55 |         if (!dragging) return
56 |         e.currentTarget.releasePointerCapture(e.pointerId)
57 |         const delta = getCoord(e) - dragStart
58 |         if (Math.abs(delta) < 5) toggleDrawer(!open)
59 |         else toggleDrawer(isVertical ? dragTranslate > -drawerSize / 2 : dragTranslate < drawerSize / 2)
60 |         setDragging(false)
61 |         setDragTranslate(null)
62 |     }
63 | 
64 |     // Calculate drawer transform
65 |     const translateValue = dragTranslate ?? (open ? 0 : isVertical ? -drawerSize : drawerSize)
66 | 
67 |     return (
68 |         
76 |             
85 |                 {open ?  : }
86 |             
87 |             
{children}
88 |         
 
89 |     )
90 | }
91 | 
92 | export default Drawer
93 | 
--------------------------------------------------------------------------------
/components/Editor.jsx:
--------------------------------------------------------------------------------
  1 | import vehicleConfigs from '../vehicleConfigs'
  2 | import EditorSection from './EditorSection'
  3 | import useGameStore from '../store/gameStore'
  4 | 
  5 | import VehicleIcon from '../assets/images/icons/Vehicle.svg'
  6 | import SuspensionIcon from '../assets/images/icons/Suspension.svg'
  7 | import PaintIcon from '../assets/images/icons/Paint.svg'
  8 | import RimIcon from '../assets/images/icons/Rim.svg'
  9 | import TireIcon from '../assets/images/icons/Tire.svg'
 10 | import ToolIcon from '../assets/images/icons/Tool.svg'
 11 | import GearIcon from '../assets/images/icons/Gear.svg'
 12 | 
 13 | function Editor() {
 14 |     // Get vehicle state from store using granular selectors
 15 |     const body = useGameStore((state) => state.currentVehicle?.body) || null
 16 |     const color = useGameStore((state) => state.currentVehicle?.color)
 17 |     const roughness = useGameStore((state) => state.currentVehicle?.roughness) || 0
 18 |     const lift = useGameStore((state) => state.currentVehicle?.lift)
 19 |     const wheel_offset = useGameStore((state) => state.currentVehicle?.wheel_offset) || 0
 20 |     const rim = useGameStore((state) => state.currentVehicle?.rim)
 21 |     const rim_color = useGameStore((state) => state.currentVehicle?.rim_color)
 22 |     const rim_color_secondary = useGameStore((state) => state.currentVehicle?.rim_color_secondary)
 23 |     const rim_diameter = useGameStore((state) => state.currentVehicle?.rim_diameter)
 24 |     const rim_width = useGameStore((state) => state.currentVehicle?.rim_width)
 25 |     const tire = useGameStore((state) => state.currentVehicle?.tire)
 26 |     const tire_diameter = useGameStore((state) => state.currentVehicle?.tire_diameter)
 27 |     const addons = useGameStore((state) => state.currentVehicle?.addons) || {}
 28 | 
 29 |     const setVehicle = useGameStore((state) => state.setVehicle)
 30 |     const physicsEnabled = useGameStore((state) => state.physicsEnabled)
 31 |     const setPhysicsEnabled = useGameStore((state) => state.setPhysicsEnabled)
 32 |     const cameraAutoRotate = useGameStore((state) => state.cameraAutoRotate)
 33 |     const setCameraAutoRotate = useGameStore((state) => state.setCameraAutoRotate)
 34 | 
 35 |     // Reconstruct currentVehicle for existing code
 36 |     const currentVehicle = {
 37 |         body,
 38 |         color,
 39 |         roughness,
 40 |         lift,
 41 |         wheel_offset,
 42 |         rim,
 43 |         rim_color,
 44 |         rim_color_secondary,
 45 |         rim_diameter,
 46 |         rim_width,
 47 |         tire,
 48 |         tire_diameter,
 49 |         addons,
 50 |     }
 51 | 
 52 |     // Check if current vehicle has addons.
 53 |     function addonsExist() {
 54 |         return currentVehicle.body && Object.keys(vehicleConfigs.vehicles[currentVehicle.body].addons).length > 0 ? true : false
 55 |     }
 56 | 
 57 |     // Group object by key.
 58 |     const groupObjectByKey = (object, key) => {
 59 |         const groups = {}
 60 |         // Loop through object keys.
 61 |         for (const id of Object.keys(object)) {
 62 |             const type = object[id][key]
 63 |             // Create group key if doesnt exist.
 64 |             if (!groups[type]) groups[type] = []
 65 |             // Push item to group.
 66 |             groups[type].push(id)
 67 |         }
 68 |         return groups
 69 |     }
 70 | 
 71 |     // Select list grouped by provided type.
 72 |     const GroupedSelect = ({ value, itemList, groupBy, ...restProps }) => {
 73 |         // Get list sorted by type.
 74 |         const groupedList = groupObjectByKey(itemList, groupBy)
 75 | 
 76 |         return (
 77 |             
 88 |         )
 89 |     }
 90 | 
 91 |     // Select list of different ranges in inches.
 92 |     const InchRangeSelect = ({ value, min, max, ...restProps }) => {
 93 |         let elements = []
 94 |         // Build options.
 95 |         for (let i = min; i <= max; i++) {
 96 |             elements.push(
 97 |                 
100 |             )
101 |         }
102 | 
103 |         return (
104 |             
107 |         )
108 |     }
109 | 
110 |     return (
111 |         
112 |             {/* Vehicle */}
113 |             
} defaultActive={true}>
114 |                 {/* Vehicle */}
115 |                 
116 |                     
117 |                      setVehicle({ body: e.target.value })} />
118 |                 
119 |             
120 | 
121 |             {/* Paint */}
122 |             
}>
123 |                 {/* Paint */}
124 |                 
125 |                     
126 |                         
127 |                          setVehicle({ color: e.target.value })} />
128 |                     
129 | 
130 |                     {/* Roughness */}
131 |                     
132 |                         
133 |                         
138 |                     
139 |                 
140 |             
141 | 
142 |             {/* Suspension */}
143 |             
}>
144 |                 {/* Vehicle Lift */}
145 |                 
146 |                     
147 |                      setVehicle({ lift: e.target.value })} />
148 |                 
149 | 
150 |                 {/* Wheel Offset */}
151 |                 
152 |                     
153 |                      setVehicle({ wheel_offset: e.target.value })} />
154 |                 
155 |             
156 | 
157 |             {/* Rims */}
158 |             
}>
159 |                 {/* Rim */}
160 |                 
161 |                     
162 |                      setVehicle({ rim: e.target.value })} />
163 |                 
164 | 
165 |                 {/* Primary Rim Color */}
166 |                 
167 |                     
168 |                     
175 |                 
176 | 
177 |                 {/* Secondary Rim Color */}
178 |                 
179 |                     
180 |                     
187 |                 
188 | 
189 |                 {/* Rim Size */}
190 |                 
191 |                     
192 |                         
193 |                          setVehicle({ rim_diameter: e.target.value })} />
194 |                     
195 | 
196 |                     {/* Rim Width */}
197 |                     
198 |                         
199 |                          setVehicle({ rim_width: e.target.value })} />
200 |                     
201 |                 
202 |             
203 | 
204 |             {/* Tires */}
205 |             
}>
206 |                 
207 |                     {/* Tire */}
208 |                     
209 |                         
210 |                          setVehicle({ tire: e.target.value })} />
211 |                     
212 | 
213 |                     {/* Tire Size */}
214 |                     
215 |                         
216 |                          setVehicle({ tire_diameter: e.target.value })} />
217 |                     
218 |                 
219 |             
220 | 
221 |             {/* Addons */}
222 |             {addonsExist() && (
223 |                 
}>
224 |                     {Object.keys(vehicleConfigs.vehicles[currentVehicle.body].addons).map((addon) => (
225 |                         
226 |                             
227 |                             
235 |                         
236 |                     ))}
237 |                 
238 |             )}
239 | 
240 |             {/* Scene */}
241 |             
}>
242 |                 {/* Auto Rotate */}
243 |                 
244 |                      setCameraAutoRotate(e.target.checked)} />
245 |                     
246 |                 
247 | 
248 |                 {/* Physics */}
249 |                 
250 |                      setPhysicsEnabled(e.target.checked)} />
251 |                     
252 |                 
253 |             
254 |         
 
255 |     )
256 | }
257 | 
258 | export default Editor
259 | 
--------------------------------------------------------------------------------
/components/EditorSection.jsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react'
 2 | import classNames from 'classnames'
 3 | 
 4 | const EditorSection = ({ title, icon, children, defaultActive }) => {
 5 |     const [isActive, setActiveState] = useState(defaultActive)
 6 | 
 7 |     function toggleActive() {
 8 |         setActiveState(!isActive)
 9 |     }
10 | 
11 |     return (
12 |         
13 |             
14 |                 {icon}
15 |                 {title}
16 |                 
19 |             
20 |             
{children}
21 |         
 
22 |     )
23 | }
24 | 
25 | export default EditorSection
26 | 
--------------------------------------------------------------------------------
/components/Environment.jsx:
--------------------------------------------------------------------------------
 1 | import { memo, useRef } from 'react'
 2 | import { useFrame, useThree, useLoader } from '@react-three/fiber'
 3 | import { TextureLoader, EquirectangularReflectionMapping } from 'three'
 4 | 
 5 | import useGameStore from '../store/gameStore'
 6 | import TerrainManager from './TerrainManager'
 7 | 
 8 | // Equirectangular environment map
 9 | const EquirectEnvMap = () => {
10 |     const texture = useLoader(TextureLoader, '/assets/images/envmap/gainmap.webp')
11 |     texture.mapping = EquirectangularReflectionMapping
12 |     texture.needsUpdate = true
13 | 
14 |     useThree(({ scene }) => {
15 |         scene.environment = texture
16 |     })
17 | 
18 |     return null
19 | }
20 | 
21 | // Camera target light
22 | const TargetLight = () => {
23 |     const lightRef = useRef()
24 | 
25 |     useFrame(() => {
26 |         const light = lightRef.current
27 |         const cameraTarget = useGameStore.getState().cameraTarget
28 | 
29 |         if (!light) return
30 |         light.position.set(cameraTarget.x + 10, 10, cameraTarget.z + 10)
31 |         light.target.position.copy(cameraTarget)
32 |         light.target.updateMatrixWorld()
33 |     })
34 | 
35 |     return 
36 | }
37 | 
38 | // Environment component
39 | const SceneEnvironment = memo(() => {
40 |     return (
41 |         <>
42 |             {/* Camera target light */}
43 |             
44 | 
45 |             {/* Blue sky */}
46 |             
47 | 
48 |             {/* Distant fog for depth */}
49 |             
50 | 
51 |             {/* Environment map for reflections */}
52 |             
53 | 
54 |             {/* Terrain */}
55 |             
56 |         >
57 |     )
58 | })
59 | 
60 | export default SceneEnvironment
61 | 
--------------------------------------------------------------------------------
/components/Header.jsx:
--------------------------------------------------------------------------------
 1 | import VehicleSwitcher from './VehicleSwitcher'
 2 | import GitHubIcon from '../assets/images/icons/GitHub.svg'
 3 | 
 4 | function Header() {
 5 |     return (
 6 |         
19 |     )
20 | }
21 | 
22 | export default Header
23 | 
--------------------------------------------------------------------------------
/components/Loader.jsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import useGameStore from '../store/gameStore'
 3 | import useLoadingManager from '../hooks/useLoadingManager'
 4 | 
 5 | export default function Loader() {
 6 |     const sceneLoaded = useGameStore((state) => state.sceneLoaded)
 7 | 
 8 |     // Use loading manager
 9 |     useLoadingManager()
10 | 
11 |     return (
12 |         <>
13 |             {!sceneLoaded && (
14 |                 
15 |                     
16 |                         
17 |                             
18 |                             
19 |                             
20 |                             
21 |                             
22 |                             
23 |                         
24 |                         
25 |                             
Loading
26 |                         
27 |                     
28 |                 
 
29 |             )}
30 |         >
31 |     )
32 | }
33 | 
--------------------------------------------------------------------------------
/components/Logo.jsx:
--------------------------------------------------------------------------------
 1 | import LogoIcon from '../assets/images/icons/Logo.svg'
 2 | 
 3 | const Logo = () => {
 4 |     return (
 5 |         
 6 |             
 7 |             
 8 |                 4x4builder
 9 |             
10 |         
11 |     )
12 | }
13 | 
14 | export default Logo
15 | 
--------------------------------------------------------------------------------
/components/Notification.jsx:
--------------------------------------------------------------------------------
 1 | import { useEffect, useState, useRef } from 'react'
 2 | import useGameStore from '../store/gameStore'
 3 | 
 4 | // Notification component
 5 | const Notification = () => {
 6 |     const notification = useGameStore((state) => state.notification)
 7 |     const hideNotification = useGameStore((state) => state.hideNotification)
 8 | 
 9 |     const [inputValue, setInputValue] = useState('')
10 |     const inputRef = useRef(null)
11 | 
12 |     // Reset input value when notification changes
13 |     useEffect(() => {
14 |         if (notification?.input) {
15 |             // Set default input value
16 |             setInputValue(notification.inputValue || '')
17 |             // Focus the input field when it appears
18 |             setTimeout(() => inputRef.current?.focus(), 100)
19 |         }
20 |     }, [notification])
21 | 
22 |     if (!notification) return null
23 | 
24 |     // Confirm the notification
25 |     const handleConfirm = () => {
26 |         const currentId = notification.id
27 |         if (notification.onConfirm) {
28 |             notification.onConfirm({
29 |                 isConfirmed: true,
30 |                 value: inputValue,
31 |                 isDismissed: false,
32 |             })
33 |         }
34 | 
35 |         // Hide if no new notification was shown
36 |         if (useGameStore.getState().notification?.id === currentId) {
37 |             hideNotification()
38 |         }
39 |     }
40 | 
41 |     // Cancel the notification
42 |     const handleCancel = () => {
43 |         if (notification.onCancel) {
44 |             notification.onCancel({
45 |                 isConfirmed: false,
46 |                 isDismissed: true,
47 |             })
48 |         }
49 |         hideNotification()
50 |     }
51 | 
52 |     // Keyboard shortcuts
53 |     const handleKeyDown = (e) => {
54 |         if (e.key === 'Enter') {
55 |             handleConfirm()
56 |         } else if (e.key === 'Escape') {
57 |             handleCancel()
58 |         }
59 |     }
60 | 
61 |     return (
62 |         
63 |             
 e.stopPropagation()}>
64 |                 {notification.title && 
{notification.title}
}
65 | 
66 |                 {notification.text && 
{notification.text}
}
67 | 
68 |                 {notification.html && 
}
69 | 
70 |                 {notification.input && (
71 |                     
 setInputValue(e.target.value)}
77 |                         onKeyDown={handleKeyDown}
78 |                         placeholder={notification.inputPlaceholder || ''}
79 |                     />
80 |                 )}
81 | 
82 |                 
83 |                     {notification.showCancelButton && (
84 |                         
87 |                     )}
88 | 
89 |                     
90 |                 
91 |             
92 |         
 
93 |     )
94 | }
95 | 
96 | export default Notification
97 | 
--------------------------------------------------------------------------------
/components/Screenshot.jsx:
--------------------------------------------------------------------------------
 1 | import { useEffect, useCallback } from 'react'
 2 | import { useThree } from '@react-three/fiber'
 3 | 
 4 | export default function Screenshot() {
 5 |     const { gl, scene, camera, size } = useThree()
 6 | 
 7 |     // Take screenshot.
 8 |     const takeScreenshot = useCallback(() => {
 9 |         // Fixed render size.
10 |         const aspect = 1280 / 720
11 |         camera.aspect = aspect
12 |         camera.updateProjectionMatrix()
13 |         gl.setSize(1280, 720)
14 |         gl.render(scene, camera)
15 | 
16 |         // Download image.
17 |         var link = document.createElement('a')
18 |         link.download = '4x4builder.png'
19 |         link.href = gl.domElement.toDataURL('image/png')
20 |         link.click()
21 | 
22 |         // Restore canvas size.
23 |         camera.aspect = size.width / size.height
24 |         camera.updateProjectionMatrix()
25 |         gl.setSize(size.width, size.height)
26 |         gl.render(scene, camera)
27 |     }, [gl, scene, camera, size])
28 | 
29 | 
30 |     // Listen for screenshot event.
31 |     useEffect(() => {
32 |         window.addEventListener('takeScreenshot', takeScreenshot)
33 |         return () => window.removeEventListener('takeScreenshot', takeScreenshot)
34 |     }, [takeScreenshot])
35 | 
36 |     return null
37 | }
38 | 
--------------------------------------------------------------------------------
/components/Sidebar.jsx:
--------------------------------------------------------------------------------
 1 | import { useState, useEffect } from 'react'
 2 | 
 3 | import Drawer from './Drawer'
 4 | import Editor from './Editor'
 5 | import Logo from './Logo'
 6 | 
 7 | // Sidebar component
 8 | const Sidebar = () => {
 9 |     const [isVertical, setIsVertical] = useState(window.innerWidth / window.innerHeight >= 1)
10 | 
11 |     useEffect(() => {
12 |         const handleResize = () => {
13 |             setIsVertical(window.innerWidth / window.innerHeight >= 1)
14 |         }
15 | 
16 |         window.addEventListener('resize', handleResize)
17 |         return () => window.removeEventListener('resize', handleResize)
18 |     }, [])
19 | 
20 |     return (
21 |         
25 |     )
26 | }
27 | 
28 | export default Sidebar
29 | 
--------------------------------------------------------------------------------
/components/TerrainManager.jsx:
--------------------------------------------------------------------------------
  1 | import { useState, useRef, useMemo } from 'react'
  2 | import { useFrame } from '@react-three/fiber'
  3 | import { useTexture } from '@react-three/drei'
  4 | import { RigidBody, HeightfieldCollider } from '@react-three/rapier'
  5 | import { Vector2, RepeatWrapping, PlaneGeometry, Vector3 } from 'three'
  6 | import { Noise } from 'noisejs'
  7 | 
  8 | import useGameStore from '../store/gameStore'
  9 | 
 10 | // Default terrain configuration
 11 | const DEFAULT_TERRAIN_CONFIG = {
 12 |     viewDistance: 160,
 13 |     tileSize: 32,
 14 |     resolution: 16,
 15 |     smoothness: 15,
 16 |     maxHeight: 4,
 17 | }
 18 | 
 19 | // TerrainTile component
 20 | const TerrainTile = ({ position, tileSize, resolution, smoothness, maxHeight, noise }) => {
 21 |     // Load texture
 22 |     const textures = useTexture({
 23 |         map: 'assets/images/ground/sand.jpg',
 24 |         normalMap: 'assets/images/ground/sand_normal.jpg',
 25 |     })
 26 |     // Apply texture settings
 27 |     useMemo(() => {
 28 |         textures.map.wrapS = textures.map.wrapT = RepeatWrapping
 29 |         textures.map.repeat.set(tileSize, tileSize)
 30 |         textures.normalMap.wrapS = textures.normalMap.wrapT = RepeatWrapping
 31 |         textures.normalMap.repeat.set(tileSize / 3, tileSize / 3)
 32 |     }, [textures])
 33 | 
 34 |     // Generate heights
 35 |     const heights = useMemo(() => {
 36 |         const values = []
 37 |         const positions = new Float32Array((resolution + 1) * (resolution + 1) * 3)
 38 |         const flatAreaRadiusSq = (tileSize * 0.5) ** 2
 39 |         const transitionEndDistSq = (tileSize * 2) ** 2
 40 |         const step = tileSize / resolution
 41 |         const tileX = Math.floor(position[0] / tileSize)
 42 |         const tileZ = Math.floor(position[2] / tileSize)
 43 |         const isCenterTile = tileX >= -1 && tileX <= 0 && tileZ >= -1 && tileZ <= 0
 44 | 
 45 |         for (let i = 0; i <= resolution; i++) {
 46 |             for (let j = 0; j <= resolution; j++) {
 47 |                 const worldX = position[0] + i * step - tileSize / 2
 48 |                 const worldZ = position[2] + j * step - tileSize / 2
 49 | 
 50 |                 const distSq = worldX * worldX + worldZ * worldZ
 51 | 
 52 |                 let height = 0
 53 |                 if (distSq >= flatAreaRadiusSq) {
 54 |                     const noiseValue = noise.perlin2(worldX / smoothness, worldZ / smoothness)
 55 |                     const normalizedHeight = (noiseValue + 1) / 2
 56 |                     if (isCenterTile || distSq < transitionEndDistSq) {
 57 |                         const t = (Math.sqrt(distSq) - Math.sqrt(flatAreaRadiusSq)) / (Math.sqrt(transitionEndDistSq) - Math.sqrt(flatAreaRadiusSq))
 58 |                         height = normalizedHeight * (t * t * (3 - 2 * t))
 59 |                     } else {
 60 |                         height = normalizedHeight
 61 |                     }
 62 |                 }
 63 |                 values.push(height)
 64 | 
 65 |                 const vertIndex = (i + (resolution + 1) * j) * 3
 66 |                 positions[vertIndex] = (i / resolution) * tileSize - tileSize / 2
 67 |                 positions[vertIndex + 1] = height * maxHeight
 68 |                 positions[vertIndex + 2] = (j / resolution) * tileSize - tileSize / 2
 69 |             }
 70 |         }
 71 | 
 72 |         return { values, positions }
 73 |     }, [position, tileSize, resolution, smoothness, noise, maxHeight])
 74 | 
 75 |     // Create geometry for terrain mesh
 76 |     const geometry = useMemo(() => {
 77 |         const geom = new PlaneGeometry(tileSize, tileSize, resolution, resolution)
 78 |         geom.getAttribute('position').array.set(heights.positions)
 79 |         geom.computeVertexNormals()
 80 |         return geom
 81 |     }, [heights, tileSize, resolution])
 82 | 
 83 |     // Set collider arguments
 84 |     const colliderArgs = useMemo(() => {
 85 |         return [resolution, resolution, heights.values, { x: tileSize, y: maxHeight, z: tileSize }]
 86 |     }, [resolution, heights, tileSize, maxHeight])
 87 | 
 88 |     return (
 89 |         
 90 |             
 91 |             
 92 |                 
 93 |             
 94 |         
 95 |     )
 96 | }
 97 | 
 98 | // Main TerrainManager component
 99 | const TerrainManager = () => {
100 |     const { viewDistance, tileSize, resolution, smoothness, maxHeight } = DEFAULT_TERRAIN_CONFIG
101 |     const [activeTiles, setActiveTiles] = useState([])
102 |     const loadedTiles = useRef(new Map())
103 |     const tilesInViewDistance = Math.ceil(viewDistance / tileSize)
104 |     const lastTileCoord = useRef({ x: null, z: null })
105 | 
106 |     // Generate noise instance
107 |     const noise = useMemo(() => new Noise(123), [])
108 | 
109 |     // Update tiles based on camera target position
110 |     useFrame(() => {
111 |         // Use camera target position if available, otherwise fall back to scene center
112 |         const centerPosition = useGameStore.getState().cameraTarget
113 |         const currentTileX = Math.floor(centerPosition.x / tileSize)
114 |         const currentTileZ = Math.floor(centerPosition.z / tileSize)
115 | 
116 |         // Only update tiles if the center position moved to a new tile
117 |         if (currentTileX === lastTileCoord.current.x && currentTileZ === lastTileCoord.current.z) {
118 |             return
119 |         }
120 |         lastTileCoord.current = { x: currentTileX, z: currentTileZ }
121 | 
122 |         const newActiveTiles = []
123 |         const tilesToLoad = new Map()
124 | 
125 |         // Check which tiles should be active
126 |         for (let x = -tilesInViewDistance; x <= tilesInViewDistance; x++) {
127 |             for (let z = -tilesInViewDistance; z <= tilesInViewDistance; z++) {
128 |                 const tileX = currentTileX + x
129 |                 const tileZ = currentTileZ + z
130 |                 const position = [tileX * tileSize, 0, tileZ * tileSize]
131 |                 const tileKey = `${tileX},${tileZ}`
132 | 
133 |                 // Calculate distance from center position to tile center
134 |                 const tileCenter = new Vector2(tileX * tileSize + tileSize / 2, tileZ * tileSize + tileSize / 2)
135 |                 const distanceToTile = new Vector2(centerPosition.x, centerPosition.z).distanceTo(tileCenter)
136 | 
137 |                 // Add tile if within view distance
138 |                 if (distanceToTile <= viewDistance) {
139 |                     newActiveTiles.push(tileKey)
140 |                     if (!loadedTiles.current.has(tileKey)) {
141 |                         tilesToLoad.set(tileKey, position)
142 |                     }
143 |                 }
144 |             }
145 |         }
146 | 
147 |         // Update loaded tiles if changes detected
148 |         if (tilesToLoad.size > 0 || loadedTiles.current.size !== newActiveTiles.length) {
149 |             // Create new map with only active tiles
150 |             const updatedLoadedTiles = new Map()
151 | 
152 |             // Keep existing tiles that are still active
153 |             for (const key of newActiveTiles) {
154 |                 if (loadedTiles.current.has(key)) {
155 |                     updatedLoadedTiles.set(key, loadedTiles.current.get(key))
156 |                 }
157 |             }
158 | 
159 |             // Add new tiles
160 |             tilesToLoad.forEach((position, key) => {
161 |                 updatedLoadedTiles.set(key, position)
162 |             })
163 | 
164 |             // Update state
165 |             loadedTiles.current = updatedLoadedTiles
166 |             setActiveTiles([...updatedLoadedTiles.entries()])
167 |         }
168 |     })
169 | 
170 |     return (
171 |         
172 |             {activeTiles.map(([key, position]) => (
173 |                 
174 |             ))}
175 |         
176 |     )
177 | }
178 | 
179 | export default TerrainManager
180 | 
--------------------------------------------------------------------------------
/components/Vehicle.jsx:
--------------------------------------------------------------------------------
  1 | import { memo, useMemo, useEffect, useRef } from 'react'
  2 | import { useFrame } from '@react-three/fiber'
  3 | import { RigidBody, CuboidCollider } from '@react-three/rapier'
  4 | import { useGLTF, Gltf } from '@react-three/drei'
  5 | import { Vector3 } from 'three'
  6 | 
  7 | import useGameStore from '../store/gameStore'
  8 | import vehicleConfigs from '../vehicleConfigs'
  9 | import useAnimateHeight from '../hooks/useAnimateHeight'
 10 | import useVehiclePhysics from '../hooks/useVehiclePhysics'
 11 | import useMaterialProperties from '../hooks/useMaterialProperties'
 12 | 
 13 | // Calculate point on line (a to b, at length).
 14 | const linePoint = (a, b, length) => {
 15 |     let dir = b.clone().sub(a).normalize().multiplyScalar(length)
 16 |     return a.clone().add(dir)
 17 | }
 18 | 
 19 | // Wheels.
 20 | const Wheels = memo(({ rim, rim_diameter, rim_width, rim_color, rim_color_secondary, tire, tire_diameter, color, roughness, wheelPositions, wheelRefs }) => {
 21 |     const { setObjectMaterials } = useMaterialProperties()
 22 | 
 23 |     // Load models.
 24 |     const rimGltf = useGLTF(vehicleConfigs.wheels.rims[rim].model)
 25 |     const tireGltf = useGLTF(vehicleConfigs.wheels.tires[tire].model)
 26 | 
 27 |     // Scale tires.
 28 |     const tireGeometry = useMemo(() => {
 29 |         // Determine y scale as a percentage of width.
 30 |         const wheelWidth = (rim_width * 2.54) / 100
 31 |         const wheelWidthScale = wheelWidth / vehicleConfigs.wheels.tires[tire].width
 32 | 
 33 |         const tireOD = vehicleConfigs.wheels.tires[tire].od / 2
 34 |         const tireID = vehicleConfigs.wheels.tires[tire].id / 2
 35 | 
 36 |         const newOd = (tire_diameter * 2.54) / 10 / 2
 37 |         const newId = (rim_diameter * 2.54) / 10 / 2
 38 | 
 39 |         // Create a copy of the original geometry.
 40 |         const geometry = tireGltf.scene.children[0].geometry.clone()
 41 | 
 42 |         // Scale to match wheel width.
 43 |         geometry.scale(1, 1, wheelWidthScale)
 44 | 
 45 |         // Get position attributes.
 46 |         const positionAttribute = geometry.getAttribute('position')
 47 |         const positionArray = positionAttribute.array
 48 | 
 49 |         // Loop through vertices.
 50 |         for (var i = 0, l = positionAttribute.count; i < l; i++) {
 51 |             // Start vector.
 52 |             let startVector = new Vector3().fromBufferAttribute(positionAttribute, i)
 53 | 
 54 |             // Center vector.
 55 |             let centerVector = new Vector3(0, 0, startVector.z)
 56 | 
 57 |             // Distance from center.
 58 |             let centerDist = centerVector.distanceTo(startVector)
 59 | 
 60 |             // Distance from rim.
 61 |             let rimDist = centerDist - tireID
 62 | 
 63 |             // Percentage from rim.
 64 |             let percentOut = rimDist / (tireOD - tireID)
 65 | 
 66 |             // New distance from center.
 67 |             let newRimDist = (percentOut * (newOd - newId) + newId) / 10
 68 | 
 69 |             // End vector.
 70 |             let setVector = linePoint(centerVector, startVector, newRimDist)
 71 | 
 72 |             // Set x,y
 73 |             positionArray[i * 3] = setVector.x
 74 |             positionArray[i * 3 + 1] = setVector.y
 75 |         }
 76 | 
 77 |         return geometry
 78 |     }, [tireGltf.scene.children, rim_diameter, rim_width, tire, tire_diameter])
 79 | 
 80 |     // Calculate rim scale as a percentage of diameter.
 81 |     const odScale = useMemo(() => ((rim_diameter * 2.54) / 100 + 0.03175) / vehicleConfigs.wheels.rims[rim].od, [rim, rim_diameter])
 82 | 
 83 |     // Calculate rim width.
 84 |     const widthScale = useMemo(() => (rim_width * 2.54) / 100 / vehicleConfigs.wheels.rims[rim].width, [rim, rim_width])
 85 | 
 86 |     // Set rim color.
 87 |     useEffect(() => {
 88 |         setObjectMaterials(rimGltf.scene, color, roughness, rim_color, rim_color_secondary)
 89 |     }, [rimGltf.scene, setObjectMaterials, rim_color, rim_color_secondary, color, roughness])
 90 | 
 91 |     return (
 92 |         
 93 |             {wheelPositions.map(({ key, rotation, ...transform }, index) => (
 94 |                 
 95 |                     {/* Add an inner group with the correct visual rotation */}
 96 |                     
 97 |                         
 98 |                         
 99 |                             
100 |                         
101 |                     
102 |                 
103 |             ))}
104 |         
105 |     )
106 | })
107 | 
108 | // Body.
109 | const Body = memo(({ id, height, color, roughness, addons }) => {
110 |     const vehicle = useRef()
111 |     const { setObjectMaterials } = useMaterialProperties()
112 | 
113 |     // Set body color.
114 |     useEffect(() => {
115 |         setObjectMaterials(vehicle.current, color, roughness)
116 |     }, [setObjectMaterials, color, roughness, addons])
117 | 
118 |     // Build array of addon paths.
119 |     const addonPaths = useMemo(() => {
120 |         return Object.entries(addons)
121 |             .filter(([type, value]) => vehicleConfigs.vehicles[id]['addons'][type]?.['options'][value])
122 |             .map(([type, value]) => {
123 |                 // Return path.
124 |                 return vehicleConfigs.vehicles[id]['addons'][type]['options'][value]['model']
125 |             })
126 |     }, [id, addons])
127 | 
128 |     // Animate height.
129 |     useAnimateHeight(vehicle, height, height + 0.1)
130 | 
131 |     return (
132 |         
133 |             
134 |             {addonPaths.length ? (
135 |                 
136 |                     {addonPaths.map((addon) => (
137 |                         
138 |                     ))}
139 |                 
140 |             ) : null}
141 |         
142 |     )
143 | })
144 | 
145 | // Vehicle component with physics
146 | const Vehicle = (props) => {
147 |     // Get vehicle properties from props or defaults
148 |     const { body, color, roughness, lift, wheel_offset, rim, rim_diameter, rim_width, rim_color, rim_color_secondary, tire, tire_diameter, addons } = {
149 |         ...vehicleConfigs.defaults,
150 |         ...props,
151 |     }
152 | 
153 |     // Get vehicle store
154 |     const setCameraTarget = useGameStore((state) => state.setCameraTarget)
155 | 
156 |     const chassisRef = useRef(null)
157 |     const wheelRefs = [useRef(null), useRef(null), useRef(null), useRef(null)]
158 | 
159 |     // Get wheel (axle) height
160 |     const axleHeight = useMemo(() => (tire_diameter * 2.54) / 100 / 2, [tire_diameter])
161 | 
162 |     // Get lift height in meters
163 |     const liftHeight = useMemo(() => ((lift || 0) * 2.54) / 100, [lift])
164 | 
165 |     // Get vehicle height
166 |     const vehicleHeight = useMemo(() => axleHeight + liftHeight, [axleHeight, liftHeight])
167 | 
168 |     // Get wheel offset and wheelbase
169 |     const offset = vehicleConfigs.vehicles[body]['wheel_offset'] + parseFloat(wheel_offset)
170 |     const wheelbase = vehicleConfigs.vehicles[body]['wheelbase']
171 | 
172 |     // Get wheel rotation
173 |     const rotation = (Math.PI * 90) / 180
174 | 
175 |     // Set wheel positions
176 |     const wheelPositions = [
177 |         { key: 'FL', name: 'FL', position: [offset, axleHeight, wheelbase / 2], rotation: [0, rotation, 0] },
178 |         { key: 'FR', name: 'FR', position: [-offset, axleHeight, wheelbase / 2], rotation: [0, -rotation, 0] },
179 |         { key: 'RL', name: 'RL', position: [offset, axleHeight, -wheelbase / 2], rotation: [0, rotation, 0] },
180 |         { key: 'RR', name: 'RR', position: [-offset, axleHeight, -wheelbase / 2], rotation: [0, -rotation, 0] },
181 |     ]
182 | 
183 |     // Create wheel configurations
184 |     const physicsWheels = useMemo(() => {
185 |         return wheelPositions.map((wheel, i) => ({
186 |             ref: wheelRefs[i],
187 |             axleCs: new Vector3(1, 0, 0),
188 |             position: new Vector3(...wheel.position),
189 |             suspensionDirection: new Vector3(0, -1, 0),
190 |             maxSuspensionTravel: 0.3,
191 |             suspensionRestLength: 0.1,
192 |             suspensionStiffness: 28,
193 |             radius: (tire_diameter * 2.54) / 100 / 2,
194 |         }))
195 |     }, [offset, axleHeight, wheelbase, tire_diameter])
196 | 
197 |     // Use vehicle physics
198 |     useVehiclePhysics(chassisRef, physicsWheels)
199 | 
200 |     // Update camera target each frame
201 |     useFrame(() => {
202 |         if (chassisRef.current) {
203 |             // Get chassis position
204 |             const { x, y, z } = chassisRef.current.translation()
205 |             // Set camera target
206 |             setCameraTarget(x, y + 0.95, z)
207 |         }
208 |     })
209 | 
210 |     // Collider props
211 |     const colliderArgs = useMemo(() => [1, 0.5, wheelbase / 2 + axleHeight], [wheelbase, axleHeight])
212 |     const colliderPosition = useMemo(() => [0, 1, 0], [])
213 | 
214 |     return (
215 |         
216 |             
217 |             
218 |                 
219 |                 
232 |             
233 |         
234 |     )
235 | }
236 | 
237 | export default Vehicle
238 | 
--------------------------------------------------------------------------------
/components/VehicleManager.jsx:
--------------------------------------------------------------------------------
 1 | import useGameStore from '../store/gameStore'
 2 | import Vehicle from './Vehicle'
 3 | 
 4 | // Vehicle manager component
 5 | const VehicleManager = () => {
 6 |     // Get current vehicle config
 7 |     const body = useGameStore((state) => state.currentVehicle.body)
 8 |     const color = useGameStore((state) => state.currentVehicle.color)
 9 |     const roughness = useGameStore((state) => state.currentVehicle.roughness)
10 |     const lift = useGameStore((state) => state.currentVehicle.lift)
11 |     const wheel_offset = useGameStore((state) => state.currentVehicle.wheel_offset)
12 |     const rim = useGameStore((state) => state.currentVehicle.rim)
13 |     const rim_diameter = useGameStore((state) => state.currentVehicle.rim_diameter)
14 |     const rim_width = useGameStore((state) => state.currentVehicle.rim_width)
15 |     const rim_color = useGameStore((state) => state.currentVehicle.rim_color)
16 |     const rim_color_secondary = useGameStore((state) => state.currentVehicle.rim_color_secondary)
17 |     const tire = useGameStore((state) => state.currentVehicle.tire)
18 |     const tire_diameter = useGameStore((state) => state.currentVehicle.tire_diameter)
19 |     const addons = useGameStore((state) => state.currentVehicle.addons)
20 | 
21 |     return (
22 |         <>
23 |             
38 |         >
39 |     )
40 | }
41 | 
42 | export default VehicleManager
43 | 
--------------------------------------------------------------------------------
/components/VehicleSwitcher.jsx:
--------------------------------------------------------------------------------
 1 | import { useRef, useState, useEffect } from 'react'
 2 | import classNames from 'classnames'
 3 | 
 4 | import useGameStore from '../store/gameStore'
 5 | import ChevronIcon from '../assets/images/icons/Chevron.svg'
 6 | import TrashIcon from '../assets/images/icons/Trash.svg'
 7 | 
 8 | // Vehicle switcher component
 9 | const VehicleSwitcher = () => {
10 |     const setVehicle = useGameStore((state) => state.setVehicle)
11 |     const savedVehicles = useGameStore((state) => state.savedVehicles)
12 |     const setSavedVehicles = useGameStore((state) => state.setSavedVehicles)
13 |     const deleteSavedVehicle = useGameStore((state) => state.deleteSavedVehicle)
14 | 
15 |     const dropdownRef = useRef(null)
16 |     const [showDropdown, setShowDropdown] = useState(false)
17 | 
18 |     const handleVehicleSelect = (vehicleId) => {
19 |         setSavedVehicles((prev) => ({ ...prev, current: vehicleId }))
20 |         setVehicle(savedVehicles[vehicleId]?.config)
21 |         setShowDropdown(false)
22 |     }
23 | 
24 |     const handleVehicleDelete = (event, vehicleId) => {
25 |         event.stopPropagation()
26 |         deleteSavedVehicle(vehicleId)
27 |         setShowDropdown(false)
28 |     }
29 | 
30 |     useEffect(() => {
31 |         const handleClickOutside = (event) => {
32 |             if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
33 |                 setShowDropdown(false)
34 |             }
35 |         }
36 |         document.addEventListener('mousedown', handleClickOutside)
37 |         return () => document.removeEventListener('mousedown', handleClickOutside)
38 |     }, [])
39 | 
40 |     const savedVehicleList = Object.entries(savedVehicles).filter(([id]) => id !== 'current')
41 |     const currentVehicleId = savedVehicles.current || null
42 |     const currentVehicleTitle = currentVehicleId ? savedVehicles[currentVehicleId]?.name : 'Vehicles'
43 | 
44 |     return (
45 |         <>
46 |             {savedVehicleList.length > 0 ? (
47 |                 
48 |                     
 setShowDropdown(!showDropdown)}>
49 |                         {currentVehicleTitle}
50 |                         {savedVehicleList.length > 1 ? (
51 |                             
52 |                         ) : (
53 |                              handleVehicleDelete(e, currentVehicleId)}
56 |                             />
57 |                         )}
58 |                     
59 |                     {showDropdown && savedVehicleList.length > 1 && (
60 |                         
61 |                             {savedVehicleList.map(([vehicleId, vehicle]) => (
62 |                                 -  handleVehicleSelect(vehicleId)}>
69 |                                     {vehicle.name}
70 |                                      handleVehicleDelete(e, vehicleId)}
73 |                                     />
74 |                                 
 
75 |                             ))}
76 |                         
77 |                     )}
78 |                 
 
79 |             ) : null}
80 |         >
81 |     )
82 | }
83 | 
84 | export default VehicleSwitcher
85 | 
--------------------------------------------------------------------------------
/hooks/useAnimateHeight.js:
--------------------------------------------------------------------------------
 1 | import { useRef } from 'react'
 2 | import { useFrame } from '@react-three/fiber'
 3 | import { MathUtils } from 'three'
 4 | 
 5 | // Elastic out easing.
 6 | const elasticOutEasing = (t, p = 0.3) => {
 7 |     return Math.pow(2, -10 * t) * Math.sin(((t - p / 4) * (2 * Math.PI)) / p) + 1
 8 | }
 9 | 
10 | // Custom hook to animate height.
11 | const useAnimateHeight = (elementRef, targetHeight, startHeight) => {
12 |     const animation = useRef({ targetHeight, progress: 0, initialHeight: startHeight || 0 })
13 | 
14 |     useFrame((state, delta) => {
15 |         // Target height has changed.
16 |         if (animation.current.targetHeight !== targetHeight) {
17 |             animation.current.targetHeight = targetHeight
18 |             animation.current.progress = 0
19 |             animation.current.initialHeight = elementRef.current.position.y
20 |         }
21 | 
22 |         // Increment progress.
23 |         animation.current.progress += delta
24 |         animation.current.progress = MathUtils.clamp(animation.current.progress, 0, 1)
25 | 
26 |         // Get eased progress.
27 |         const easedProgress = elasticOutEasing(animation.current.progress)
28 | 
29 |         // Get current height.
30 |         const currentHeight = MathUtils.lerp(animation.current.initialHeight, animation.current.targetHeight, easedProgress)
31 | 
32 |         // Update element position.
33 |         elementRef.current.position.y = currentHeight
34 |     })
35 | }
36 | 
37 | export default useAnimateHeight
38 | 
--------------------------------------------------------------------------------
/hooks/useInput.js:
--------------------------------------------------------------------------------
 1 | import { useEffect } from 'react'
 2 | import useInputStore from '../store/inputStore'
 3 | 
 4 | /**
 5 |  * Hook to handle input from keyboard and gamepad
 6 |  * Uses refs to prevent rerenders when used within useFrame
 7 |  */
 8 | const useInput = () => {
 9 |     // Store references
10 |     const setKey = useInputStore((state) => state.setKey)
11 |     const setGamepadState = useInputStore((state) => state.setGamepadState)
12 | 
13 |     // Setup input handling
14 |     useEffect(() => {
15 |         let frameId = null
16 | 
17 |         // Gamepad polling
18 |         const pollGamepad = () => {
19 |             const gamepads = navigator.getGamepads()
20 |             const gamepad = gamepads[0]
21 |             if (gamepad) {
22 |                 const axes = Array.from(gamepad.axes)
23 |                 const buttons = gamepad.buttons.map((button) => button.pressed)
24 |                 setGamepadState(axes, buttons)
25 |             }
26 | 
27 |             // Continue the animation loop
28 |             frameId = requestAnimationFrame(pollGamepad)
29 |         }
30 | 
31 |         // Start the animation loop
32 |         pollGamepad()
33 | 
34 |         // Log gamepad connection/disconnection
35 |         const handleGamepadConnected = () => console.log('Gamepad connected')
36 |         const handleGamepadDisconnected = () => console.log('Gamepad disconnected')
37 | 
38 |         // Keyboard event handlers
39 |         const handleKeyDown = (e) => setKey(e.key, true)
40 |         const handleKeyUp = (e) => setKey(e.key, false)
41 | 
42 |         // Set up event listeners
43 |         window.addEventListener('gamepadconnected', handleGamepadConnected)
44 |         window.addEventListener('gamepaddisconnected', handleGamepadDisconnected)
45 |         window.addEventListener('keydown', handleKeyDown)
46 |         window.addEventListener('keyup', handleKeyUp)
47 | 
48 |         // Cleanup
49 |         return () => {
50 |             window.removeEventListener('gamepadconnected', handleGamepadConnected)
51 |             window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected)
52 |             window.removeEventListener('keydown', handleKeyDown)
53 |             window.removeEventListener('keyup', handleKeyUp)
54 |             cancelAnimationFrame(frameId)
55 |         }
56 |     }, [])
57 | }
58 | 
59 | export default useInput
60 | 
--------------------------------------------------------------------------------
/hooks/useLoadingManager.js:
--------------------------------------------------------------------------------
 1 | import { useEffect, useRef } from 'react'
 2 | import { DefaultLoadingManager } from 'three'
 3 | import useGameStore from '../store/gameStore'
 4 | 
 5 | const useLoadingManager = () => {
 6 |     const setSceneLoaded = useGameStore((state) => state.setSceneLoaded)
 7 |     const isLoading = useRef(false)
 8 | 
 9 |     useEffect(() => {
10 |         DefaultLoadingManager.onStart = () => {
11 |             isLoading.current = true
12 |             setTimeout(() => isLoading.current && setSceneLoaded(false), 0)
13 |         }
14 | 
15 |         DefaultLoadingManager.onLoad = () => {
16 |             isLoading.current = false
17 |             setSceneLoaded(true)
18 |         }
19 | 
20 |         return () => {
21 |             DefaultLoadingManager.onStart = DefaultLoadingManager.onLoad = null
22 |         }
23 |     }, [setSceneLoaded])
24 | }
25 | 
26 | export default useLoadingManager
27 | 
--------------------------------------------------------------------------------
/hooks/useMaterialProperties.js:
--------------------------------------------------------------------------------
  1 | import { useCallback } from 'react'
  2 | import { Mesh, Color } from 'three'
  3 | 
  4 | const COLORS = {
  5 |     WHITE: new Color(1, 1, 1),
  6 |     LIGHT_GREY: new Color(0.8, 0.8, 0.8),
  7 |     MED_GREY: new Color(0.5, 0.5, 0.5),
  8 |     DARK_GREY: new Color(0.2, 0.2, 0.2),
  9 |     BLACK: new Color(0.025, 0.025, 0.025),
 10 | }
 11 | 
 12 | // Set vehicle materials.
 13 | const setMaterials = (material, color, roughness, rim_color, rim_color_secondary) => {
 14 |     // Switch material name.
 15 |     switch (material.name) {
 16 |         case 'body':
 17 |             material.color.setStyle(color)
 18 |             material.metalness = 0.4
 19 |             material.roughness = roughness
 20 |             break
 21 |         case 'chrome':
 22 |         case 'mirror':
 23 |             material.metalness = 1
 24 |             material.roughness = 0
 25 |             material.color.set(COLORS.WHITE)
 26 |             break
 27 |         case 'glass':
 28 |             material.transparent = true
 29 |             material.metalness = 1
 30 |             material.roughness = 0
 31 |             material.opacity = 0.2
 32 |             material.color.set(COLORS.LIGHT_GREY)
 33 |             break
 34 |         case 'glass_tint':
 35 |             material.transparent = true
 36 |             material.metalness = 1
 37 |             material.roughness = 0
 38 |             material.opacity = 0.4
 39 |             material.color.set(COLORS.MED_GREY)
 40 |             break
 41 |         case 'glass_dark':
 42 |             material.transparent = true
 43 |             material.metalness = 1
 44 |             material.roughness = 0
 45 |             material.opacity = 0.8
 46 |             material.color.set(COLORS.DARK_GREY)
 47 |             break
 48 |         case 'rubber':
 49 |             material.metalness = 0.5
 50 |             material.roughness = 0.9
 51 |             material.flatShading = true
 52 |             material.color.set(COLORS.BLACK)
 53 |             break
 54 |         case 'black':
 55 |             material.metalness = 0
 56 |             material.roughness = 0.5
 57 |             material.color.set(COLORS.BLACK)
 58 |             break
 59 |         case 'rim':
 60 |         case 'rim_secondary':
 61 |             // Switch rim color / secondary rim color.
 62 |             switch (material.name === 'rim_secondary' ? rim_color_secondary : rim_color) {
 63 |                 case 'silver':
 64 |                     material.metalness = 0.6
 65 |                     material.roughness = 0.1
 66 |                     material.color.set(COLORS.LIGHT_GREY)
 67 |                     break
 68 |                 case 'chrome':
 69 |                     material.metalness = 0.8
 70 |                     material.roughness = 0
 71 |                     material.color.set(COLORS.WHITE)
 72 |                     break
 73 |                 case 'gloss_black':
 74 |                     material.metalness = 1
 75 |                     material.roughness = 0.1
 76 |                     material.color.set(COLORS.BLACK)
 77 |                     break
 78 |                 case 'flat_black':
 79 |                     material.metalness = 0.2
 80 |                     material.roughness = 1
 81 |                     material.color.set(COLORS.BLACK)
 82 |                     break
 83 |                 case 'body':
 84 |                     material.metalness = 0.4
 85 |                     material.roughness = roughness
 86 |                     material.color.setStyle(color)
 87 |                     break
 88 |                 default:
 89 |             }
 90 |             break
 91 |         default:
 92 |     }
 93 | }
 94 | 
 95 | export default function useMaterialProperties() {
 96 |     // Set object materials.
 97 |     const setObjectMaterials = useCallback((object, color, roughness, rim_color, rim_color_secondary) => {
 98 |         if (!object) return
 99 | 
100 |         // Traverse object.
101 |         object.traverseVisible((child) => {
102 |             if (child instanceof Mesh) {
103 |                 // Cast shadows from mesh.
104 |                 child.castShadow = true
105 | 
106 |                 // Ensure that the material is always an array.
107 |                 const materials = Array.isArray(child.material) ? child.material : [child.material]
108 | 
109 |                 // Set material properties for each material.
110 |                 materials.forEach((material) => {
111 |                     setMaterials(material, color, roughness, rim_color, rim_color_secondary)
112 |                 })
113 |             }
114 |         })
115 |     }, [])
116 | 
117 |     return { setObjectMaterials }
118 | }
119 | 
--------------------------------------------------------------------------------
/hooks/useVehiclePhysics.js:
--------------------------------------------------------------------------------
  1 | import { useRef, useEffect, useState } from 'react'
  2 | import { useRapier, useAfterPhysicsStep } from '@react-three/rapier'
  3 | import { useFrame } from '@react-three/fiber'
  4 | import { Vector3, Quaternion } from 'three'
  5 | 
  6 | import useGameStore from '../store/gameStore'
  7 | import useInputStore from '../store/inputStore'
  8 | 
  9 | // Constants
 10 | const VECTORS = {
 11 |     UP: new Vector3(0, 1, 0),
 12 |     RIGHT: new Vector3(1, 0, 0),
 13 |     DOWN: new Vector3(0, -1, 0),
 14 |     FORWARD: new Vector3(0, 0, 1),
 15 | }
 16 | 
 17 | // Physics
 18 | const FORCES = {
 19 |     accelerate: 30,
 20 |     brake: 0.5,
 21 |     steerAngle: Math.PI / 6,
 22 |     airControl: 0.1, // Subtle air control force
 23 | }
 24 | 
 25 | /**
 26 |  * Generic vehicle physics hook for wheeled vehicles
 27 |  * @param {Object} vehicleRef - Reference to the vehicle rigid body
 28 |  * @param {Array} wheels - Array of wheel configurations with refs and positions
 29 |  * @returns {Object} - Vehicle controller
 30 |  */
 31 | export const useVehiclePhysics = (vehicleRef, wheels) => {
 32 |     const physicsEnabled = useGameStore((state) => state.physicsEnabled)
 33 |     const setPhysicsEnabled = useGameStore((state) => state.setPhysicsEnabled)
 34 | 
 35 |     const { world } = useRapier()
 36 | 
 37 |     // Refs
 38 |     const vehicleController = useRef()
 39 | 
 40 |     // Track airborne state
 41 |     const [isAirborne, setIsAirborne] = useState(false)
 42 | 
 43 |     // Setup vehicle physics
 44 |     useEffect(() => {
 45 |         if (!vehicleRef.current) return
 46 | 
 47 |         // Create vehicle controller
 48 |         const vehicle = world.createVehicleController(vehicleRef.current)
 49 | 
 50 |         // Set the vehicle's forward axis to Z (index 2)
 51 |         // This makes the forward direction perpendicular to the wheel axle direction
 52 |         vehicle.setIndexForwardAxis = 2
 53 | 
 54 |         // Add and configure wheels
 55 |         wheels.forEach((wheel, index) => {
 56 |             vehicle.addWheel(wheel.position, wheel.suspensionDirection || VECTORS.DOWN, wheel.axleCs || VECTORS.RIGHT, wheel.suspensionRestLength || 0.05, wheel.radius)
 57 |             vehicle.setWheelSuspensionStiffness(index, wheel.suspensionStiffness || 20)
 58 |             vehicle.setWheelMaxSuspensionTravel(index, wheel.maxSuspensionTravel || 0.23)
 59 |             vehicle.setWheelSuspensionCompression(index, wheel.suspensionCompression || 2.3)
 60 |             vehicle.setWheelSuspensionRelaxation(index, wheel.suspensionRebound || 3.4)
 61 |         })
 62 | 
 63 |         // Store controller reference
 64 |         vehicleController.current = vehicle
 65 | 
 66 |         return () => {
 67 |             if (vehicleController.current) {
 68 |                 world.removeVehicleController(vehicle)
 69 |                 vehicleController.current = null
 70 |             }
 71 |         }
 72 |     }, [vehicleRef, wheels, world])
 73 | 
 74 |     // Update wheel positions after physics step
 75 |     useAfterPhysicsStep((world) => {
 76 |         const controller = vehicleController.current
 77 |         if (!controller) return
 78 | 
 79 |         // Update the vehicle with safe timestep
 80 |         controller.updateVehicle(world.timestep)
 81 | 
 82 |         // Check if all wheels are not in contact with the ground (airborne)
 83 |         let wheelsInContact = 0
 84 | 
 85 |         // Update each wheel
 86 |         wheels.forEach((wheel, index) => {
 87 |             const wheelRef = wheel.ref.current
 88 |             if (!wheelRef) return
 89 | 
 90 |             // Get wheel data with fallbacks
 91 |             const wheelAxleCs = controller.wheelAxleCs(index) || VECTORS.RIGHT
 92 |             const connection = controller.wheelChassisConnectionPointCs(index)
 93 |             const suspension = controller.wheelSuspensionLength(index) || 0
 94 |             const steering = controller.wheelSteering(index) || 0
 95 |             const rotation = controller.wheelRotation(index) || 0
 96 | 
 97 |             // Check if the wheel is in contact with the ground
 98 |             if (controller.wheelIsInContact(index)) {
 99 |                 wheelsInContact++
100 |             }
101 | 
102 |             // Update position
103 |             wheelRef.position.y = connection?.y - suspension
104 | 
105 |             // Apply steering and rotation
106 |             wheelRef.quaternion.multiplyQuaternions(new Quaternion().setFromAxisAngle(VECTORS.UP, steering), new Quaternion().setFromAxisAngle(wheelAxleCs, rotation))
107 |         })
108 | 
109 |         // Update airborne state
110 |         const newAirborneState = wheelsInContact === 0
111 |         if (newAirborneState !== isAirborne) {
112 |             setIsAirborne(newAirborneState)
113 |         }
114 |     })
115 | 
116 |     // Handle input forces each frame
117 |     useFrame(() => {
118 |         if (!vehicleController.current) return
119 | 
120 |         // Get input refs from store
121 |         const { keys, gamepadAxes, gamepadButtons } = useInputStore.getState()
122 | 
123 |         // Get gamepad input
124 |         const leftStickX = gamepadAxes[0] || 0
125 |         const leftStickY = gamepadAxes[1] || 0
126 |         const rightStickX = gamepadAxes[2] || 0
127 |         const rightStickY = gamepadAxes[3] || 0
128 | 
129 |         const leftTrigger = gamepadButtons[6] ? 1 : 0
130 |         const rightTrigger = gamepadButtons[7] ? 1 : 0
131 | 
132 |         const clamp = (value) => Math.min(1, Math.max(-1, value))
133 | 
134 |         // Calculate forces based on input
135 |         const engineForce = FORCES.accelerate * clamp((keys.has('ArrowUp') ? 1 : 0) + (rightStickY < 0 ? -rightStickY : 0) + rightTrigger)
136 |         const steerForce = FORCES.steerAngle * clamp((keys.has('ArrowRight') ? -1 : 0) + (keys.has('ArrowLeft') ? 1 : 0) + -leftStickX)
137 |         const brakeForce = FORCES.brake * clamp((keys.has('ArrowDown') ? 1 : 0) + (rightStickY > 0 ? rightStickY : 0) + leftTrigger)
138 | 
139 |         if (!isAirborne) {
140 |             // Front wheels steering (assuming first two wheels are front)
141 |             for (let i = 0; i < 2 && i < wheels.length; i++) {
142 |                 vehicleController.current.setWheelSteering(i, steerForce)
143 |             }
144 | 
145 |             // Rear wheels driving (assuming last two wheels are rear)
146 |             for (let i = 2; i < 4 && i < wheels.length; i++) {
147 |                 vehicleController.current.setWheelEngineForce(i, -engineForce)
148 |             }
149 | 
150 |             // All wheels braking
151 |             for (let i = 0; i < wheels.length; i++) {
152 |                 vehicleController.current.setWheelBrake(i, brakeForce)
153 |             }
154 |         } else {
155 |             // Airborne controls when all wheels are not in contact
156 |             const vehicle = vehicleRef.current
157 |             if (vehicle) {
158 |                 const pitch = clamp((keys.has('ArrowUp') ? -1 : 0) + (keys.has('ArrowDown') ? 1 : 0) - leftStickY)
159 |                 const roll = clamp((keys.has('ArrowLeft') ? -1 : 0) + (keys.has('ArrowRight') ? 1 : 0) + leftStickX)
160 |                 const yaw = clamp(-rightStickX)
161 | 
162 |                 // Construct torque vector in world space
163 |                 const localTorque = new Vector3(pitch, yaw, roll)
164 |                 const worldTorque = localTorque.applyQuaternion(new Quaternion().copy(vehicle.rotation()))
165 | 
166 |                 // Apply impulse
167 |                 vehicle.applyTorqueImpulse(worldTorque.multiplyScalar(FORCES.airControl), true)
168 |             }
169 |         }
170 | 
171 |         // Enable physics if not already enabled
172 |         if (!physicsEnabled && engineForce) {
173 |             setPhysicsEnabled(true)
174 |         }
175 |     })
176 | 
177 |     // Return the vehicleController ref and control functions
178 |     return {
179 |         vehicleController,
180 |     }
181 | }
182 | 
183 | export default useVehiclePhysics
184 | 
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         
 5 |         
 6 |         
 7 |         4x4 Builder
 8 |         
 9 |         
10 |     
11 |     
12 |         
13 |         
14 |     
15 | 
16 | 
--------------------------------------------------------------------------------
/main.jsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import ReactDOM from 'react-dom/client'
 3 | import App from './components/App'
 4 | 
 5 | import './assets/styles/global.css'
 6 | 
 7 | ReactDOM.createRoot(document.getElementById('root')).render(
 8 |     
 9 |         
10 |     
11 | )
12 | 
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |     "name": "4x4builder",
 3 |     "version": "2.0.0",
 4 |     "private": true,
 5 |     "description": "4x4builder.com",
 6 |     "author": "Shane Vincent",
 7 |     "type": "module",
 8 |     "scripts": {
 9 |         "dev": "vite",
10 |         "build": "vite build",
11 |         "preview": "vite preview"
12 |     },
13 |     "dependencies": {
14 |         "@react-three/drei": "^10.0.4",
15 |         "@react-three/fiber": "^9.1.0",
16 |         "@react-three/rapier": "^2.0.0",
17 |         "@tailwindcss/vite": "^4.0.14",
18 |         "classnames": "^2.5.1",
19 |         "immer": "^10.1.1",
20 |         "noisejs": "^2.1.0",
21 |         "react": "^19.0.0",
22 |         "react-dom": "^19.0.0",
23 |         "tailwindcss": "^4.0.14",
24 |         "three": "^0.174.0",
25 |         "vite-plugin-svgr": "^4.3.0",
26 |         "zustand": "^5.0.3"
27 |     },
28 |     "devDependencies": {
29 |         "@types/react": "^19.0.10",
30 |         "@types/react-dom": "^19.0.4",
31 |         "@vitejs/plugin-react": "^4.3.4",
32 |         "vite": "^6.2.1"
33 |     }
34 | }
35 | 
--------------------------------------------------------------------------------
/public/assets/images/envmap/gainmap.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/envmap/gainmap.webp
--------------------------------------------------------------------------------
/public/assets/images/ground/dirt_01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/ground/dirt_01.png
--------------------------------------------------------------------------------
/public/assets/images/ground/dirt_01_nrm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/ground/dirt_01_nrm.png
--------------------------------------------------------------------------------
/public/assets/images/ground/ground_tile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/ground/ground_tile.png
--------------------------------------------------------------------------------
/public/assets/images/ground/sand.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/ground/sand.jpg
--------------------------------------------------------------------------------
/public/assets/images/ground/sand_normal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/images/ground/sand_normal.jpg
--------------------------------------------------------------------------------
/public/assets/models/vehicles/ford/bronco/6g/bronco.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/ford/bronco/6g/bronco.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/jeep/jk/jku.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/jeep/jk/jku.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/jeep/xj/xj.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/jeep/xj/xj.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/jeep/yj/yj.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/jeep/yj/yj.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/4runner.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/4runner.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/shrockworks_bumper.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/shrockworks_bumper.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/steel_sliders.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/steel_sliders.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/stock_bumper.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/stock_bumper.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/stock_rack.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/stock_rack.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/stock_sliders.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/stock_sliders.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/3g/whitson_rack.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/3g/whitson_rack.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/4g/4runner.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/4g/4runner.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/5g/4runner.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/5g/4runner.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/4runner/5g/4runner_late.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/4runner/5g/4runner_late.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/land_cruiser/j250/j250.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/land_cruiser/j250/j250.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/land_cruiser/j80/j80.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/land_cruiser/j80/j80.glb
--------------------------------------------------------------------------------
/public/assets/models/vehicles/toyota/tacoma/2g/tacoma.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/vehicles/toyota/tacoma/2g/tacoma.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/ar_mojave.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/ar_mojave.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/cragar_soft_8.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/cragar_soft_8.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/ford_bronco.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/ford_bronco.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/konig_countersteer.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/konig_countersteer.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/level_8_strike_6.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/level_8_strike_6.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/moto_metal_mO951.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/moto_metal_mO951.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/toyota_4runner.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/toyota_4runner.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/toyota_trd.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/toyota_trd.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/xd_grenade.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/xd_grenade.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/rims/xd_machete.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/rims/xd_machete.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/bfg_at.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/bfg_at.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/bfg_km2.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/bfg_km2.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/bfg_km3.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/bfg_km3.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/maxxis_trepador.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/maxxis_trepador.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/mud_grappler.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/mud_grappler.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/thornbird.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/thornbird.glb
--------------------------------------------------------------------------------
/public/assets/models/wheels/tires/toyo_open_country_mt.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theshanergy/4x4builder/5da9c29960f181f21b6d600ecab368f0526b1f1d/public/assets/models/wheels/tires/toyo_open_country_mt.glb
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/store/gameStore.js:
--------------------------------------------------------------------------------
  1 | import { create } from 'zustand'
  2 | import { produce } from 'immer'
  3 | import { Vector3 } from 'three'
  4 | import vehicleConfigs from '../vehicleConfigs'
  5 | 
  6 | // Compatibility shim for legacy localStorage data, mapping old vehicle id field to body
  7 | const preprocessVehicleConfig = (config) => {
  8 |     if (!config) return config
  9 |     const { id, ...rest } = config
 10 |     return { ...rest, ...(id && { body: id }) }
 11 | }
 12 | 
 13 | // Game store
 14 | const useGameStore = create((set, get) => {
 15 |     return {
 16 |         // Game state
 17 |         sceneLoaded: false,
 18 |         physicsEnabled: false,
 19 |         performanceDegraded: false,
 20 |         setSceneLoaded: (loaded) => set({ sceneLoaded: loaded }),
 21 |         setPhysicsEnabled: (enabled) => set({ physicsEnabled: enabled }),
 22 |         setPerformanceDegraded: (degraded) => set({ performanceDegraded: degraded }),
 23 | 
 24 |         // Notification state
 25 |         notification: null,
 26 |         showNotification: (notificationData) => set({ notification: { ...notificationData, id: Date.now() } }),
 27 |         hideNotification: () => set({ notification: null }),
 28 | 
 29 |         // Camera state
 30 |         cameraTarget: new Vector3(0, 0, 0),
 31 |         cameraControlsRef: null,
 32 |         cameraAutoRotate: false,
 33 |         setCameraTarget: (x, y, z) => {
 34 |             // mutate in place
 35 |             useGameStore.getState().cameraTarget.set(x, y, z)
 36 |         },
 37 |         setCameraControlsRef: (ref) => set({ cameraControlsRef: ref }),
 38 |         setCameraAutoRotate: (autoRotate) => set({ cameraAutoRotate: autoRotate }),
 39 | 
 40 |         // Saved vehicles
 41 |         savedVehicles: (() => {
 42 |             // Get from local storage or null.
 43 |             const localStorageVehicles = localStorage.getItem('savedVehicles')
 44 |             const vehicles = localStorageVehicles ? JSON.parse(localStorageVehicles) : { current: null }
 45 | 
 46 |             // Normalize all saved configs
 47 |             for (const key in vehicles) {
 48 |                 if (key !== 'current' && vehicles[key]?.config) {
 49 |                     vehicles[key].config = preprocessVehicleConfig(vehicles[key].config)
 50 |                 }
 51 |             }
 52 | 
 53 |             return vehicles
 54 |         })(),
 55 |         setSavedVehicles: (updater) =>
 56 |             set((state) => {
 57 |                 const newSavedVehicles = typeof updater === 'function' ? updater(state.savedVehicles) : updater
 58 |                 localStorage.setItem('savedVehicles', JSON.stringify(newSavedVehicles))
 59 | 
 60 |                 // Force state to reinitialize `currentVehicle`
 61 |                 return {
 62 |                     savedVehicles: newSavedVehicles,
 63 |                     currentVehicle:
 64 |                         newSavedVehicles.current && newSavedVehicles[newSavedVehicles.current] ? newSavedVehicles[newSavedVehicles.current].config : vehicleConfigs.defaults,
 65 |                 }
 66 |             }),
 67 | 
 68 |         // Delete a vehicle from saved vehicles
 69 |         deleteSavedVehicle: (vehicleId) => {
 70 |             set((state) => {
 71 |                 const updatedVehicles = { ...state.savedVehicles }
 72 |                 delete updatedVehicles[vehicleId]
 73 | 
 74 |                 if (state.savedVehicles.current === vehicleId) {
 75 |                     const remainingIds = Object.keys(updatedVehicles).filter((key) => key !== 'current')
 76 |                     updatedVehicles.current = remainingIds[0] || null
 77 |                 }
 78 | 
 79 |                 return { savedVehicles: updatedVehicles }
 80 |             })
 81 | 
 82 |             get().setSavedVehicles((vehicles) => vehicles) // Forces resync with localStorage
 83 |         },
 84 | 
 85 |         // Current vehicle config
 86 |         currentVehicle: (() => {
 87 |             const localStorageVehicles = localStorage.getItem('savedVehicles')
 88 |             const savedVehicles = localStorageVehicles ? JSON.parse(localStorageVehicles) : { current: null }
 89 |             const defaultVehicleId = savedVehicles.current
 90 |             const config = defaultVehicleId && savedVehicles[defaultVehicleId] ? savedVehicles[defaultVehicleId].config : vehicleConfigs.defaults
 91 | 
 92 |             return preprocessVehicleConfig(config)
 93 |         })(),
 94 |         setVehicle: (updater) =>
 95 |             set(
 96 |                 produce((state) => {
 97 |                     // Get previous vehicle id
 98 |                     const prevBodyId = state.currentVehicle.body
 99 | 
100 |                     // Update vehicle state
101 |                     if (typeof updater === 'function') {
102 |                         updater(state.currentVehicle)
103 |                     } else {
104 |                         Object.assign(state.currentVehicle, preprocessVehicleConfig(updater))
105 |                     }
106 | 
107 |                     // Get new vehicle id
108 |                     const newBodyId = state.currentVehicle.body
109 | 
110 |                     // If vehicle body changed, reset addons
111 |                     if (newBodyId !== prevBodyId && updater.body) {
112 |                         state.currentVehicle.addons = vehicleConfigs.vehicles[newBodyId]?.default_addons || {}
113 |                     }
114 |                 })
115 |             ),
116 | 
117 |         // Load vehicle from URL parameters
118 |         loadVehicleFromUrl: () => {
119 |             const urlParams = new URLSearchParams(window.location.search)
120 |             const encodedConfig = urlParams.get('config')
121 | 
122 |             if (encodedConfig) {
123 |                 console.log('Loading vehicle from shared url.')
124 |                 const jsonString = decodeURIComponent(encodedConfig)
125 |                 const config = preprocessVehicleConfig(JSON.parse(jsonString))
126 | 
127 |                 // Overwrite current vehicle from URL parameter
128 |                 set({ currentVehicle: config })
129 | 
130 |                 // Clear current saved vehicle
131 |                 set((state) => ({
132 |                     savedVehicles: {
133 |                         ...state.savedVehicles,
134 |                         current: null,
135 |                     },
136 |                 }))
137 | 
138 |                 // Clear URL parameters
139 |                 window.history.replaceState({}, '', window.location.pathname)
140 | 
141 |                 return true
142 |             }
143 | 
144 |             return false
145 |         },
146 |     }
147 | })
148 | 
149 | export default useGameStore
150 | 
--------------------------------------------------------------------------------
/store/inputStore.js:
--------------------------------------------------------------------------------
 1 | // inputStore.js
 2 | import { create } from 'zustand'
 3 | 
 4 | const useInputStore = create((set) => ({
 5 |     keys: new Set(),
 6 |     gamepadAxes: [],
 7 |     gamepadButtons: [],
 8 |     setKey: (key, pressed) =>
 9 |         set((state) => {
10 |             const keys = new Set(state.keys)
11 |             if (pressed) keys.add(key)
12 |             else keys.delete(key)
13 |             return { keys }
14 |         }),
15 |     setGamepadState: (axes, buttons) =>
16 |         set(() => ({
17 |             gamepadAxes: axes,
18 |             gamepadButtons: buttons,
19 |         })),
20 | }))
21 | 
22 | export default useInputStore
23 | 
--------------------------------------------------------------------------------
/vehicleConfigs.js:
--------------------------------------------------------------------------------
  1 | const vehicleConfigs = {
  2 |     defaults: {
  3 |         body: 'toyota_4runner_5g',
  4 |         lift: 0,
  5 |         color: '#B91818',
  6 |         roughness: 0,
  7 |         addons: {},
  8 |         wheel_offset: 0,
  9 |         rim: 'toyota_4runner_5thgen',
 10 |         rim_color: 'silver',
 11 |         rim_color_secondary: 'silver',
 12 |         rim_diameter: 17,
 13 |         rim_width: 10,
 14 |         tire: 'bfg_at',
 15 |         tire_diameter: 32,
 16 |         spare: true,
 17 |     },
 18 |     vehicles: {
 19 |         toyota_4runner_5g_late: {
 20 |             name: 'Toyota 4Runner (2014-2024)',
 21 |             make: 'Toyota',
 22 |             model: 'assets/models/vehicles/toyota/4runner/5g/4runner_late.glb',
 23 |             wheel_offset: 0.8,
 24 |             wheelbase: 2.789,
 25 |             default_addons: {},
 26 |             addons: {},
 27 |         },
 28 |         toyota_4runner_5g: {
 29 |             name: 'Toyota 4Runner (2011-2013)',
 30 |             make: 'Toyota',
 31 |             model: 'assets/models/vehicles/toyota/4runner/5g/4runner.glb',
 32 |             wheel_offset: 0.76,
 33 |             wheelbase: 2.789,
 34 |             default_addons: {},
 35 |             addons: {},
 36 |         },
 37 |         toyota_4runner_4g: {
 38 |             name: 'Toyota 4Runner (2002-2009)',
 39 |             make: 'Toyota',
 40 |             model: 'assets/models/vehicles/toyota/4runner/4g/4runner.glb',
 41 |             wheel_offset: 0.76,
 42 |             wheelbase: 2.79,
 43 |             default_addons: {},
 44 |             addons: {},
 45 |         },
 46 |         toyota_4runner_3g: {
 47 |             name: 'Toyota 4Runner (1996-2002)',
 48 |             make: 'Toyota',
 49 |             model: 'assets/models/vehicles/toyota/4runner/3g/4runner.glb',
 50 |             wheel_offset: 0.75,
 51 |             wheelbase: 2.675,
 52 |             spare: [-0.175, 0.7, -2.5],
 53 |             default_addons: {
 54 |                 bumper_f: 'stock',
 55 |                 sliders: 'stock',
 56 |                 rack: 'stock',
 57 |             },
 58 |             addons: {
 59 |                 bumper_f: {
 60 |                     name: 'Bumper',
 61 |                     required: true,
 62 |                     options: {
 63 |                         stock: {
 64 |                             name: 'Stock',
 65 |                             model: 'assets/models/vehicles/toyota/4runner/3g/stock_bumper.glb',
 66 |                         },
 67 |                         shrockworks: {
 68 |                             name: 'Shrockworks',
 69 |                             model: 'assets/models/vehicles/toyota/4runner/3g/shrockworks_bumper.glb',
 70 |                         },
 71 |                     },
 72 |                 },
 73 |                 sliders: {
 74 |                     name: 'Sliders',
 75 |                     required: false,
 76 |                     options: {
 77 |                         stock: {
 78 |                             name: 'Stock',
 79 |                             model: 'assets/models/vehicles/toyota/4runner/3g/stock_sliders.glb',
 80 |                         },
 81 |                         steel: {
 82 |                             name: 'Steel',
 83 |                             model: 'assets/models/vehicles/toyota/4runner/3g/steel_sliders.glb',
 84 |                         },
 85 |                     },
 86 |                 },
 87 |                 rack: {
 88 |                     name: 'Rack',
 89 |                     required: false,
 90 |                     options: {
 91 |                         stock: {
 92 |                             name: 'Stock',
 93 |                             model: 'assets/models/vehicles/toyota/4runner/3g/stock_rack.glb',
 94 |                         },
 95 |                         whitson: {
 96 |                             name: 'Whitson Metalworks',
 97 |                             model: 'assets/models/vehicles/toyota/4runner/3g/whitson_rack.glb',
 98 |                         },
 99 |                     },
100 |                 },
101 |             },
102 |         },
103 |         toyota_tacoma_2g_ac: {
104 |             name: 'Toyota Tacoma (2005-2015)',
105 |             make: 'Toyota',
106 |             model: 'assets/models/vehicles/toyota/tacoma/2g/tacoma.glb',
107 |             wheel_offset: 0.81,
108 |             wheelbase: 3.245,
109 |             default_addons: {},
110 |             addons: {},
111 |         },
112 |         toyota_j250: {
113 |             name: 'Toyota Land Cruiser (2024+)',
114 |             make: 'Toyota',
115 |             model: 'assets/models/vehicles/toyota/land_cruiser/j250/j250.glb',
116 |             wheel_offset: 0.81,
117 |             wheelbase: 2.85,
118 |             default_addons: {},
119 |             addons: {},
120 |         },
121 |         toyota_j80: {
122 |             name: 'Toyota Land Cruiser (1990–2008)',
123 |             make: 'Toyota',
124 |             model: 'assets/models/vehicles/toyota/land_cruiser/j80/j80.glb',
125 |             wheel_offset: 0.78,
126 |             wheelbase: 2.85,
127 |             default_addons: {},
128 |             addons: {},
129 |         },
130 |         jeep_jku: {
131 |             name: 'Jeep Wrangler (JKU)',
132 |             make: 'Jeep',
133 |             model: 'assets/models/vehicles/jeep/jk/jku.glb',
134 |             wheel_offset: 0.8,
135 |             wheelbase: 2.946,
136 |             default_addons: {},
137 |             addons: {},
138 |         },
139 |         jeep_yj: {
140 |             name: 'Jeep Wrangler (YJ)',
141 |             make: 'Jeep',
142 |             model: 'assets/models/vehicles/jeep/yj/yj.glb',
143 |             wheel_offset: 0.7,
144 |             wheelbase: 2.372,
145 |             default_addons: {},
146 |             addons: {},
147 |         },
148 |         jeep_xj: {
149 |             name: 'Jeep Cherokee (XJ)',
150 |             make: 'Jeep',
151 |             model: 'assets/models/vehicles/jeep/xj/xj.glb',
152 |             wheel_offset: 0.7,
153 |             wheelbase: 2.5,
154 |             default_addons: {},
155 |             addons: {},
156 |         },
157 |         ford_bronco_6g: {
158 |             name: 'Ford Bronco',
159 |             make: 'Ford',
160 |             model: 'assets/models/vehicles/ford/bronco/6g/bronco.glb',
161 |             wheel_offset: 0.85,
162 |             wheelbase: 2.95,
163 |             spare: [0, 0.7, -2.35],
164 |             default_addons: {},
165 |             addons: {},
166 |         },
167 |     },
168 |     wheels: {
169 |         rims: {
170 |             xd_grenade: {
171 |                 make: 'XD Wheels',
172 |                 name: 'XD Series Grenade',
173 |                 model: 'assets/models/wheels/rims/xd_grenade.glb',
174 |                 width: 0.5,
175 |                 od: 1,
176 |             },
177 |             xd_machete: {
178 |                 make: 'XD Wheels',
179 |                 name: 'XD Machete',
180 |                 model: 'assets/models/wheels/rims/xd_machete.glb',
181 |                 width: 0.5,
182 |                 od: 1,
183 |             },
184 |             level_8_strike_6: {
185 |                 make: 'Level 8',
186 |                 name: 'Level 8 Strike 6',
187 |                 model: 'assets/models/wheels/rims/level_8_strike_6.glb',
188 |                 width: 0.5,
189 |                 od: 1,
190 |             },
191 |             konig_countersteer: {
192 |                 make: 'Konig',
193 |                 name: 'Konig Countersteer',
194 |                 model: 'assets/models/wheels/rims/konig_countersteer.glb',
195 |                 width: 0.5,
196 |                 od: 1,
197 |             },
198 |             cragar_soft_8: {
199 |                 make: 'Crager',
200 |                 name: 'Cragar Soft 8',
201 |                 model: 'assets/models/wheels/rims/cragar_soft_8.glb',
202 |                 width: 0.5,
203 |                 od: 1,
204 |             },
205 |             moto_metal_mO951: {
206 |                 make: 'Moto Metal',
207 |                 name: 'Moto Metal MO951',
208 |                 model: 'assets/models/wheels/rims/moto_metal_mO951.glb',
209 |                 width: 0.5,
210 |                 od: 1,
211 |             },
212 |             ar_mojave: {
213 |                 make: 'American Racing',
214 |                 name: 'American Racing Mojave',
215 |                 model: 'assets/models/wheels/rims/ar_mojave.glb',
216 |                 width: 0.5,
217 |                 od: 1,
218 |             },
219 |             toyota_4runner_5thgen: {
220 |                 make: 'Toyota',
221 |                 name: 'Toyota 4Runner 5th gen',
222 |                 model: 'assets/models/wheels/rims/toyota_4runner.glb',
223 |                 width: 0.5,
224 |                 od: 1,
225 |             },
226 |             toyota_trd: {
227 |                 make: 'Toyota',
228 |                 name: 'Toyota TRD Pro',
229 |                 model: 'assets/models/wheels/rims/toyota_trd.glb',
230 |                 width: 0.5,
231 |                 od: 1,
232 |             },
233 |             ford_bronco: {
234 |                 make: 'Ford',
235 |                 name: 'Ford Bronco',
236 |                 model: 'assets/models/wheels/rims/ford_bronco.glb',
237 |                 width: 0.5,
238 |                 od: 1,
239 |             },
240 |         },
241 |         tires: {
242 |             nitto_mud_grappler: {
243 |                 make: 'Nitto',
244 |                 name: 'Nitto Mud Grappler',
245 |                 model: 'assets/models/wheels/tires/mud_grappler.glb',
246 |                 width: 0.32,
247 |                 od: 0.883,
248 |                 id: 0.48,
249 |             },
250 |             bfg_at: {
251 |                 make: 'BFGoodrich',
252 |                 name: 'BFGoodrich A/T',
253 |                 model: 'assets/models/wheels/tires/bfg_at.glb',
254 |                 width: 0.26,
255 |                 od: 0.895,
256 |                 id: 0.43,
257 |             },
258 |             bfg_km3: {
259 |                 make: 'BFGoodrich',
260 |                 name: 'BFGoodrich KM3',
261 |                 model: 'assets/models/wheels/tires/bfg_km3.glb',
262 |                 width: 0.267,
263 |                 od: 0.849,
264 |                 id: 0.48,
265 |             },
266 |             bfg_km2: {
267 |                 make: 'BFGoodrich',
268 |                 name: 'BFGoodrich KM2',
269 |                 model: 'assets/models/wheels/tires/bfg_km2.glb',
270 |                 width: 0.245,
271 |                 od: 0.837,
272 |                 id: 0.44,
273 |             },
274 |             maxxis_trepador: {
275 |                 make: 'Maxxis',
276 |                 name: 'Maxxis Trepador',
277 |                 model: 'assets/models/wheels/tires/maxxis_trepador.glb',
278 |                 width: 0.34,
279 |                 od: 0.92,
280 |                 id: 0.445,
281 |             },
282 |         },
283 |     },
284 | }
285 | 
286 | export default vehicleConfigs
287 | 
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
 1 | import { defineConfig } from 'vite'
 2 | import react from '@vitejs/plugin-react'
 3 | import tailwindcss from '@tailwindcss/vite'
 4 | import svgr from 'vite-plugin-svgr'
 5 | 
 6 | // https://vitejs.dev/config/
 7 | export default defineConfig({
 8 |     plugins: [
 9 |         react(),
10 |         tailwindcss(),
11 |         svgr({
12 |             include: '**/*.svg',
13 |         }),
14 |     ],
15 | })
16 | 
--------------------------------------------------------------------------------