├── .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 | ![4x4 Builder Screenshot](assets/images/screenshot.png) 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 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/images/icons/Controller.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/images/icons/GitHub.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/images/icons/Paint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Rim.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/images/icons/Suspension.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Tire.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/images/icons/Tool.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/images/icons/Trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/images/icons/Vehicle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 27 | setPerformanceDegraded(true)} /> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 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 |