Merge branch 'develop' into feat-editor
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
# Agent - La Fabrik
|
||||||
|
|
||||||
|
You are working on **La Fabrik**, an interactive 3D web experience built with React Three Fiber.
|
||||||
|
|
||||||
|
## Read This First
|
||||||
|
|
||||||
|
- `docs/technical/architecture.md` describes the code that exists today.
|
||||||
|
- `docs/technical/target-architecture.md` describes the intended target-state.
|
||||||
|
- Do not assume target-state systems already exist.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
- Stack: React 19, Three.js, `@react-three/fiber`, `@react-three/drei`, `@react-three/rapier`, TypeScript, Vite
|
||||||
|
- No external global state library is used.
|
||||||
|
- Current singleton-style services are limited to:
|
||||||
|
- `InteractionManager`
|
||||||
|
- `AudioManager`
|
||||||
|
- `Debug`
|
||||||
|
- Current gameplay scope is still prototype-level:
|
||||||
|
- player movement
|
||||||
|
- trigger/grab interactions
|
||||||
|
- debug camera and scene switching
|
||||||
|
- simple audio playback
|
||||||
|
|
||||||
|
## Current Architecture Rules
|
||||||
|
|
||||||
|
- Scene objects live in `src/world/` and `src/components/3d/`.
|
||||||
|
- HTML overlays live in `src/components/ui/`.
|
||||||
|
- Shared static config lives in `src/data/`.
|
||||||
|
- Debug tooling lives in `src/utils/debug/` and `src/hooks/debug/`.
|
||||||
|
- Use the `@/` alias for imports from `src/`.
|
||||||
|
- Prefer small, direct changes over adding new abstraction layers.
|
||||||
|
- Shared types should live close to their domain and only move outward when they gain multiple real consumers.
|
||||||
|
|
||||||
|
## Target-State Guidance
|
||||||
|
|
||||||
|
The project may later grow toward a manager-driven gameplay architecture with clearer separation between:
|
||||||
|
|
||||||
|
- production world code
|
||||||
|
- gameplay orchestration
|
||||||
|
- UI overlays
|
||||||
|
- debug tooling
|
||||||
|
|
||||||
|
That target-state is aspirational until the matching code exists. If a target-state rule conflicts with the current implementation, treat the current code as the source of truth and improve it incrementally.
|
||||||
|
|
||||||
|
## Do Not Assume
|
||||||
|
|
||||||
|
- There is no `GameManager` in the current codebase.
|
||||||
|
- There are no implemented mission, zone, cinematic, or dialogue systems yet.
|
||||||
|
- Dependency versions are not pinned today; do not rewrite dependency strategy unless explicitly asked.
|
||||||
|
- The old `# route path ...` file header convention is not in use.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Files in `.agent/skills/` are supplemental patterns and examples. Some describe target-state or generic practices rather than the exact current implementation, so verify against the code before applying them.
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Agent — La Fabrik
|
|
||||||
|
|
||||||
You are working on **La Fabrik**, an interactive 3D web experience built with React Three Fiber. The player steps into the role of a technician in Altera (2050) and completes missions: repairing an e-bike, fixing a power grid, upgrading a vertical farm.
|
|
||||||
|
|
||||||
## Project Identity
|
|
||||||
|
|
||||||
- **Stack:** React 19, Three.js, @react-three/fiber 9, @react-three/drei, @react-three/rapier, GSAP, TypeScript, Vite
|
|
||||||
- **No external state lib.** State is managed by a custom `GameManager` singleton with a subscribe/getState pattern.
|
|
||||||
- **No Zustand, no Redux, no Context for global state.**
|
|
||||||
- **Versions are pinned** (no `^` in dependencies). Do not upgrade packages without explicit request.
|
|
||||||
|
|
||||||
## Architecture Rules
|
|
||||||
|
|
||||||
### Two patterns coexist
|
|
||||||
|
|
||||||
1. **Singleton manager classes** — for orchestration, audio, cinematics, zone detection, debug
|
|
||||||
2. **Declarative React components** — for all 3D scene objects (map, zones, lights, player, postprocessing)
|
|
||||||
|
|
||||||
Scene objects are **never** singleton classes. Managers are **never** React components.
|
|
||||||
|
|
||||||
### State ownership
|
|
||||||
|
|
||||||
- `GameManager` is the single source of truth for durable gameplay state (phase, zone, mission, input lock, dialogue)
|
|
||||||
- Other managers (`CinematicManager`, `AudioManager`, `ZoneManager`) handle side effects only — they read from GameManager but do not duplicate its state
|
|
||||||
- React components subscribe to GameManager through `useGameState()` hook
|
|
||||||
- **High-frequency values** (movement, camera interpolation, physics) stay in `useRef` + `useFrame` — never in React state
|
|
||||||
|
|
||||||
### File conventions
|
|
||||||
|
|
||||||
- Every file starts with a comment: `# route path <relative_path>` (e.g. `# route path src/world/Map.tsx`)
|
|
||||||
- Scene components live in `src/world/` and `src/components/3d/`
|
|
||||||
- UI overlays live in `src/components/ui/`
|
|
||||||
- Managers live in `src/stateManager/`
|
|
||||||
- Debug tooling lives in `src/utils/debug/`
|
|
||||||
- Hooks live in `src/hooks/`
|
|
||||||
- Static data lives in `src/data/`
|
|
||||||
- Shaders live in `src/shaders/`
|
|
||||||
- Utilities live in `src/utils/`
|
|
||||||
|
|
||||||
### Import paths
|
|
||||||
|
|
||||||
Use `@/` alias for imports from `src/`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { GameManager } from "@/stateManager/GameManager";
|
|
||||||
import { useGameState } from "@/hooks/useGameState";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory management
|
|
||||||
|
|
||||||
- Dispose only what you own (custom materials, render targets, manual clones)
|
|
||||||
- Never blindly deep-dispose shared/cached assets (drei loaders cache models)
|
|
||||||
- Use `Dispose.material()`, `Dispose.mesh()`, `Dispose.renderTarget()` from `src/utils/Dispose.ts`
|
|
||||||
|
|
||||||
### Debug
|
|
||||||
|
|
||||||
- Debug panel activates with `?debug` in URL
|
|
||||||
- All debug logic goes through `Debug.getInstance()` from `src/utils/debug/Debug.ts`
|
|
||||||
- Never scatter `if (isDev)` blocks across files
|
|
||||||
- `r3f-perf` is lazy-loaded only in debug mode via `src/utils/debug/DebugPerf.tsx`
|
|
||||||
|
|
||||||
## Managers (4 max)
|
|
||||||
|
|
||||||
| Manager | Responsibility |
|
|
||||||
| ------------------ | ------------------------------------------------------------------- |
|
|
||||||
| `GameManager` | Phase, zone, mission, input lock, dialogue — single source of truth |
|
|
||||||
| `CinematicManager` | GSAP timelines, camera lock/unlock |
|
|
||||||
| `AudioManager` | Music, SFX, spatial audio |
|
|
||||||
| `ZoneManager` | Zone detection, LOD triggers |
|
|
||||||
|
|
||||||
## Do NOT
|
|
||||||
|
|
||||||
- Create new manager classes without explicit request
|
|
||||||
- Use Zustand, Redux, or React Context for global state
|
|
||||||
- Put high-frequency values in React state (`useState`)
|
|
||||||
- Import `CinematicManager`/`AudioManager`/`ZoneManager` directly from components — always go through `GameManager`
|
|
||||||
- Upgrade pinned dependency versions
|
|
||||||
- Create files outside the documented architecture without explicit request
|
|
||||||
|
|
||||||
## Skills
|
|
||||||
|
|
||||||
See `.agent/skills/` for detailed patterns per technology:
|
|
||||||
|
|
||||||
- `best-practices.md` — Code generation conventions (W3C, simple, scalable, modern)
|
|
||||||
- `r3f.md` — React Three Fiber component patterns
|
|
||||||
- `three.md` — Three.js conventions and AnimationMixer
|
|
||||||
- `gsap.md` — GSAP timeline and cinematic patterns
|
|
||||||
- `managers.md` — Singleton manager implementation
|
|
||||||
- `memory.md` — GPU memory and disposal rules
|
|
||||||
- `debug.md` — Debug utility and r3f-perf setup
|
|
||||||
@@ -75,6 +75,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: 🧰 Setup Node
|
- name: 🧰 Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: 🧰 Setup Node
|
- name: 🧰 Setup Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
public/models
|
||||||
|
public/**/*.glb
|
||||||
|
public/**/*.gltf
|
||||||
|
public/**/*.png
|
||||||
|
public/**/*.jpg
|
||||||
|
public/**/*.jpeg
|
||||||
|
public/**/*.webp
|
||||||
|
public/**/*.hdr
|
||||||
|
public/**/*.exr
|
||||||
|
public/**/*.ktx
|
||||||
|
public/**/*.ktx2
|
||||||
|
public/**/*.mp3
|
||||||
|
public/**/*.wav
|
||||||
|
public/**/*.ogg
|
||||||
|
public/**/*.mp4
|
||||||
|
public/**/*.webm
|
||||||
@@ -102,16 +102,19 @@ la-fabrik/
|
|||||||
│ ├── EventEmitter.ts # Simple typed pub/sub utility
|
│ ├── EventEmitter.ts # Simple typed pub/sub utility
|
||||||
│ ├── Sizes.ts # Viewport size tracking
|
│ ├── Sizes.ts # Viewport size tracking
|
||||||
│ ├── Time.ts # Animation frame timing utility
|
│ ├── Time.ts # Animation frame timing utility
|
||||||
│ ├── Readme.md
|
|
||||||
│ └── debug/ # Dev-only tools and scene inspection
|
│ └── debug/ # Dev-only tools and scene inspection
|
||||||
│ ├── Debug.ts # Global lil-gui manager
|
│ ├── Debug.ts # Global lil-gui manager
|
||||||
│ ├── DebugPerf.tsx # r3f-perf overlay mounted in Canvas
|
│ ├── DebugPerf.tsx # r3f-perf overlay mounted in Canvas
|
||||||
├── hooks/
|
│ ├── isDebugEnabled.ts # Debug query-string helper
|
||||||
│ └── debug/
|
|
||||||
│ └── useCameraMode.ts
|
|
||||||
│ └── scene/
|
│ └── scene/
|
||||||
│ ├── DebugHelpers.tsx # Grid + axes helpers shown in debug mode
|
│ ├── DebugHelpers.tsx # Grid + axes helpers shown in debug mode
|
||||||
│ └── DebugCameraControls.tsx # Free debug camera for map inspection
|
│ └── DebugCameraControls.tsx # Free debug camera for map inspection
|
||||||
|
├── hooks/
|
||||||
|
│ └── debug/
|
||||||
|
│ ├── useCameraMode.ts
|
||||||
|
│ ├── useDebugFolder.ts
|
||||||
|
│ ├── useDebugStore.ts
|
||||||
|
│ └── useSceneMode.ts
|
||||||
│
|
│
|
||||||
├── App.tsx # Canvas bootstrap
|
├── App.tsx # Canvas bootstrap
|
||||||
└── main.tsx
|
└── main.tsx
|
||||||
@@ -126,8 +129,8 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- `http://localhost:5173` for the app
|
- app: `http://localhost:5173`
|
||||||
- `http://localhost:5173?debug` to enable debug tooling
|
- debug mode: `http://localhost:5173?debug`
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,47 @@
|
|||||||
# Implemented Architecture
|
# Current Architecture
|
||||||
|
|
||||||
This document describes the code that exists today in the repository.
|
This document describes the code that exists today in the repository.
|
||||||
|
|
||||||
## Runtime Structure
|
## Runtime Structure
|
||||||
|
|
||||||
- `src/App.tsx` mounts the `Canvas`, the 3D `World`, the debug perf overlay, and the HTML crosshair overlay.
|
- `src/App.tsx` mounts the `Canvas`, the 3D `World`, the debug perf overlay, and the HTML overlays.
|
||||||
- `src/world/World.tsx` composes the active 3D scene.
|
- `src/world/World.tsx` composes the active scene, including:
|
||||||
- `src/world/Map.tsx` loads and centers the blocking map model.
|
- environment and lighting
|
||||||
- `src/world/Lighting.tsx` owns the current ambient and directional light setup.
|
- debug helpers and debug camera mode
|
||||||
- `src/world/Environment.tsx` owns the current background color.
|
- either the map scene or the debug physics test scene
|
||||||
- `src/world/player/FPSController.tsx` provides the current player camera, pointer lock, and `ZQSD` movement.
|
- the player rig when the active camera mode is `player`
|
||||||
- `src/utils/debug/` contains debug-only tooling such as `lil-gui`, scene helpers, and the free debug camera.
|
- `src/world/Map.tsx` loads the main map model and builds the collision octree.
|
||||||
- `src/components/ui/Crosshair.tsx` is the only current HTML overlay component in use.
|
- `src/world/debug/TestScene.tsx` provides a debug-oriented interaction and physics scene.
|
||||||
|
- `src/world/player/PlayerComponent.tsx` mounts the camera and controller.
|
||||||
|
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
|
||||||
|
|
||||||
## Camera Modes
|
## Interaction Model
|
||||||
|
|
||||||
The application currently has two camera modes:
|
- `src/stateManager/InteractionManager.ts` is the current interaction state source.
|
||||||
|
- `src/components/3d/InteractableObject.tsx` handles focus detection through distance and raycasting.
|
||||||
|
- `src/components/3d/TriggerObject.tsx` implements trigger-style interactions.
|
||||||
|
- `src/components/3d/GrabbableObject.tsx` implements hold-and-release interactions.
|
||||||
|
- `src/hooks/useInteraction.ts` exposes the interaction snapshot to React UI.
|
||||||
|
- `src/components/ui/InteractPrompt.tsx` shows the `E` prompt for trigger interactions.
|
||||||
|
|
||||||
- `player`
|
## Audio
|
||||||
- controlled by `FPSController`
|
|
||||||
- player height is `1.75m`
|
|
||||||
- movement uses `ZQSD`
|
|
||||||
- `E` is reserved for future interaction
|
|
||||||
- `debug`
|
|
||||||
- controlled by `DebugCameraControls`
|
|
||||||
- enabled from the debug panel
|
|
||||||
|
|
||||||
The active mode is stored in the debug subsystem and consumed through `src/hooks/debug/useCameraMode.ts`.
|
- `src/stateManager/AudioManager.ts` currently provides pooled one-shot sound playback.
|
||||||
|
- Trigger interactions may play audio directly through `AudioManager`.
|
||||||
|
|
||||||
## Debug System
|
## Debug System
|
||||||
|
|
||||||
- `src/utils/debug/Debug.ts` is a singleton wrapper around `lil-gui`
|
- Debug mode is enabled with `?debug`.
|
||||||
- `src/utils/debug/DebugPerf.tsx` lazy-loads `r3f-perf`
|
- `src/utils/debug/Debug.ts` owns the `lil-gui` instance and debug controls.
|
||||||
- `src/utils/debug/scene/DebugHelpers.tsx` mounts grid and axes in debug mode
|
- `src/hooks/debug/useCameraMode.ts` and `src/hooks/debug/useSceneMode.ts` subscribe to debug state.
|
||||||
- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free camera in debug mode
|
- `src/utils/debug/DebugPerf.tsx` lazily mounts `r3f-perf` in debug mode.
|
||||||
|
- `src/utils/debug/scene/DebugHelpers.tsx` mounts debug helpers.
|
||||||
|
- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
|
||||||
|
|
||||||
## Current Limitations
|
## Current Limitations
|
||||||
|
|
||||||
- There is no gameplay state manager implemented yet.
|
- The repository is still a prototype, not the full intended game runtime.
|
||||||
- There are no zone systems, missions, dialogue systems, or cinematic systems implemented yet.
|
- `src/world/debug/TestScene.tsx` is still part of the active scene composition.
|
||||||
- Player movement currently uses a simple height clamp instead of real collision or ground detection.
|
- There is no central gameplay orchestrator such as `GameManager` yet.
|
||||||
- The map is currently a blocking preview scene, not a full playable world.
|
- Missions, zones, cinematics, and dialogue systems are not implemented.
|
||||||
|
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
|
||||||
|
|||||||
@@ -2,60 +2,69 @@
|
|||||||
|
|
||||||
This document describes the intended medium-term architecture for the project.
|
This document describes the intended medium-term architecture for the project.
|
||||||
|
|
||||||
|
## Relationship To The Current Code
|
||||||
|
|
||||||
|
- `docs/technical/architecture.md` is the source of truth for what exists now.
|
||||||
|
- This document is intentionally aspirational.
|
||||||
|
- If this document conflicts with the current implementation, the current implementation wins.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- Keep `main` stable, `develop` as the integration branch, and `feat/*` for feature work.
|
- Keep `App.tsx` small and orchestration-oriented.
|
||||||
- Keep the runtime split between scene composition, gameplay systems, debug tooling, and HTML UI.
|
- Separate production world code from debug-only runtime paths.
|
||||||
- Keep one clear source of truth per concern.
|
- Keep one clear source of truth per concern.
|
||||||
|
- Grow gameplay systems incrementally instead of prebuilding empty architecture.
|
||||||
|
|
||||||
## Intended Layers
|
## Intended Layers
|
||||||
|
|
||||||
### App Layer
|
### App Layer
|
||||||
|
|
||||||
- `App.tsx` should stay small and orchestration-oriented.
|
- `App.tsx` mounts the canvas scene and top-level HTML overlays.
|
||||||
- It should mount the canvas scene and top-level HTML overlays.
|
- It should stay thin and avoid gameplay logic.
|
||||||
|
|
||||||
### World Layer
|
### World Layer
|
||||||
|
|
||||||
- `src/world/` should contain only production scene objects and scene composition.
|
- `src/world/` should contain production scene composition and production scene objects.
|
||||||
- Expected responsibilities:
|
- Expected responsibilities:
|
||||||
- world composition
|
- world composition
|
||||||
- map/environment/lighting
|
- map, environment, lighting
|
||||||
- player controller
|
- player controller
|
||||||
- zones
|
- production interaction anchors
|
||||||
- post-processing used in production
|
- production post-processing, if needed
|
||||||
|
|
||||||
### Debug Layer
|
### Debug Layer
|
||||||
|
|
||||||
- `src/utils/debug/` should contain only developer tooling.
|
- Debug-only scenes and tooling should be isolated from the production world path.
|
||||||
- Expected responsibilities:
|
- Expected responsibilities:
|
||||||
- `lil-gui`
|
- `lil-gui`
|
||||||
- performance overlay
|
- performance overlay
|
||||||
- scene helpers
|
- scene helpers
|
||||||
- free camera and calibration controls
|
- free camera and calibration controls
|
||||||
|
- temporary test scenes used during development
|
||||||
|
|
||||||
### UI Layer
|
### UI Layer
|
||||||
|
|
||||||
- `src/components/ui/` should contain HTML overlays used by the player.
|
- `src/components/ui/` should contain player-facing HTML overlays.
|
||||||
- Expected examples:
|
- Expected future examples:
|
||||||
- crosshair
|
- crosshair
|
||||||
- loading screen
|
- loading flow
|
||||||
- mission HUD
|
- mission HUD
|
||||||
- narrative overlays
|
- narrative overlays
|
||||||
|
|
||||||
### Gameplay Layer
|
### Gameplay Layer
|
||||||
|
|
||||||
- Gameplay state should eventually live in dedicated managers and thin hooks once those systems exist.
|
- As the project grows, gameplay state can move toward a clearer orchestration layer.
|
||||||
- Expected future concerns:
|
- Likely future concerns:
|
||||||
- missions
|
- missions
|
||||||
- zones
|
- zones
|
||||||
- cinematics
|
- cinematics
|
||||||
|
- dialogue
|
||||||
- audio
|
- audio
|
||||||
- interactions
|
- interactions
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- `world/` should not contain debug-only tooling.
|
- Prefer direct, working code over speculative scaffolding.
|
||||||
- `debug/` should not own production gameplay systems.
|
- Shared types should stay close to their domain until they have multiple real consumers.
|
||||||
- Shared types should live close to their domain and move outward only when they gain multiple real consumers.
|
- Avoid creating new managers or service layers without an active runtime need.
|
||||||
- New files should only be created when they have an active runtime purpose.
|
- Debug-only runtime paths should be clearly marked and easy to remove later.
|
||||||
|
|||||||
+28
-23
@@ -1,44 +1,49 @@
|
|||||||
# Implemented Features
|
# Implemented Features
|
||||||
|
|
||||||
This document lists features that are actually implemented in the current codebase.
|
This document lists features that are implemented in the current codebase.
|
||||||
|
|
||||||
## Scene Preview
|
## Scene
|
||||||
|
|
||||||
- Fullscreen React Three Fiber scene
|
- Fullscreen React Three Fiber scene
|
||||||
- Blocking map loaded from `public/models/map/blocking/model.glb`
|
- Main map scene loaded from `public/models/map/model.gltf`
|
||||||
|
- Debug physics test scene selectable from the debug panel
|
||||||
- Ambient and directional lighting
|
- Ambient and directional lighting
|
||||||
- Solid background environment color
|
- Environment background setup
|
||||||
|
|
||||||
## Camera Modes
|
## Player
|
||||||
|
|
||||||
- Player camera mode
|
- Player camera mode
|
||||||
- eye height at `1.75m`
|
- Pointer lock mouse look
|
||||||
- pointer lock mouse look
|
- Movement with `ZQSD`
|
||||||
- movement with `ZQSD`
|
- Jumping
|
||||||
- vertical clamp to prevent falling below the map plane
|
- Octree-based collision against the loaded map
|
||||||
- Debug camera mode
|
|
||||||
- free orbit camera
|
|
||||||
- switchable from the debug panel
|
|
||||||
|
|
||||||
## UI
|
## Interactions
|
||||||
|
|
||||||
- Center-screen crosshair shown only in player mode
|
- Focus detection by distance and raycast
|
||||||
|
- Trigger interactions activated with `E`
|
||||||
|
- Grab interactions activated with the primary mouse button
|
||||||
|
- Interaction prompt shown for trigger interactions
|
||||||
|
|
||||||
|
## Audio
|
||||||
|
|
||||||
|
- One-shot sound playback for trigger interactions
|
||||||
|
- Simple per-sound pooling through `AudioManager`
|
||||||
|
|
||||||
## Debug Tooling
|
## Debug Tooling
|
||||||
|
|
||||||
- `?debug` query param enables the debug panel
|
- `?debug` query param enables the debug panel
|
||||||
- `lil-gui` panel with camera mode selection
|
- `lil-gui` controls for camera mode, scene mode, and interaction spheres
|
||||||
- debug lighting controls
|
- Debug scene helpers
|
||||||
- debug scene helpers
|
- Free debug camera
|
||||||
- `r3f-perf` overlay
|
- `r3f-perf` overlay
|
||||||
|
|
||||||
## Not Implemented Yet
|
## Not Implemented Yet
|
||||||
|
|
||||||
- missions
|
- mission system
|
||||||
- interactions on `E`
|
- zone system
|
||||||
- gameplay zones
|
- cinematic system
|
||||||
- cinematics
|
- dialogue system
|
||||||
- audio systems
|
|
||||||
- loading flow
|
- loading flow
|
||||||
- minimap and mission HUD
|
- minimap and mission HUD
|
||||||
- collisions beyond the current simple player height clamp
|
- full production separation between gameplay and debug scenes
|
||||||
|
|||||||
Generated
+219
-1653
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -19,6 +19,7 @@
|
|||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"@react-three/rapier": "^2.2.0",
|
"@react-three/rapier": "^2.2.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"lil-gui": "^0.21.0",
|
||||||
"r3f-perf": "^7.2.3",
|
"r3f-perf": "^7.2.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -37,7 +38,6 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"lil-gui": "^0.21.0",
|
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.8.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d623439755187553ac394395a4e8734e4ea7ca950c94b50649d2b00e6461387e
|
||||||
|
size 80888
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b3535a67501bb43ccf233a25e98b20b3804e29f1fe7ef8ba821bbdd00b98f140
|
oid sha256:838b942fbdb16367386f3f2a3cc6b13c363874ac02ae16e623654fcdfea609ea
|
||||||
size 3279070
|
size 3220908
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:904b303c98f865526b9524b955f440e630f29d8e18a57bb7bf443fcd9715add1
|
||||||
|
size 83079911
|
||||||
+5
-2
@@ -1,4 +1,5 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { Crosshair } from "@/components/ui/Crosshair";
|
import { Crosshair } from "@/components/ui/Crosshair";
|
||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
@@ -14,8 +15,10 @@ function App(): React.JSX.Element {
|
|||||||
element={
|
element={
|
||||||
<>
|
<>
|
||||||
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
|
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
|
||||||
<World />
|
<Suspense fallback={null}>
|
||||||
<DebugPerf />
|
<World />
|
||||||
|
<DebugPerf />
|
||||||
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<Crosshair />
|
<Crosshair />
|
||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { RigidBody } from "@react-three/rapier";
|
||||||
|
import type { RapierRigidBody } from "@react-three/rapier";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||||
|
import {
|
||||||
|
GRAB_DEFAULT_COLLIDERS,
|
||||||
|
GRAB_DEFAULT_LABEL,
|
||||||
|
GRAB_HOLD_DISTANCE_DEFAULT,
|
||||||
|
GRAB_HOLD_DISTANCE_MAX,
|
||||||
|
GRAB_HOLD_DISTANCE_MIN,
|
||||||
|
GRAB_HOLD_DISTANCE_STEP,
|
||||||
|
GRAB_STIFFNESS_DEFAULT,
|
||||||
|
GRAB_STIFFNESS_MAX,
|
||||||
|
GRAB_STIFFNESS_MIN,
|
||||||
|
GRAB_STIFFNESS_STEP,
|
||||||
|
GRAB_THROW_BOOST_DEFAULT,
|
||||||
|
GRAB_THROW_BOOST_MAX,
|
||||||
|
GRAB_THROW_BOOST_MIN,
|
||||||
|
GRAB_THROW_BOOST_STEP,
|
||||||
|
} from "@/data/grabConfig";
|
||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
|
interface GrabbableObjectProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
children: React.ReactNode;
|
||||||
|
colliders?: ColliderShape;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared params let one debug folder drive every instance.
|
||||||
|
const params = {
|
||||||
|
stiffness: GRAB_STIFFNESS_DEFAULT,
|
||||||
|
throwBoost: GRAB_THROW_BOOST_DEFAULT,
|
||||||
|
holdDistance: GRAB_HOLD_DISTANCE_DEFAULT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO_ANGULAR_VELOCITY = { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
|
const _holdTarget = new THREE.Vector3();
|
||||||
|
const _currentPos = new THREE.Vector3();
|
||||||
|
const _velocity = new THREE.Vector3();
|
||||||
|
|
||||||
|
export function GrabbableObject({
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||||
|
label = GRAB_DEFAULT_LABEL,
|
||||||
|
}: GrabbableObjectProps): React.JSX.Element {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
const rbRef = useRef<RapierRigidBody>(null);
|
||||||
|
const isHolding = useRef(false);
|
||||||
|
|
||||||
|
useDebugFolder("GrabbableObject", (folder) => {
|
||||||
|
folder
|
||||||
|
.add(
|
||||||
|
params,
|
||||||
|
"stiffness",
|
||||||
|
GRAB_STIFFNESS_MIN,
|
||||||
|
GRAB_STIFFNESS_MAX,
|
||||||
|
GRAB_STIFFNESS_STEP,
|
||||||
|
)
|
||||||
|
.name("Hold stiffness");
|
||||||
|
folder
|
||||||
|
.add(
|
||||||
|
params,
|
||||||
|
"throwBoost",
|
||||||
|
GRAB_THROW_BOOST_MIN,
|
||||||
|
GRAB_THROW_BOOST_MAX,
|
||||||
|
GRAB_THROW_BOOST_STEP,
|
||||||
|
)
|
||||||
|
.name("Throw boost");
|
||||||
|
folder
|
||||||
|
.add(
|
||||||
|
params,
|
||||||
|
"holdDistance",
|
||||||
|
GRAB_HOLD_DISTANCE_MIN,
|
||||||
|
GRAB_HOLD_DISTANCE_MAX,
|
||||||
|
GRAB_HOLD_DISTANCE_STEP,
|
||||||
|
)
|
||||||
|
.name("Hold distance");
|
||||||
|
});
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (!isHolding.current || !rbRef.current) return;
|
||||||
|
|
||||||
|
camera.getWorldDirection(_holdTarget);
|
||||||
|
_holdTarget.multiplyScalar(params.holdDistance).add(camera.position);
|
||||||
|
|
||||||
|
const t = rbRef.current.translation();
|
||||||
|
_currentPos.set(t.x, t.y, t.z);
|
||||||
|
|
||||||
|
_velocity
|
||||||
|
.subVectors(_holdTarget, _currentPos)
|
||||||
|
.multiplyScalar(params.stiffness);
|
||||||
|
|
||||||
|
rbRef.current.setLinvel(
|
||||||
|
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
rbRef.current.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RigidBody
|
||||||
|
ref={rbRef}
|
||||||
|
type="dynamic"
|
||||||
|
colliders={colliders}
|
||||||
|
position={position}
|
||||||
|
>
|
||||||
|
<InteractableObject
|
||||||
|
kind="grab"
|
||||||
|
label={label}
|
||||||
|
position={position}
|
||||||
|
bodyRef={rbRef}
|
||||||
|
onPress={() => {
|
||||||
|
isHolding.current = true;
|
||||||
|
}}
|
||||||
|
onRelease={() => {
|
||||||
|
isHolding.current = false;
|
||||||
|
if (!rbRef.current || params.throwBoost === GRAB_THROW_BOOST_DEFAULT)
|
||||||
|
return;
|
||||||
|
const v = rbRef.current.linvel();
|
||||||
|
rbRef.current.setLinvel(
|
||||||
|
{
|
||||||
|
x: v.x * params.throwBoost,
|
||||||
|
y: v.y * params.throwBoost,
|
||||||
|
z: v.z * params.throwBoost,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</InteractableObject>
|
||||||
|
</RigidBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,73 +1,94 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { RigidBody } from "@react-three/rapier";
|
|
||||||
import type { RapierRigidBody } from "@react-three/rapier";
|
import type { RapierRigidBody } from "@react-three/rapier";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import type GUI from "lil-gui";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
|
import {
|
||||||
|
INTERACTION_DEBUG_SPHERE_COLOR,
|
||||||
|
INTERACTION_DEBUG_SPHERE_OPACITY,
|
||||||
|
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||||
|
} from "@/data/debugConfig";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import {
|
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||||
InteractionManager,
|
|
||||||
type InteractableHandle,
|
|
||||||
type InteractableKind,
|
|
||||||
} from "@/stateManager/InteractionManager";
|
|
||||||
import { INTERACTION_RADIUS } from "@/data/interactionConfig";
|
import { INTERACTION_RADIUS } from "@/data/interactionConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
|
import type { InteractableHandle, InteractableKind } from "@/types/interaction";
|
||||||
|
|
||||||
interface InteractableObjectProps {
|
interface InteractableObjectBaseProps {
|
||||||
kind: InteractableKind;
|
|
||||||
label: string;
|
label: string;
|
||||||
position: [number, number, number];
|
position: Vector3Tuple;
|
||||||
rigidBodyType?: "dynamic" | "fixed";
|
bodyRef?: RefObject<RapierRigidBody | null>;
|
||||||
colliders?: "cuboid" | "ball" | "hull";
|
|
||||||
rbRef?: RefObject<RapierRigidBody | null>;
|
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onRelease?: () => void;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TriggerInteractableObjectProps extends InteractableObjectBaseProps {
|
||||||
|
kind: "trigger";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrabInteractableObjectProps extends InteractableObjectBaseProps {
|
||||||
|
kind: "grab";
|
||||||
|
onRelease: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InteractableObjectProps =
|
||||||
|
| TriggerInteractableObjectProps
|
||||||
|
| GrabInteractableObjectProps;
|
||||||
|
|
||||||
|
type MutableInteractableHandle = {
|
||||||
|
kind: InteractableKind;
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
onRelease?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
const _cameraPos = new THREE.Vector3();
|
const _cameraPos = new THREE.Vector3();
|
||||||
const _cameraDir = new THREE.Vector3();
|
const _cameraDir = new THREE.Vector3();
|
||||||
const _objectPos = new THREE.Vector3();
|
const _objectPos = new THREE.Vector3();
|
||||||
const _raycaster = new THREE.Raycaster();
|
const _raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
export function InteractableObject({
|
export function InteractableObject(
|
||||||
kind,
|
props: InteractableObjectProps,
|
||||||
label,
|
): React.JSX.Element {
|
||||||
position,
|
const { kind, label, position, bodyRef, onPress, children } = props;
|
||||||
rigidBodyType = "dynamic",
|
const onRelease = props.kind === "grab" ? props.onRelease : undefined;
|
||||||
colliders = "cuboid",
|
|
||||||
rbRef,
|
|
||||||
onPress,
|
|
||||||
onRelease = () => {},
|
|
||||||
children,
|
|
||||||
}: InteractableObjectProps): React.JSX.Element {
|
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const internalRef = useRef<RapierRigidBody>(null);
|
|
||||||
const bodyRef = rbRef ?? internalRef;
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const debugSphereRef = useRef<THREE.Mesh>(null);
|
const debugSphereRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
const handle = useRef<InteractableHandle>({
|
const handle = useRef<InteractableHandle>(
|
||||||
kind,
|
props.kind === "grab"
|
||||||
label,
|
? { kind: props.kind, label, onPress, onRelease: props.onRelease }
|
||||||
onPress,
|
: { kind: props.kind, label, onPress },
|
||||||
onRelease,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handle.current.onPress = onPress;
|
const current = handle.current as MutableInteractableHandle;
|
||||||
handle.current.onRelease = onRelease;
|
current.kind = kind;
|
||||||
});
|
current.label = label;
|
||||||
|
current.onPress = onPress;
|
||||||
|
|
||||||
useDebugFolder("Interaction", (folder) => {
|
if (kind === "grab" && onRelease) {
|
||||||
|
current.onRelease = onRelease;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete current.onRelease;
|
||||||
|
return undefined;
|
||||||
|
}, [kind, label, onPress, onRelease]);
|
||||||
|
|
||||||
|
const setupInteractionDebugFolder = useCallback((folder: GUI) => {
|
||||||
folder
|
folder
|
||||||
.add({ radius: INTERACTION_RADIUS }, "radius")
|
.add({ radius: INTERACTION_RADIUS }, "radius")
|
||||||
.name("Interaction radius")
|
.name("Interaction radius")
|
||||||
.disable();
|
.disable();
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
useDebugFolder("Interaction", setupInteractionDebugFolder);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const body = bodyRef.current;
|
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
const manager = InteractionManager.getInstance();
|
const manager = InteractionManager.getInstance();
|
||||||
@@ -77,8 +98,8 @@ export function InteractableObject({
|
|||||||
debug.active && debug.getShowInteractionSpheres();
|
debug.active && debug.getShowInteractionSpheres();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body) {
|
if (bodyRef?.current) {
|
||||||
const t = body.translation();
|
const t = bodyRef.current.translation();
|
||||||
_objectPos.set(t.x, t.y, t.z);
|
_objectPos.set(t.x, t.y, t.z);
|
||||||
} else {
|
} else {
|
||||||
_objectPos.set(...position);
|
_objectPos.set(...position);
|
||||||
@@ -99,7 +120,6 @@ export function InteractableObject({
|
|||||||
_raycaster.far = INTERACTION_RADIUS;
|
_raycaster.far = INTERACTION_RADIUS;
|
||||||
|
|
||||||
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
||||||
|
|
||||||
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
||||||
|
|
||||||
if (validHit) {
|
if (validHit) {
|
||||||
@@ -110,24 +130,23 @@ export function InteractableObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RigidBody
|
<group ref={groupRef}>
|
||||||
ref={bodyRef}
|
{children}
|
||||||
type={rigidBodyType}
|
<mesh ref={debugSphereRef} visible={false}>
|
||||||
colliders={colliders}
|
<sphereGeometry
|
||||||
position={position}
|
args={[
|
||||||
>
|
INTERACTION_RADIUS,
|
||||||
<group ref={groupRef}>
|
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||||
{children}
|
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||||
<mesh ref={debugSphereRef} visible={false}>
|
]}
|
||||||
<sphereGeometry args={[INTERACTION_RADIUS, 16, 16]} />
|
/>
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#facc15"
|
color={INTERACTION_DEBUG_SPHERE_COLOR}
|
||||||
wireframe
|
wireframe
|
||||||
transparent
|
transparent
|
||||||
opacity={0.25}
|
opacity={INTERACTION_DEBUG_SPHERE_OPACITY}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
</RigidBody>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import { RigidBody } from "@react-three/rapier";
|
||||||
|
import { InteractableObject } from "@/components/3d/InteractableObject";
|
||||||
|
import {
|
||||||
|
TRIGGER_DEFAULT_COLLIDERS,
|
||||||
|
TRIGGER_DEFAULT_LABEL,
|
||||||
|
TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||||
|
TRIGGER_DEFAULT_SPAWN_OFFSET,
|
||||||
|
} from "@/data/triggerConfig";
|
||||||
|
import { AudioManager } from "@/stateManager/AudioManager";
|
||||||
|
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
|
interface SpawnedModel {
|
||||||
|
id: number;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TriggerObjectProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
children: React.ReactNode;
|
||||||
|
colliders?: ColliderShape;
|
||||||
|
label?: string;
|
||||||
|
soundPath?: string;
|
||||||
|
soundVolume?: number;
|
||||||
|
spawnModel?: string;
|
||||||
|
spawnOffset?: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _spawnCounter = 0;
|
||||||
|
|
||||||
|
function SpawnedModelInstance({
|
||||||
|
path,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { scene } = useGLTF(path);
|
||||||
|
return <primitive object={scene.clone()} position={position} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TriggerObject({
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
||||||
|
label = TRIGGER_DEFAULT_LABEL,
|
||||||
|
soundPath,
|
||||||
|
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||||
|
spawnModel,
|
||||||
|
spawnOffset = TRIGGER_DEFAULT_SPAWN_OFFSET,
|
||||||
|
}: TriggerObjectProps): React.JSX.Element {
|
||||||
|
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RigidBody type="fixed" colliders={colliders} position={position}>
|
||||||
|
<InteractableObject
|
||||||
|
kind="trigger"
|
||||||
|
label={label}
|
||||||
|
position={position}
|
||||||
|
onPress={() => {
|
||||||
|
if (soundPath) {
|
||||||
|
AudioManager.getInstance().playSound(soundPath, soundVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnModel) {
|
||||||
|
const spawnPos: Vector3Tuple = [
|
||||||
|
position[0] + spawnOffset[0],
|
||||||
|
position[1] + spawnOffset[1],
|
||||||
|
position[2] + spawnOffset[2],
|
||||||
|
];
|
||||||
|
setSpawned((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: ++_spawnCounter, position: spawnPos },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</InteractableObject>
|
||||||
|
</RigidBody>
|
||||||
|
|
||||||
|
{spawnModel &&
|
||||||
|
spawned.map((s) => (
|
||||||
|
<SpawnedModelInstance
|
||||||
|
key={s.id}
|
||||||
|
path={spawnModel}
|
||||||
|
position={s.position}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { INTERACT_KEY } from "@/data/keybindings";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useInteraction } from "@/hooks/useInteraction";
|
import { useInteraction } from "@/hooks/useInteraction";
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ export function InteractPrompt(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="interact-prompt" aria-live="polite">
|
<div className="interact-prompt" aria-live="polite">
|
||||||
<kbd className="interact-prompt__key">E</kbd>
|
<kbd className="interact-prompt__key">{INTERACT_KEY.toUpperCase()}</kbd>
|
||||||
<span className="interact-prompt__label">{focused.label}</span>
|
<span className="interact-prompt__label">{focused.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const INTERACTION_DEBUG_SPHERE_SEGMENTS = 16;
|
||||||
|
export const INTERACTION_DEBUG_SPHERE_COLOR = "#facc15";
|
||||||
|
export const INTERACTION_DEBUG_SPHERE_OPACITY = 0.25;
|
||||||
|
|
||||||
|
export const MAP_DEBUG_BOX_HELPER_COLOR = 0x00ff88;
|
||||||
|
|
||||||
|
export const DEBUG_CAMERA_DAMPING_FACTOR = 0.05;
|
||||||
|
export const DEBUG_CAMERA_MIN_DISTANCE = 100;
|
||||||
|
export const DEBUG_CAMERA_MAX_DISTANCE = 1000;
|
||||||
|
|
||||||
|
export const DEBUG_GRID_SIZE = 180;
|
||||||
|
export const DEBUG_GRID_DIVISIONS = 36;
|
||||||
|
export const DEBUG_GRID_PRIMARY_COLOR = "#1d4ed8";
|
||||||
|
export const DEBUG_GRID_SECONDARY_COLOR = "#1e293b";
|
||||||
|
export const DEBUG_GRID_Y = 0.01;
|
||||||
|
export const DEBUG_AXES_SIZE = 10;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const GAME_SCENE_SKYBOX_PATH = "/skybox/sky.exr";
|
||||||
|
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const GRAB_DEFAULT_COLLIDERS = "cuboid";
|
||||||
|
export const GRAB_DEFAULT_LABEL = "Prendre";
|
||||||
|
|
||||||
|
export const GRAB_STIFFNESS_DEFAULT = 15;
|
||||||
|
export const GRAB_THROW_BOOST_DEFAULT = 1.0;
|
||||||
|
export const GRAB_HOLD_DISTANCE_DEFAULT = 2;
|
||||||
|
|
||||||
|
export const GRAB_STIFFNESS_MIN = 1;
|
||||||
|
export const GRAB_STIFFNESS_MAX = 50;
|
||||||
|
export const GRAB_STIFFNESS_STEP = 1;
|
||||||
|
|
||||||
|
export const GRAB_THROW_BOOST_MIN = 0.5;
|
||||||
|
export const GRAB_THROW_BOOST_MAX = 3.0;
|
||||||
|
export const GRAB_THROW_BOOST_STEP = 0.1;
|
||||||
|
|
||||||
|
export const GRAB_HOLD_DISTANCE_MIN = 0.5;
|
||||||
|
export const GRAB_HOLD_DISTANCE_MAX = 5.0;
|
||||||
|
export const GRAB_HOLD_DISTANCE_STEP = 0.1;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const MOVE_FORWARD_KEY = "z";
|
||||||
|
export const MOVE_BACKWARD_KEY = "s";
|
||||||
|
export const MOVE_LEFT_KEY = "q";
|
||||||
|
export const MOVE_RIGHT_KEY = "d";
|
||||||
|
export const JUMP_KEY = " ";
|
||||||
|
export const INTERACT_KEY = "e";
|
||||||
|
export const PRIMARY_INTERACT_MOUSE_BUTTON = 0;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const AMBIENT_LIGHT_COLOR = "#dbeafe";
|
||||||
|
export const SUN_LIGHT_COLOR = "#fff7ed";
|
||||||
|
|
||||||
|
export const LIGHTING_DEFAULTS = {
|
||||||
|
ambientIntensity: 1.8,
|
||||||
|
sunIntensity: 2.8,
|
||||||
|
sunX: 60,
|
||||||
|
sunY: 80,
|
||||||
|
sunZ: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AMBIENT_INTENSITY_MIN = 0;
|
||||||
|
export const AMBIENT_INTENSITY_MAX = 5;
|
||||||
|
export const AMBIENT_INTENSITY_STEP = 0.1;
|
||||||
|
|
||||||
|
export const SUN_INTENSITY_MIN = 0;
|
||||||
|
export const SUN_INTENSITY_MAX = 8;
|
||||||
|
export const SUN_INTENSITY_STEP = 0.1;
|
||||||
|
|
||||||
|
export const SUN_X_MIN = -100;
|
||||||
|
export const SUN_X_MAX = 100;
|
||||||
|
export const SUN_X_STEP = 1;
|
||||||
|
|
||||||
|
export const SUN_Y_MIN = 0;
|
||||||
|
export const SUN_Y_MAX = 150;
|
||||||
|
export const SUN_Y_STEP = 1;
|
||||||
|
|
||||||
|
export const SUN_Z_MIN = -100;
|
||||||
|
export const SUN_Z_MAX = 100;
|
||||||
|
export const SUN_Z_STEP = 1;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
|
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||||
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
|
export const PLAYER_WALK_SPEED = 11;
|
||||||
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
|
export const PLAYER_GRAVITY = 30;
|
||||||
|
export const PLAYER_MAX_DELTA = 0.05;
|
||||||
|
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
|
||||||
|
export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||||
|
|
||||||
|
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 100, 0];
|
||||||
|
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
|
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||||
|
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||||
|
export const TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS: Vector3Tuple = [
|
||||||
|
100, 0.5, 100,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TEST_SCENE_GRABBABLE_POSITION: Vector3Tuple = [0, 1, -3];
|
||||||
|
export const TEST_SCENE_GRABBABLE_BOX_SIZE: Vector3Tuple = [0.5, 0.5, 0.5];
|
||||||
|
export const TEST_SCENE_GRABBABLE_COLOR = "#e07b39";
|
||||||
|
export const TEST_SCENE_GRABBABLE_ROUGHNESS = 0.6;
|
||||||
|
export const TEST_SCENE_GRABBABLE_METALNESS = 0.1;
|
||||||
|
|
||||||
|
export const TEST_SCENE_TRIGGER_POSITION: Vector3Tuple = [3, 2, -3];
|
||||||
|
export const TEST_SCENE_TRIGGER_SOUND_PATH = "/sounds/fa.mp3";
|
||||||
|
export const TEST_SCENE_TRIGGER_RADIUS = 0.4;
|
||||||
|
export const TEST_SCENE_TRIGGER_SEGMENTS = 32;
|
||||||
|
export const TEST_SCENE_TRIGGER_COLOR = "#3b82f6";
|
||||||
|
export const TEST_SCENE_TRIGGER_ROUGHNESS = 0.3;
|
||||||
|
export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
|
export const TRIGGER_DEFAULT_COLLIDERS = "ball";
|
||||||
|
export const TRIGGER_DEFAULT_LABEL = "Interagir";
|
||||||
|
export const TRIGGER_DEFAULT_SOUND_VOLUME = 1;
|
||||||
|
export const TRIGGER_DEFAULT_SPAWN_OFFSET: Vector3Tuple = [0, 0, 0];
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
|
||||||
import type { CameraMode } from "@/types/debug";
|
import type { CameraMode } from "@/types/debug";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||||
|
|
||||||
export function useCameraMode(): CameraMode {
|
export function useCameraMode(): CameraMode {
|
||||||
const debug = Debug.getInstance();
|
return useDebugStore((debug) => debug.getCameraMode());
|
||||||
|
|
||||||
return useSyncExternalStore(
|
|
||||||
(listener) => debug.subscribe(listener),
|
|
||||||
() => debug.getCameraMode(),
|
|
||||||
() => debug.getCameraMode(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type GUI from "lil-gui";
|
import type GUI from "lil-gui";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
|
|
||||||
@@ -6,12 +6,23 @@ export function useDebugFolder(
|
|||||||
name: string,
|
name: string,
|
||||||
setup: (folder: GUI) => void,
|
setup: (folder: GUI) => void,
|
||||||
): void {
|
): void {
|
||||||
|
const setupRef = useRef(setup);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setupRef.current = setup;
|
||||||
|
}, [setup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
if (!debug.active) return;
|
if (!debug.active) return;
|
||||||
|
|
||||||
const folder = debug.createFolder(name);
|
const folder = debug.createFolder(name);
|
||||||
if (!folder) return;
|
if (folder) {
|
||||||
setup(folder);
|
setupRef.current(folder);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, []);
|
|
||||||
|
return () => {
|
||||||
|
debug.destroyFolder(name);
|
||||||
|
};
|
||||||
|
}, [name]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
|
|
||||||
|
export function useDebugStore<T>(selector: (debug: Debug) => T): T {
|
||||||
|
const debug = Debug.getInstance();
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => debug.subscribe(listener),
|
||||||
|
() => selector(debug),
|
||||||
|
() => selector(debug),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
|
||||||
import type { SceneMode } from "@/types/debug";
|
import type { SceneMode } from "@/types/debug";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||||
|
|
||||||
export function useSceneMode(): SceneMode {
|
export function useSceneMode(): SceneMode {
|
||||||
const debug = Debug.getInstance();
|
return useDebugStore((debug) => debug.getSceneMode());
|
||||||
|
|
||||||
return useSyncExternalStore(
|
|
||||||
(listener) => debug.subscribe(listener),
|
|
||||||
() => debug.getSceneMode(),
|
|
||||||
() => debug.getSceneMode(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import {
|
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||||
InteractionManager,
|
import type { InteractionSnapshot } from "@/types/interaction";
|
||||||
type InteractionSnapshot,
|
|
||||||
} from "@/stateManager/InteractionManager";
|
const manager = InteractionManager.getInstance();
|
||||||
|
|
||||||
export function useInteraction(): InteractionSnapshot {
|
export function useInteraction(): InteractionSnapshot {
|
||||||
const manager = InteractionManager.getInstance();
|
return useSyncExternalStore(
|
||||||
const [state, setState] = useState<InteractionSnapshot>(manager.getState());
|
manager.subscribe.bind(manager),
|
||||||
|
manager.getState.bind(manager),
|
||||||
useEffect(() => {
|
);
|
||||||
return manager.subscribe(() => {
|
|
||||||
setState({ ...manager.getState() });
|
|
||||||
});
|
|
||||||
}, [manager]);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { Object3D } from "three";
|
||||||
|
import { Octree } from "three/addons/math/Octree.js";
|
||||||
|
import type { OctreeReadyHandler } from "@/types/3d";
|
||||||
|
|
||||||
|
export function useOctreeGraphNode(
|
||||||
|
graphNodeRef: RefObject<Object3D | null>,
|
||||||
|
onOctreeReady: OctreeReadyHandler,
|
||||||
|
): void {
|
||||||
|
const octreeBuilt = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const graphNode = graphNodeRef.current;
|
||||||
|
if (octreeBuilt.current || !graphNode) return;
|
||||||
|
octreeBuilt.current = true;
|
||||||
|
|
||||||
|
graphNode.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
const octree = new Octree();
|
||||||
|
octree.fromGraphNode(graphNode);
|
||||||
|
onOctreeReady(octree);
|
||||||
|
}, [graphNodeRef, onOctreeReady]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
|
||||||
export class AudioManager {
|
export class AudioManager {
|
||||||
private static _instance: AudioManager | null = null;
|
private static _instance: AudioManager | null = null;
|
||||||
|
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
||||||
|
|
||||||
|
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
||||||
|
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
||||||
|
"AbortError",
|
||||||
|
"NotAllowedError",
|
||||||
|
]);
|
||||||
|
|
||||||
static getInstance(): AudioManager {
|
static getInstance(): AudioManager {
|
||||||
if (!AudioManager._instance) {
|
if (!AudioManager._instance) {
|
||||||
@@ -12,12 +21,65 @@ export class AudioManager {
|
|||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
playSound(path: string, volume = 1): void {
|
playSound(path: string, volume = 1): void {
|
||||||
const audio = new Audio(path);
|
const audio = this._acquireAudio(path);
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
void audio.play();
|
audio.currentTime = 0;
|
||||||
|
|
||||||
|
void audio.play().catch((error: unknown) => {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
AudioManager.IGNORED_PLAYBACK_ERRORS.has(error.name)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("AudioManager", "Failed to play sound", {
|
||||||
|
path,
|
||||||
|
error: AudioManager._toLogValue(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this._audioPools.forEach((pool) => {
|
||||||
|
pool.forEach((audio) => {
|
||||||
|
audio.pause();
|
||||||
|
audio.src = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._audioPools.clear();
|
||||||
AudioManager._instance = null;
|
AudioManager._instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _acquireAudio(path: string): HTMLAudioElement {
|
||||||
|
const existingPool = this._audioPools.get(path);
|
||||||
|
|
||||||
|
if (existingPool) {
|
||||||
|
const availableAudio = existingPool.find(
|
||||||
|
(audio) => audio.paused || audio.ended,
|
||||||
|
);
|
||||||
|
if (availableAudio) return availableAudio;
|
||||||
|
|
||||||
|
if (existingPool.length < AudioManager.MAX_POOL_SIZE_PER_SOUND) {
|
||||||
|
const pooledAudio = new Audio(path);
|
||||||
|
existingPool.push(pooledAudio);
|
||||||
|
return pooledAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recycledAudio = existingPool[0];
|
||||||
|
if (recycledAudio) return recycledAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialAudio = new Audio(path);
|
||||||
|
this._audioPools.set(path, [initialAudio]);
|
||||||
|
return initialAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _toLogValue(error: unknown): Error | DOMException | string {
|
||||||
|
if (error instanceof Error || error instanceof DOMException) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
export type InteractableKind = "grab" | "trigger";
|
import type {
|
||||||
|
GrabInteractableHandle,
|
||||||
export interface InteractableHandle {
|
InteractableHandle,
|
||||||
kind: InteractableKind;
|
InteractionSnapshot,
|
||||||
label: string;
|
} from "@/types/interaction";
|
||||||
onPress: () => void;
|
|
||||||
onRelease: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InteractionSnapshot {
|
|
||||||
focused: InteractableHandle | null;
|
|
||||||
holding: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InteractionManager {
|
export class InteractionManager {
|
||||||
private static _instance: InteractionManager | null = null;
|
private static _instance: InteractionManager | null = null;
|
||||||
|
|
||||||
private _focused: InteractableHandle | null = null;
|
private _focused: InteractableHandle | null = null;
|
||||||
private _holding = false;
|
private _holding = false;
|
||||||
private _holdingHandle: InteractableHandle | null = null;
|
private _holdingHandle: GrabInteractableHandle | null = null;
|
||||||
|
private _snapshot: InteractionSnapshot = {
|
||||||
|
focused: null,
|
||||||
|
holding: false,
|
||||||
|
};
|
||||||
private readonly _listeners = new Set<() => void>();
|
private readonly _listeners = new Set<() => void>();
|
||||||
|
|
||||||
static getInstance(): InteractionManager {
|
static getInstance(): InteractionManager {
|
||||||
@@ -31,20 +27,13 @@ export class InteractionManager {
|
|||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
getState(): InteractionSnapshot {
|
getState(): InteractionSnapshot {
|
||||||
return {
|
return this._snapshot;
|
||||||
focused: this._focused,
|
|
||||||
holding: this._holding,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFocused(handle: InteractableHandle | null): void {
|
setFocused(handle: InteractableHandle | null): void {
|
||||||
if (this._focused === handle) return;
|
if (this._focused === handle) return;
|
||||||
// Never interrupt an active grab via focus change
|
if (this._holding) return;
|
||||||
if (this._holding) {
|
|
||||||
this._focused = handle;
|
|
||||||
this._emit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._focused = handle;
|
this._focused = handle;
|
||||||
this._emit();
|
this._emit();
|
||||||
}
|
}
|
||||||
@@ -52,14 +41,20 @@ export class InteractionManager {
|
|||||||
pressInteract(): void {
|
pressInteract(): void {
|
||||||
if (!this._focused) return;
|
if (!this._focused) return;
|
||||||
|
|
||||||
this._holding = this._focused.kind === "grab";
|
if (this._focused.kind === "grab") {
|
||||||
if (this._holding) this._holdingHandle = this._focused;
|
this._holding = true;
|
||||||
|
this._holdingHandle = this._focused;
|
||||||
|
} else {
|
||||||
|
this._holding = false;
|
||||||
|
this._holdingHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
this._focused.onPress();
|
this._focused.onPress();
|
||||||
this._emit();
|
this._emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseInteract(): void {
|
releaseInteract(): void {
|
||||||
const handle = this._holdingHandle ?? this._focused;
|
const handle = this._holding ? this._holdingHandle : null;
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
|
|
||||||
handle.onRelease();
|
handle.onRelease();
|
||||||
@@ -77,11 +72,22 @@ export class InteractionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this._focused = null;
|
||||||
|
this._holding = false;
|
||||||
|
this._holdingHandle = null;
|
||||||
|
this._snapshot = {
|
||||||
|
focused: null,
|
||||||
|
holding: false,
|
||||||
|
};
|
||||||
this._listeners.clear();
|
this._listeners.clear();
|
||||||
InteractionManager._instance = null;
|
InteractionManager._instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _emit(): void {
|
private _emit(): void {
|
||||||
|
this._snapshot = {
|
||||||
|
focused: this._focused,
|
||||||
|
holding: this._holding,
|
||||||
|
};
|
||||||
this._listeners.forEach((cb) => cb());
|
this._listeners.forEach((cb) => cb());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Octree } from "three/addons/math/Octree.js";
|
||||||
|
|
||||||
|
export type Vector3Tuple = [number, number, number];
|
||||||
|
|
||||||
|
export type ColliderShape = "cuboid" | "ball" | "hull";
|
||||||
|
|
||||||
|
export type OctreeReadyHandler = (octree: Octree) => void;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type InteractableKind = "grab" | "trigger";
|
||||||
|
|
||||||
|
export interface TriggerInteractableHandle {
|
||||||
|
kind: "trigger";
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrabInteractableHandle {
|
||||||
|
kind: "grab";
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
onRelease: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InteractableHandle =
|
||||||
|
| TriggerInteractableHandle
|
||||||
|
| GrabInteractableHandle;
|
||||||
|
|
||||||
|
export interface InteractionSnapshot {
|
||||||
|
focused: InteractableHandle | null;
|
||||||
|
holding: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export type LogValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| Error
|
||||||
|
| DOMException
|
||||||
|
| { [key: string]: LogValue }
|
||||||
|
| LogValue[];
|
||||||
|
|
||||||
|
export type LogContext = Readonly<Record<string, LogValue>>;
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
scope: string;
|
||||||
|
message: string;
|
||||||
|
context?: LogContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoggerConfig {
|
||||||
|
minLevel: LogLevel;
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
type Listener<TPayload> = (payload: TPayload) => void;
|
type Listener<TPayload> = (payload: TPayload) => void;
|
||||||
|
|
||||||
// TypeScript cannot narrow mapped-type indexed access by a generic key TKey
|
|
||||||
// (microsoft/TypeScript#30581). The helper below encapsulates the one necessary
|
|
||||||
// cast so the rest of the class stays cast-free.
|
|
||||||
type ListenerMap<TEvents extends Record<string, unknown>> = {
|
type ListenerMap<TEvents extends Record<string, unknown>> = {
|
||||||
[TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>;
|
[TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>;
|
||||||
};
|
};
|
||||||
|
|||||||
+25
-10
@@ -1,5 +1,6 @@
|
|||||||
import GUI from "lil-gui";
|
import GUI from "lil-gui";
|
||||||
import type { CameraMode, SceneMode } from "@/types/debug";
|
import type { CameraMode, SceneMode } from "@/types/debug";
|
||||||
|
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||||
|
|
||||||
export class Debug {
|
export class Debug {
|
||||||
private static instance: Debug | null = null;
|
private static instance: Debug | null = null;
|
||||||
@@ -7,7 +8,7 @@ export class Debug {
|
|||||||
public readonly active: boolean;
|
public readonly active: boolean;
|
||||||
private readonly gui: GUI | null;
|
private readonly gui: GUI | null;
|
||||||
private readonly folders = new Map<string, GUI>();
|
private readonly folders = new Map<string, GUI>();
|
||||||
private readonly registeredFolders = new Set<string>();
|
private readonly folderRefCounts = new Map<string, number>();
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private readonly controls: {
|
private readonly controls: {
|
||||||
cameraMode: CameraMode;
|
cameraMode: CameraMode;
|
||||||
@@ -28,7 +29,7 @@ export class Debug {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.active = new URLSearchParams(window.location.search).has("debug");
|
this.active = isDebugEnabled();
|
||||||
this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null;
|
this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null;
|
||||||
|
|
||||||
if (this.gui) {
|
if (this.gui) {
|
||||||
@@ -63,27 +64,41 @@ export class Debug {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a named GUI folder. Returns the folder on first call, null on
|
* Acquires a named GUI folder. Returns the folder on first acquisition and null
|
||||||
* subsequent calls with the same name — callers should skip `.add()` when
|
* on subsequent acquisitions so callers only register controls once.
|
||||||
* null is returned to avoid duplicating controls under StrictMode double-mount.
|
|
||||||
*/
|
*/
|
||||||
createFolder(name: string): GUI | null {
|
createFolder(name: string): GUI | null {
|
||||||
if (!this.gui) return null;
|
if (!this.gui) return null;
|
||||||
|
|
||||||
if (this.registeredFolders.has(name)) return null;
|
|
||||||
|
|
||||||
this.registeredFolders.add(name);
|
|
||||||
|
|
||||||
const existing = this.folders.get(name);
|
const existing = this.folders.get(name);
|
||||||
|
|
||||||
if (existing) return existing;
|
if (existing) {
|
||||||
|
this.folderRefCounts.set(name, (this.folderRefCounts.get(name) ?? 0) + 1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const folder = this.gui.addFolder(name);
|
const folder = this.gui.addFolder(name);
|
||||||
this.folders.set(name, folder);
|
this.folders.set(name, folder);
|
||||||
|
this.folderRefCounts.set(name, 1);
|
||||||
|
|
||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroyFolder(name: string): void {
|
||||||
|
const folder = this.folders.get(name);
|
||||||
|
const refCount = this.folderRefCounts.get(name);
|
||||||
|
if (!folder || refCount === undefined) return;
|
||||||
|
|
||||||
|
if (refCount > 1) {
|
||||||
|
this.folderRefCounts.set(name, refCount - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.destroy();
|
||||||
|
this.folders.delete(name);
|
||||||
|
this.folderRefCounts.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
subscribe(listener: () => void): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function isDebugEnabled(): boolean {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URLSearchParams(window.location.search).has("debug");
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
|
import {
|
||||||
|
DEBUG_CAMERA_DAMPING_FACTOR,
|
||||||
|
DEBUG_CAMERA_MAX_DISTANCE,
|
||||||
|
DEBUG_CAMERA_MIN_DISTANCE,
|
||||||
|
} from "@/data/debugConfig";
|
||||||
|
import {
|
||||||
|
PLAYER_EYE_HEIGHT,
|
||||||
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
|
} from "@/data/playerConfig";
|
||||||
|
|
||||||
|
const DEBUG_CAMERA_TARGET = [
|
||||||
|
PLAYER_SPAWN_POSITION_GAME[0],
|
||||||
|
PLAYER_EYE_HEIGHT,
|
||||||
|
PLAYER_SPAWN_POSITION_GAME[2],
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function DebugCameraControls(): React.JSX.Element {
|
export function DebugCameraControls(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
enableDamping
|
enableDamping
|
||||||
dampingFactor={0.05}
|
dampingFactor={DEBUG_CAMERA_DAMPING_FACTOR}
|
||||||
minDistance={100}
|
minDistance={DEBUG_CAMERA_MIN_DISTANCE}
|
||||||
maxDistance={1000}
|
maxDistance={DEBUG_CAMERA_MAX_DISTANCE}
|
||||||
target={[0, 1.75, 0]}
|
target={DEBUG_CAMERA_TARGET}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
DEBUG_AXES_SIZE,
|
||||||
|
DEBUG_GRID_DIVISIONS,
|
||||||
|
DEBUG_GRID_PRIMARY_COLOR,
|
||||||
|
DEBUG_GRID_SECONDARY_COLOR,
|
||||||
|
DEBUG_GRID_SIZE,
|
||||||
|
DEBUG_GRID_Y,
|
||||||
|
} from "@/data/debugConfig";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
|
|
||||||
export function DebugHelpers(): React.JSX.Element | null {
|
export function DebugHelpers(): React.JSX.Element | null {
|
||||||
@@ -10,10 +18,15 @@ export function DebugHelpers(): React.JSX.Element | null {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<gridHelper
|
<gridHelper
|
||||||
args={[180, 36, "#1d4ed8", "#1e293b"]}
|
args={[
|
||||||
position={[0, 0.01, 0]}
|
DEBUG_GRID_SIZE,
|
||||||
|
DEBUG_GRID_DIVISIONS,
|
||||||
|
DEBUG_GRID_PRIMARY_COLOR,
|
||||||
|
DEBUG_GRID_SECONDARY_COLOR,
|
||||||
|
]}
|
||||||
|
position={[0, DEBUG_GRID_Y, 0]}
|
||||||
/>
|
/>
|
||||||
<axesHelper args={[10]} />
|
<axesHelper args={[DEBUG_AXES_SIZE]} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type {
|
||||||
|
LogContext,
|
||||||
|
LogEntry,
|
||||||
|
LogLevel,
|
||||||
|
LoggerConfig,
|
||||||
|
} from "@/types/logger";
|
||||||
|
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<LogLevel, string> = {
|
||||||
|
debug: "DEBUG",
|
||||||
|
info: "INFO",
|
||||||
|
warn: "WARN",
|
||||||
|
error: "ERROR",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_STYLES: Record<LogLevel, string> = {
|
||||||
|
debug: "color: #94a3b8; font-weight: 600;",
|
||||||
|
info: "color: #60a5fa; font-weight: 600;",
|
||||||
|
warn: "color: #f59e0b; font-weight: 600;",
|
||||||
|
error: "color: #f87171; font-weight: 600;",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_STYLE = "color: #e5e7eb; font-weight: 600;";
|
||||||
|
const MESSAGE_STYLE = "color: inherit;";
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private readonly config: LoggerConfig;
|
||||||
|
|
||||||
|
constructor(config: LoggerConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(scope: string, message: string, context?: LogContext): void {
|
||||||
|
this.log("debug", scope, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(scope: string, message: string, context?: LogContext): void {
|
||||||
|
this.log("info", scope, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(scope: string, message: string, context?: LogContext): void {
|
||||||
|
this.log("warn", scope, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(scope: string, message: string, context?: LogContext): void {
|
||||||
|
this.log("error", scope, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(
|
||||||
|
level: LogLevel,
|
||||||
|
scope: string,
|
||||||
|
message: string,
|
||||||
|
context?: LogContext,
|
||||||
|
): void {
|
||||||
|
if (!this.shouldLog(level)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
scope,
|
||||||
|
message,
|
||||||
|
...(context ? { context } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedMessage = `%c[${LEVEL_LABELS[level]}]%c [${scope}]%c ${message}`;
|
||||||
|
const args = [
|
||||||
|
formattedMessage,
|
||||||
|
LEVEL_STYLES[level],
|
||||||
|
SCOPE_STYLE,
|
||||||
|
MESSAGE_STYLE,
|
||||||
|
entry,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
console.debug(...args);
|
||||||
|
return;
|
||||||
|
case "info":
|
||||||
|
console.info(...args);
|
||||||
|
return;
|
||||||
|
case "warn":
|
||||||
|
console.warn(...args);
|
||||||
|
return;
|
||||||
|
case "error":
|
||||||
|
console.error(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.config.minLevel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMinLevel(): LogLevel {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDebugEnabled() ? "debug" : "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger({
|
||||||
|
minLevel: resolveMinLevel(),
|
||||||
|
});
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
|
import { Environment as DreiEnvironment } from "@react-three/drei";
|
||||||
|
import {
|
||||||
|
GAME_SCENE_SKYBOX_PATH,
|
||||||
|
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||||
|
} from "@/data/environmentConfig";
|
||||||
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
|
|
||||||
export function Environment(): React.JSX.Element {
|
export function Environment(): React.JSX.Element {
|
||||||
return <color attach="background" args={["#0b1018"]} />;
|
const sceneMode = useSceneMode();
|
||||||
|
|
||||||
|
if (sceneMode === "physics") {
|
||||||
|
return (
|
||||||
|
<color attach="background" args={[PHYSICS_SCENE_BACKGROUND_COLOR]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DreiEnvironment background files={GAME_SCENE_SKYBOX_PATH} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-14
@@ -1,6 +1,26 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import type { AmbientLight, DirectionalLight } from "three";
|
import type { AmbientLight, DirectionalLight } from "three";
|
||||||
|
import {
|
||||||
|
AMBIENT_INTENSITY_MAX,
|
||||||
|
AMBIENT_INTENSITY_MIN,
|
||||||
|
AMBIENT_INTENSITY_STEP,
|
||||||
|
AMBIENT_LIGHT_COLOR,
|
||||||
|
LIGHTING_DEFAULTS,
|
||||||
|
SUN_INTENSITY_MAX,
|
||||||
|
SUN_INTENSITY_MIN,
|
||||||
|
SUN_INTENSITY_STEP,
|
||||||
|
SUN_LIGHT_COLOR,
|
||||||
|
SUN_X_MAX,
|
||||||
|
SUN_X_MIN,
|
||||||
|
SUN_X_STEP,
|
||||||
|
SUN_Y_MAX,
|
||||||
|
SUN_Y_MIN,
|
||||||
|
SUN_Y_STEP,
|
||||||
|
SUN_Z_MAX,
|
||||||
|
SUN_Z_MIN,
|
||||||
|
SUN_Z_STEP,
|
||||||
|
} from "@/data/lightingConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
|
||||||
type LightingState = {
|
type LightingState = {
|
||||||
@@ -11,24 +31,40 @@ type LightingState = {
|
|||||||
sunZ: number;
|
sunZ: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIGHTING_STATE: LightingState = {
|
const LIGHTING_STATE: LightingState = { ...LIGHTING_DEFAULTS };
|
||||||
ambientIntensity: 1.8,
|
|
||||||
sunIntensity: 2.8,
|
|
||||||
sunX: 60,
|
|
||||||
sunY: 80,
|
|
||||||
sunZ: 30,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Lighting(): React.JSX.Element {
|
export function Lighting(): React.JSX.Element {
|
||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.add(LIGHTING_STATE, "ambientIntensity", 0, 5, 0.1).name("Ambient");
|
folder
|
||||||
folder.add(LIGHTING_STATE, "sunIntensity", 0, 8, 0.1).name("Sun Intensity");
|
.add(
|
||||||
folder.add(LIGHTING_STATE, "sunX", -100, 100, 1).name("Sun X");
|
LIGHTING_STATE,
|
||||||
folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y");
|
"ambientIntensity",
|
||||||
folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z");
|
AMBIENT_INTENSITY_MIN,
|
||||||
|
AMBIENT_INTENSITY_MAX,
|
||||||
|
AMBIENT_INTENSITY_STEP,
|
||||||
|
)
|
||||||
|
.name("Ambient");
|
||||||
|
folder
|
||||||
|
.add(
|
||||||
|
LIGHTING_STATE,
|
||||||
|
"sunIntensity",
|
||||||
|
SUN_INTENSITY_MIN,
|
||||||
|
SUN_INTENSITY_MAX,
|
||||||
|
SUN_INTENSITY_STEP,
|
||||||
|
)
|
||||||
|
.name("Sun Intensity");
|
||||||
|
folder
|
||||||
|
.add(LIGHTING_STATE, "sunX", SUN_X_MIN, SUN_X_MAX, SUN_X_STEP)
|
||||||
|
.name("Sun X");
|
||||||
|
folder
|
||||||
|
.add(LIGHTING_STATE, "sunY", SUN_Y_MIN, SUN_Y_MAX, SUN_Y_STEP)
|
||||||
|
.name("Sun Y");
|
||||||
|
folder
|
||||||
|
.add(LIGHTING_STATE, "sunZ", SUN_Z_MIN, SUN_Z_MAX, SUN_Z_STEP)
|
||||||
|
.name("Sun Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
@@ -51,7 +87,7 @@ export function Lighting(): React.JSX.Element {
|
|||||||
<ambientLight
|
<ambientLight
|
||||||
ref={ambient}
|
ref={ambient}
|
||||||
intensity={LIGHTING_STATE.ambientIntensity}
|
intensity={LIGHTING_STATE.ambientIntensity}
|
||||||
color="#dbeafe"
|
color={AMBIENT_LIGHT_COLOR}
|
||||||
/>
|
/>
|
||||||
<directionalLight
|
<directionalLight
|
||||||
ref={sun}
|
ref={sun}
|
||||||
@@ -61,7 +97,7 @@ export function Lighting(): React.JSX.Element {
|
|||||||
LIGHTING_STATE.sunZ,
|
LIGHTING_STATE.sunZ,
|
||||||
]}
|
]}
|
||||||
intensity={LIGHTING_STATE.sunIntensity}
|
intensity={LIGHTING_STATE.sunIntensity}
|
||||||
color="#fff7ed"
|
color={SUN_LIGHT_COLOR}
|
||||||
castShadow
|
castShadow
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+6
-15
@@ -2,34 +2,25 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Octree } from "three/addons/math/Octree.js";
|
import { MAP_DEBUG_BOX_HELPER_COLOR } from "@/data/debugConfig";
|
||||||
|
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||||
|
import type { OctreeReadyHandler } from "@/types/3d";
|
||||||
import { Debug } from "@/utils/debug/Debug";
|
import { Debug } from "@/utils/debug/Debug";
|
||||||
|
|
||||||
const MAP_PATH = "/models/map/model.gltf";
|
const MAP_PATH = "/models/map/model.gltf";
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
onOctreeReady: (octree: Octree) => void;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
|
export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
|
||||||
const { scene: gltfScene } = useGLTF(MAP_PATH);
|
const { scene: gltfScene } = useGLTF(MAP_PATH);
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const octreeBuilt = useRef(false);
|
|
||||||
const boxHelpersRef = useRef<THREE.BoxHelper[]>([]);
|
const boxHelpersRef = useRef<THREE.BoxHelper[]>([]);
|
||||||
const { scene } = useThree();
|
const { scene } = useThree();
|
||||||
|
|
||||||
useEffect(() => {
|
useOctreeGraphNode(groupRef, onOctreeReady);
|
||||||
if (octreeBuilt.current || !groupRef.current) return;
|
|
||||||
octreeBuilt.current = true;
|
|
||||||
|
|
||||||
groupRef.current.updateMatrixWorld(true);
|
|
||||||
|
|
||||||
const octree = new Octree();
|
|
||||||
octree.fromGraphNode(groupRef.current);
|
|
||||||
onOctreeReady(octree);
|
|
||||||
}, [onOctreeReady]);
|
|
||||||
|
|
||||||
// BoxHelper wireframes in debug mode — one per mesh in the model
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const debug = Debug.getInstance();
|
const debug = Debug.getInstance();
|
||||||
if (!debug.active || !groupRef.current) return;
|
if (!debug.active || !groupRef.current) return;
|
||||||
@@ -38,7 +29,7 @@ export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
|
|||||||
|
|
||||||
groupRef.current.traverse((child) => {
|
groupRef.current.traverse((child) => {
|
||||||
if (!(child instanceof THREE.Mesh)) return;
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
const helper = new THREE.BoxHelper(child, 0x00ff88);
|
const helper = new THREE.BoxHelper(child, MAP_DEBUG_BOX_HELPER_COLOR);
|
||||||
scene.add(helper);
|
scene.add(helper);
|
||||||
helpers.push(helper);
|
helpers.push(helper);
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-8
@@ -1,5 +1,9 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three/addons/math/Octree.js";
|
||||||
|
import {
|
||||||
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
|
} from "@/data/playerConfig";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
|
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
|
||||||
@@ -14,7 +18,10 @@ export function World(): React.JSX.Element {
|
|||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const [octree, setOctree] = useState<Octree | null>(null);
|
const [octree, setOctree] = useState<Octree | null>(null);
|
||||||
const onOctreeReady = useCallback((o: Octree) => setOctree(o), []);
|
const playerSpawnPosition =
|
||||||
|
sceneMode === "game"
|
||||||
|
? PLAYER_SPAWN_POSITION_GAME
|
||||||
|
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -24,16 +31,13 @@ export function World(): React.JSX.Element {
|
|||||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||||
|
|
||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<Map onOctreeReady={onOctreeReady} />
|
<Map onOctreeReady={setOctree} />
|
||||||
) : (
|
) : (
|
||||||
<TestScene onOctreeReady={onOctreeReady} />
|
<TestScene onOctreeReady={setOctree} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cameraMode !== "debug" ? (
|
{cameraMode !== "debug" ? (
|
||||||
<PlayerComponent
|
<PlayerComponent octree={octree} spawnPosition={playerSpawnPosition} />
|
||||||
octree={octree}
|
|
||||||
spawnY={sceneMode === "game" ? 100 : 3}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,89 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Octree } from "three/addons/math/Octree.js";
|
import { GrabbableObject } from "@/components/3d/GrabbableObject";
|
||||||
import { GrabCube } from "@/world/objects/GrabCube";
|
import { TriggerObject } from "@/components/3d/TriggerObject";
|
||||||
import { TriggerSphere } from "@/world/objects/TriggerSphere";
|
import {
|
||||||
|
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
||||||
|
TEST_SCENE_FLOOR_POSITION,
|
||||||
|
TEST_SCENE_FLOOR_SIZE,
|
||||||
|
TEST_SCENE_GRABBABLE_BOX_SIZE,
|
||||||
|
TEST_SCENE_GRABBABLE_COLOR,
|
||||||
|
TEST_SCENE_GRABBABLE_METALNESS,
|
||||||
|
TEST_SCENE_GRABBABLE_POSITION,
|
||||||
|
TEST_SCENE_GRABBABLE_ROUGHNESS,
|
||||||
|
TEST_SCENE_TRIGGER_COLOR,
|
||||||
|
TEST_SCENE_TRIGGER_METALNESS,
|
||||||
|
TEST_SCENE_TRIGGER_POSITION,
|
||||||
|
TEST_SCENE_TRIGGER_RADIUS,
|
||||||
|
TEST_SCENE_TRIGGER_ROUGHNESS,
|
||||||
|
TEST_SCENE_TRIGGER_SEGMENTS,
|
||||||
|
TEST_SCENE_TRIGGER_SOUND_PATH,
|
||||||
|
} from "@/data/testSceneConfig";
|
||||||
|
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||||
|
import type { OctreeReadyHandler } from "@/types/3d";
|
||||||
|
|
||||||
interface TestSceneProps {
|
interface TestSceneProps {
|
||||||
onOctreeReady: (octree: Octree) => void;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestScene({
|
export function TestScene({
|
||||||
onOctreeReady,
|
onOctreeReady,
|
||||||
}: TestSceneProps): React.JSX.Element {
|
}: TestSceneProps): React.JSX.Element {
|
||||||
const floorRef = useRef<THREE.Group>(null);
|
const floorRef = useRef<THREE.Group>(null);
|
||||||
const octreeBuilt = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useOctreeGraphNode(floorRef, onOctreeReady);
|
||||||
if (octreeBuilt.current || !floorRef.current) return;
|
|
||||||
octreeBuilt.current = true;
|
|
||||||
|
|
||||||
floorRef.current.updateMatrixWorld(true);
|
|
||||||
|
|
||||||
const octree = new Octree();
|
|
||||||
octree.fromGraphNode(floorRef.current);
|
|
||||||
onOctreeReady(octree);
|
|
||||||
}, [onOctreeReady]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Invisible floor mesh for Octree player collision */}
|
|
||||||
<group ref={floorRef}>
|
<group ref={floorRef}>
|
||||||
<mesh visible={false} position={[0, -0.5, 0]}>
|
<mesh visible={false} position={TEST_SCENE_FLOOR_POSITION}>
|
||||||
<boxGeometry args={[200, 1, 200]} />
|
<boxGeometry args={TEST_SCENE_FLOOR_SIZE} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
{/* Rapier physics for interactable objects */}
|
|
||||||
<Physics>
|
<Physics>
|
||||||
<RigidBody type="fixed">
|
<RigidBody type="fixed">
|
||||||
<CuboidCollider args={[100, 0.5, 100]} position={[0, -0.5, 0]} />
|
<CuboidCollider
|
||||||
|
args={TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS}
|
||||||
|
position={TEST_SCENE_FLOOR_POSITION}
|
||||||
|
/>
|
||||||
</RigidBody>
|
</RigidBody>
|
||||||
<GrabCube />
|
|
||||||
<TriggerSphere />
|
<GrabbableObject
|
||||||
|
position={TEST_SCENE_GRABBABLE_POSITION}
|
||||||
|
colliders="cuboid"
|
||||||
|
>
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<boxGeometry args={TEST_SCENE_GRABBABLE_BOX_SIZE} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={TEST_SCENE_GRABBABLE_COLOR}
|
||||||
|
roughness={TEST_SCENE_GRABBABLE_ROUGHNESS}
|
||||||
|
metalness={TEST_SCENE_GRABBABLE_METALNESS}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</GrabbableObject>
|
||||||
|
|
||||||
|
<TriggerObject
|
||||||
|
position={TEST_SCENE_TRIGGER_POSITION}
|
||||||
|
soundPath={TEST_SCENE_TRIGGER_SOUND_PATH}
|
||||||
|
>
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<sphereGeometry
|
||||||
|
args={[
|
||||||
|
TEST_SCENE_TRIGGER_RADIUS,
|
||||||
|
TEST_SCENE_TRIGGER_SEGMENTS,
|
||||||
|
TEST_SCENE_TRIGGER_SEGMENTS,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={TEST_SCENE_TRIGGER_COLOR}
|
||||||
|
roughness={TEST_SCENE_TRIGGER_ROUGHNESS}
|
||||||
|
metalness={TEST_SCENE_TRIGGER_METALNESS}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</TriggerObject>
|
||||||
</Physics>
|
</Physics>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import type { RapierRigidBody } from "@react-three/rapier";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
|
||||||
|
|
||||||
const CUBE_SIZE = 0.5;
|
|
||||||
const HOLD_DISTANCE = 2;
|
|
||||||
const SPAWN_POSITION: [number, number, number] = [0, 1, -3];
|
|
||||||
|
|
||||||
const params = { stiffness: 15, throwBoost: 1.0 };
|
|
||||||
|
|
||||||
const _holdTarget = new THREE.Vector3();
|
|
||||||
const _currentPos = new THREE.Vector3();
|
|
||||||
const _velocity = new THREE.Vector3();
|
|
||||||
|
|
||||||
export function GrabCube(): React.JSX.Element {
|
|
||||||
const camera = useThree((state) => state.camera);
|
|
||||||
const rbRef = useRef<RapierRigidBody>(null);
|
|
||||||
const isHolding = useRef(false);
|
|
||||||
|
|
||||||
useDebugFolder("GrabCube", (folder) => {
|
|
||||||
folder.add(params, "stiffness", 1, 50, 1).name("Hold stiffness");
|
|
||||||
folder.add(params, "throwBoost", 0.5, 3.0, 0.1).name("Throw boost");
|
|
||||||
});
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (!isHolding.current || !rbRef.current) return;
|
|
||||||
|
|
||||||
camera.getWorldDirection(_holdTarget);
|
|
||||||
_holdTarget.multiplyScalar(HOLD_DISTANCE).add(camera.position);
|
|
||||||
|
|
||||||
const t = rbRef.current.translation();
|
|
||||||
_currentPos.set(t.x, t.y, t.z);
|
|
||||||
|
|
||||||
_velocity
|
|
||||||
.subVectors(_holdTarget, _currentPos)
|
|
||||||
.multiplyScalar(params.stiffness);
|
|
||||||
|
|
||||||
rbRef.current.setLinvel(
|
|
||||||
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
rbRef.current.setAngvel({ x: 0, y: 0, z: 0 }, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InteractableObject
|
|
||||||
kind="grab"
|
|
||||||
label="Prendre"
|
|
||||||
position={SPAWN_POSITION}
|
|
||||||
rigidBodyType="dynamic"
|
|
||||||
colliders="cuboid"
|
|
||||||
rbRef={rbRef}
|
|
||||||
onPress={() => {
|
|
||||||
isHolding.current = true;
|
|
||||||
}}
|
|
||||||
onRelease={() => {
|
|
||||||
isHolding.current = false;
|
|
||||||
if (rbRef.current && params.throwBoost !== 1.0) {
|
|
||||||
const v = rbRef.current.linvel();
|
|
||||||
rbRef.current.setLinvel(
|
|
||||||
{
|
|
||||||
x: v.x * params.throwBoost,
|
|
||||||
y: v.y * params.throwBoost,
|
|
||||||
z: v.z * params.throwBoost,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<mesh castShadow receiveShadow>
|
|
||||||
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
|
|
||||||
<meshStandardMaterial color="#e07b39" roughness={0.6} metalness={0.1} />
|
|
||||||
</mesh>
|
|
||||||
</InteractableObject>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { AudioManager } from "@/stateManager/AudioManager";
|
|
||||||
import { InteractableObject } from "@/components/3d/InteractableObject";
|
|
||||||
|
|
||||||
const SPHERE_RADIUS = 0.4;
|
|
||||||
const SPAWN_POSITION: [number, number, number] = [3, 2, -3];
|
|
||||||
const SOUND_PATH = "/sounds/fa.mp3";
|
|
||||||
|
|
||||||
interface TriggerSphereProps {
|
|
||||||
soundPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TriggerSphere({
|
|
||||||
soundPath = SOUND_PATH,
|
|
||||||
}: TriggerSphereProps): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<InteractableObject
|
|
||||||
kind="trigger"
|
|
||||||
label="Interagir"
|
|
||||||
position={SPAWN_POSITION}
|
|
||||||
rigidBodyType="fixed"
|
|
||||||
colliders="ball"
|
|
||||||
onPress={() => {
|
|
||||||
AudioManager.getInstance().playSound(soundPath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<mesh castShadow receiveShadow>
|
|
||||||
<sphereGeometry args={[SPHERE_RADIUS, 32, 32]} />
|
|
||||||
<meshStandardMaterial color="#3b82f6" roughness={0.3} metalness={0.5} />
|
|
||||||
</mesh>
|
|
||||||
</InteractableObject>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { PointerLockControls } from "@react-three/drei";
|
import { PointerLockControls } from "@react-three/drei";
|
||||||
|
|
||||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
|
||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
|
||||||
|
|
||||||
export function PlayerCamera(): React.JSX.Element {
|
export function PlayerCamera(): React.JSX.Element {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three/addons/math/Octree.js";
|
||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
||||||
import { PlayerController } from "@/world/player/PlayerController";
|
import { PlayerController } from "@/world/player/PlayerController";
|
||||||
|
|
||||||
interface PlayerComponentProps {
|
interface PlayerComponentProps {
|
||||||
octree?: Octree | null;
|
octree: Octree | null;
|
||||||
spawnY?: number;
|
spawnPosition: Vector3Tuple;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerComponent({
|
export function PlayerComponent({
|
||||||
octree = null,
|
spawnPosition,
|
||||||
spawnY = 100,
|
octree,
|
||||||
}: PlayerComponentProps): React.JSX.Element {
|
}: PlayerComponentProps): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
camera.position.set(0, spawnY, 0);
|
camera.position.set(...spawnPosition);
|
||||||
}, [camera, spawnY]);
|
}, [camera, spawnPosition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PlayerCamera />
|
<PlayerCamera />
|
||||||
<PlayerController octree={octree} />
|
<PlayerController octree={octree} spawnPosition={spawnPosition} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,28 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Capsule } from "three/addons/math/Capsule.js";
|
import { Capsule } from "three/addons/math/Capsule.js";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three/addons/math/Octree.js";
|
||||||
import { InteractionManager } from "@/stateManager/InteractionManager";
|
|
||||||
import {
|
import {
|
||||||
PLAYER_EYE_HEIGHT,
|
INTERACT_KEY,
|
||||||
|
JUMP_KEY,
|
||||||
|
MOVE_BACKWARD_KEY,
|
||||||
|
MOVE_FORWARD_KEY,
|
||||||
|
MOVE_LEFT_KEY,
|
||||||
|
MOVE_RIGHT_KEY,
|
||||||
|
PRIMARY_INTERACT_MOUSE_BUTTON,
|
||||||
|
} from "@/data/keybindings";
|
||||||
|
import {
|
||||||
|
PLAYER_ACCELERATION_MULTIPLIER,
|
||||||
|
PLAYER_AIR_CONTROL_FACTOR,
|
||||||
PLAYER_CAPSULE_RADIUS,
|
PLAYER_CAPSULE_RADIUS,
|
||||||
} from "@/world/player/PlayerCamera";
|
PLAYER_EYE_HEIGHT,
|
||||||
|
PLAYER_GRAVITY,
|
||||||
const WALK_SPEED = 11;
|
PLAYER_JUMP_SPEED,
|
||||||
const AIR_CONTROL = 0.35;
|
PLAYER_MAX_DELTA,
|
||||||
const JUMP_SPEED = 9;
|
PLAYER_WALK_SPEED,
|
||||||
const GRAVITY = 30;
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
|
} from "@/data/playerConfig";
|
||||||
|
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||||
|
import type { Vector3Tuple } from "@/types/3d";
|
||||||
|
|
||||||
type Keys = {
|
type Keys = {
|
||||||
forward: boolean;
|
forward: boolean;
|
||||||
@@ -32,6 +44,7 @@ const DEFAULT_KEYS: Keys = {
|
|||||||
|
|
||||||
interface PlayerControllerProps {
|
interface PlayerControllerProps {
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
|
spawnPosition: Vector3Tuple;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _forward = new THREE.Vector3();
|
const _forward = new THREE.Vector3();
|
||||||
@@ -39,15 +52,18 @@ const _right = new THREE.Vector3();
|
|||||||
const _wishDir = new THREE.Vector3();
|
const _wishDir = new THREE.Vector3();
|
||||||
const _up = new THREE.Vector3(0, 1, 0);
|
const _up = new THREE.Vector3(0, 1, 0);
|
||||||
const _translateVec = new THREE.Vector3();
|
const _translateVec = new THREE.Vector3();
|
||||||
|
const _collisionCorrection = new THREE.Vector3();
|
||||||
|
|
||||||
export function PlayerController({ octree }: PlayerControllerProps): null {
|
export function PlayerController({
|
||||||
|
octree,
|
||||||
|
spawnPosition,
|
||||||
|
}: PlayerControllerProps): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||||
const velocity = useRef(new THREE.Vector3());
|
const velocity = useRef(new THREE.Vector3());
|
||||||
const onFloor = useRef(false);
|
const onFloor = useRef(false);
|
||||||
const wantsJump = useRef(false);
|
const wantsJump = useRef(false);
|
||||||
|
|
||||||
// Capsule: start = feet, end = eyes
|
|
||||||
const capsule = useRef(
|
const capsule = useRef(
|
||||||
new Capsule(
|
new Capsule(
|
||||||
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
|
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
|
||||||
@@ -56,39 +72,40 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync capsule to camera spawn position on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const spawnY = camera.position.y;
|
|
||||||
capsule.current.start.set(
|
capsule.current.start.set(
|
||||||
0,
|
spawnPosition[0],
|
||||||
spawnY - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
|
||||||
0,
|
spawnPosition[2],
|
||||||
);
|
);
|
||||||
capsule.current.end.set(0, spawnY, 0);
|
capsule.current.end.set(...spawnPosition);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
velocity.current.set(0, 0, 0);
|
||||||
}, []);
|
onFloor.current = false;
|
||||||
|
wantsJump.current = false;
|
||||||
|
camera.position.copy(capsule.current.end);
|
||||||
|
}, [camera, spawnPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = InteractionManager.getInstance();
|
const interaction = InteractionManager.getInstance();
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
switch (event.key.toLowerCase()) {
|
switch (event.key.toLowerCase()) {
|
||||||
case "z":
|
case MOVE_FORWARD_KEY:
|
||||||
keys.current.forward = true;
|
keys.current.forward = true;
|
||||||
break;
|
break;
|
||||||
case "s":
|
case MOVE_BACKWARD_KEY:
|
||||||
keys.current.backward = true;
|
keys.current.backward = true;
|
||||||
break;
|
break;
|
||||||
case "q":
|
case MOVE_LEFT_KEY:
|
||||||
keys.current.left = true;
|
keys.current.left = true;
|
||||||
break;
|
break;
|
||||||
case "d":
|
case MOVE_RIGHT_KEY:
|
||||||
keys.current.right = true;
|
keys.current.right = true;
|
||||||
break;
|
break;
|
||||||
case " ":
|
case JUMP_KEY:
|
||||||
wantsJump.current = true;
|
wantsJump.current = true;
|
||||||
break;
|
break;
|
||||||
case "e":
|
case INTERACT_KEY:
|
||||||
if (interaction.getState().focused?.kind === "trigger") {
|
if (interaction.getState().focused?.kind === "trigger") {
|
||||||
interaction.pressInteract();
|
interaction.pressInteract();
|
||||||
}
|
}
|
||||||
@@ -101,23 +118,18 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||||
switch (event.key.toLowerCase()) {
|
switch (event.key.toLowerCase()) {
|
||||||
case "z":
|
case MOVE_FORWARD_KEY:
|
||||||
keys.current.forward = false;
|
keys.current.forward = false;
|
||||||
break;
|
break;
|
||||||
case "s":
|
case MOVE_BACKWARD_KEY:
|
||||||
keys.current.backward = false;
|
keys.current.backward = false;
|
||||||
break;
|
break;
|
||||||
case "q":
|
case MOVE_LEFT_KEY:
|
||||||
keys.current.left = false;
|
keys.current.left = false;
|
||||||
break;
|
break;
|
||||||
case "d":
|
case MOVE_RIGHT_KEY:
|
||||||
keys.current.right = false;
|
keys.current.right = false;
|
||||||
break;
|
break;
|
||||||
case "e":
|
|
||||||
if (interaction.getState().focused?.kind === "trigger") {
|
|
||||||
interaction.releaseInteract();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,14 +137,14 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent): void => {
|
const handleMouseDown = (event: MouseEvent): void => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().focused?.kind === "grab") {
|
if (interaction.getState().focused?.kind === "grab") {
|
||||||
interaction.pressInteract();
|
interaction.pressInteract();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (event: MouseEvent): void => {
|
const handleMouseUp = (event: MouseEvent): void => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().holding) {
|
if (interaction.getState().holding) {
|
||||||
interaction.releaseInteract();
|
interaction.releaseInteract();
|
||||||
}
|
}
|
||||||
@@ -153,10 +165,8 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
// Clamp delta so physics don't explode on tab focus regain
|
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||||
const dt = Math.min(delta, 0.05);
|
|
||||||
|
|
||||||
// Compute wish direction from camera yaw (XZ only)
|
|
||||||
camera.getWorldDirection(_forward);
|
camera.getWorldDirection(_forward);
|
||||||
_forward.setY(0);
|
_forward.setY(0);
|
||||||
if (_forward.lengthSq() > 0) {
|
if (_forward.lengthSq() > 0) {
|
||||||
@@ -171,33 +181,32 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
// Accelerate horizontally
|
const accel = onFloor.current
|
||||||
const accel = onFloor.current ? WALK_SPEED : WALK_SPEED * AIR_CONTROL;
|
? PLAYER_WALK_SPEED
|
||||||
velocity.current.x += _wishDir.x * accel * dt * 9;
|
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
|
||||||
velocity.current.z += _wishDir.z * accel * dt * 9;
|
velocity.current.x +=
|
||||||
|
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
|
velocity.current.z +=
|
||||||
|
_wishDir.z * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
|
|
||||||
// Exponential damping on XZ
|
const damping = Math.exp(-PLAYER_XZ_DAMPING_FACTOR * dt);
|
||||||
const damping = Math.exp(-8 * dt);
|
|
||||||
velocity.current.x *= damping;
|
velocity.current.x *= damping;
|
||||||
velocity.current.z *= damping;
|
velocity.current.z *= damping;
|
||||||
|
|
||||||
// Gravity + jump
|
|
||||||
if (onFloor.current) {
|
if (onFloor.current) {
|
||||||
velocity.current.y = Math.max(0, velocity.current.y);
|
velocity.current.y = Math.max(0, velocity.current.y);
|
||||||
if (wantsJump.current) {
|
if (wantsJump.current) {
|
||||||
velocity.current.y = JUMP_SPEED;
|
velocity.current.y = PLAYER_JUMP_SPEED;
|
||||||
onFloor.current = false;
|
onFloor.current = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
velocity.current.y -= GRAVITY * dt;
|
velocity.current.y -= PLAYER_GRAVITY * dt;
|
||||||
}
|
}
|
||||||
wantsJump.current = false;
|
wantsJump.current = false;
|
||||||
|
|
||||||
// Move capsule
|
|
||||||
_translateVec.copy(velocity.current).multiplyScalar(dt);
|
_translateVec.copy(velocity.current).multiplyScalar(dt);
|
||||||
capsule.current.translate(_translateVec);
|
capsule.current.translate(_translateVec);
|
||||||
|
|
||||||
// Resolve collisions against octree
|
|
||||||
if (octree) {
|
if (octree) {
|
||||||
const result = octree.capsuleIntersect(capsule.current);
|
const result = octree.capsuleIntersect(capsule.current);
|
||||||
onFloor.current = false;
|
onFloor.current = false;
|
||||||
@@ -206,21 +215,18 @@ export function PlayerController({ octree }: PlayerControllerProps): null {
|
|||||||
onFloor.current = result.normal.y > 0;
|
onFloor.current = result.normal.y > 0;
|
||||||
|
|
||||||
if (!onFloor.current) {
|
if (!onFloor.current) {
|
||||||
// Cancel velocity component going into the wall
|
|
||||||
const vn = result.normal.dot(velocity.current);
|
const vn = result.normal.dot(velocity.current);
|
||||||
velocity.current.addScaledVector(result.normal, -vn);
|
velocity.current.addScaledVector(result.normal, -vn);
|
||||||
} else {
|
} else {
|
||||||
velocity.current.y = Math.max(0, velocity.current.y);
|
velocity.current.y = Math.max(0, velocity.current.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push capsule out of geometry
|
|
||||||
capsule.current.translate(
|
capsule.current.translate(
|
||||||
result.normal.clone().multiplyScalar(result.depth),
|
_collisionCorrection.copy(result.normal).multiplyScalar(result.depth),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync camera to capsule top (eye position)
|
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -28,13 +28,13 @@ const saveMapPlugin = () => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), saveMapPlugin()],
|
plugins: [react(), saveMapPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user