Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 153833deec | |||
| b617885aa2 | |||
| 5d2e7e2aab | |||
| de77f76d48 | |||
| bdc704fe8e | |||
| bce7d11b66 | |||
| 8aa755da7a | |||
| 6d58b90856 | |||
| bafca5a936 | |||
| dcf3a8564c | |||
| bc862960a7 | |||
| 597ebcfbd4 | |||
| aa2d411b0c | |||
| 061e0dc677 | |||
| 9ef94af488 | |||
| 27b4a2c392 | |||
| d5feb07ff0 | |||
| c33d973f12 | |||
| 396e7e4ff0 |
@@ -25,7 +25,7 @@ Current behavior:
|
|||||||
| -------- | ------------------: | --- | ------------------------------------- |
|
| -------- | ------------------: | --- | ------------------------------------- |
|
||||||
| `low` | 10m | On | Always use `*-LOD` models |
|
| `low` | 10m | On | Always use `*-LOD` models |
|
||||||
| `medium` | 20m | On | Always use `*-LOD` models |
|
| `medium` | 20m | On | Always use `*-LOD` models |
|
||||||
| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` |
|
| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` |
|
||||||
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
||||||
|
|
||||||
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
||||||
|
|||||||
@@ -158,9 +158,11 @@ Current runtime values:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
chunkSize: 35
|
chunkSize: 35
|
||||||
loadRadius: 45
|
low load/unload radius: 10m / 18m
|
||||||
unloadRadius: 45
|
medium load/unload radius: 20m / 30m
|
||||||
updateInterval: 350ms
|
high load/unload radius: 35m / 45m
|
||||||
|
ultra load/unload radius: 50m / 65m
|
||||||
|
updateInterval: 250ms
|
||||||
fog near: 30
|
fog near: 30
|
||||||
fog far: 45
|
fog far: 45
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ Activation des ombres -> Ombres prêtes -> Gameplay prêt
|
|||||||
|
|
||||||
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
||||||
|
|
||||||
|
After the warmup, shadow maps switch back to manual refreshes driven by `Lighting`.
|
||||||
|
The sun still follows the player camera, but the shadow map is only marked dirty
|
||||||
|
when the camera has moved enough and a short refresh interval has elapsed. This
|
||||||
|
keeps shadows present after loading without paying for a full shadow render every
|
||||||
|
frame across the dense vegetation chunks.
|
||||||
|
|
||||||
The debug physics scene is ready when:
|
The debug physics scene is ready when:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,15 @@
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
|
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||||
import { router } from "@/router";
|
import { router } from "@/router";
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <SiteMobileBlocker />;
|
||||||
|
}
|
||||||
|
|
||||||
return <RouterProvider router={router} />;
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+105
-53
@@ -2,17 +2,22 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
|
||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||||
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
|
useTerrainHeightSampler,
|
||||||
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
|
||||||
import {
|
import {
|
||||||
EBIKE_CAMERA_TRANSFORM,
|
EBIKE_CAMERA_TRANSFORM,
|
||||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
EBIKE_WORLD_ROTATION_Y,
|
EBIKE_WORLD_ROTATION_Y,
|
||||||
} from "@/data/ebike/ebikeConfig";
|
} from "@/data/ebike/ebikeConfig";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -31,12 +36,29 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
position: position,
|
position: position,
|
||||||
});
|
});
|
||||||
const model = useClonedObject(scene);
|
const model = useClonedObject(scene);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
const parkedPosition = useMemo<Vector3Tuple>(() => {
|
||||||
|
const [x, y, z] = position;
|
||||||
|
const height = terrainHeight.getHeight(x, z) ?? y;
|
||||||
|
const bottomOffset = getObjectBottomOffset(model, [
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [x, height + bottomOffset, z];
|
||||||
|
}, [model, position, terrainHeight]);
|
||||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const updateEbikeSounds = useEbikeSounds();
|
const updateEbikeSounds = useEbikeSounds();
|
||||||
|
const repairGameOwnsEbikeModel =
|
||||||
|
mainState === "ebike" &&
|
||||||
|
ebikeStep !== "locked" &&
|
||||||
|
ebikeStep !== "waiting" &&
|
||||||
|
ebikeStep !== "inspected";
|
||||||
|
|
||||||
// Map active mainState to target repair zone coordinate
|
// Map active mainState to target repair zone coordinate
|
||||||
const destPos = useMemo(() => {
|
const destPos = useMemo(() => {
|
||||||
@@ -58,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
}>({
|
}>({
|
||||||
x: position[0],
|
x: parkedPosition[0],
|
||||||
y: position[1],
|
y: parkedPosition[1],
|
||||||
z: position[2],
|
z: parkedPosition[2],
|
||||||
});
|
});
|
||||||
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||||
new THREE.Vector3(...position),
|
new THREE.Vector3(...parkedPosition),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
||||||
const restingPositionRef = useRef<Vector3Tuple>([
|
const restingPositionRef = useRef<Vector3Tuple>([
|
||||||
position[0],
|
parkedPosition[0],
|
||||||
position[1] - PLAYER_EYE_HEIGHT,
|
parkedPosition[1],
|
||||||
position[2],
|
parkedPosition[2],
|
||||||
]);
|
]);
|
||||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
@@ -79,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||||
const [debugRestingPosition, setDebugRestingPosition] =
|
const [debugRestingPosition, setDebugRestingPosition] =
|
||||||
useState<Vector3Tuple>([
|
useState<Vector3Tuple>([
|
||||||
position[0],
|
parkedPosition[0],
|
||||||
position[1] - PLAYER_EYE_HEIGHT,
|
parkedPosition[1],
|
||||||
position[2],
|
parkedPosition[2],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (movementMode === "ebike") return;
|
||||||
|
|
||||||
|
restingPositionRef.current = parkedPosition;
|
||||||
|
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||||
|
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||||
|
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.position.set(...parkedPosition);
|
||||||
|
groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ebikeParkedPosition = parkedPosition;
|
||||||
|
window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y;
|
||||||
|
}, [movementMode, parkedPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
const fork = model.getObjectByName("fourche");
|
const fork = model.getObjectByName("fourche");
|
||||||
@@ -169,16 +207,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
];
|
];
|
||||||
|
const interactionLabel =
|
||||||
|
mainState === "ebike"
|
||||||
|
? "Réparer l'e-bike"
|
||||||
|
: movementMode === "walk"
|
||||||
|
? "Monter sur le bike"
|
||||||
|
: "Descendre du bike";
|
||||||
|
|
||||||
const handleInteract = useCallback((): void => {
|
const handleInteract = useCallback((): void => {
|
||||||
if (window.ebikeBreakdownActive === true) return;
|
if (window.ebikeBreakdownActive === true) return;
|
||||||
|
|
||||||
if (movementMode === "walk") {
|
if (movementMode === "walk") {
|
||||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
if (
|
||||||
|
mainState === "ebike" &&
|
||||||
|
(ebikeStep === "locked" || ebikeStep === "waiting")
|
||||||
|
) {
|
||||||
setMissionStep("ebike", "inspected");
|
setMissionStep("ebike", "inspected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mainState === "ebike" && ebikeStep === "inspected") {
|
||||||
|
setMissionStep("ebike", "fragmented");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cameraOffset = new THREE.Vector3(
|
const cameraOffset = new THREE.Vector3(
|
||||||
...EBIKE_CAMERA_TRANSFORM.position,
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
);
|
);
|
||||||
@@ -258,51 +310,51 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<group
|
{!repairGameOwnsEbikeModel ? (
|
||||||
ref={groupRef}
|
<group
|
||||||
position={position}
|
ref={groupRef}
|
||||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
position={parkedPosition}
|
||||||
>
|
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||||
<primitive object={model} />
|
scale={EBIKE_WORLD_SCALE}
|
||||||
<InteractableObject
|
|
||||||
kind="trigger"
|
|
||||||
label={
|
|
||||||
mainState === "ebike" && ebikeStep === "waiting"
|
|
||||||
? "Inspecter l'e-bike"
|
|
||||||
: movementMode === "walk"
|
|
||||||
? "Monter sur le bike"
|
|
||||||
: "Descendre du bike"
|
|
||||||
}
|
|
||||||
position={position}
|
|
||||||
radius={15}
|
|
||||||
onPress={handleInteract}
|
|
||||||
>
|
>
|
||||||
<mesh>
|
<primitive object={model} />
|
||||||
<boxGeometry args={[10, 13, 2]} />
|
<InteractableObject
|
||||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
kind="trigger"
|
||||||
</mesh>
|
label={interactionLabel}
|
||||||
</InteractableObject>
|
position={parkedPosition}
|
||||||
|
radius={5}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[8, 9, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
|
||||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||||
<EbikeGPSMap
|
<EbikeGPSMap
|
||||||
width={0.8}
|
width={0.8}
|
||||||
height={0.8}
|
height={0.8}
|
||||||
startPos={gpsStartPos}
|
startPos={gpsStartPos}
|
||||||
destPos={destPos}
|
destPos={destPos}
|
||||||
mapImageUrl="/assets/world/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
minZ: -142,
|
minZ: -142,
|
||||||
maxZ: 138,
|
maxZ: 138,
|
||||||
}}
|
}}
|
||||||
zoom={4}
|
zoom={4}
|
||||||
/>
|
/>
|
||||||
|
</group>
|
||||||
|
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
|
||||||
|
<EbikeSpeedometer />
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
) : null}
|
||||||
|
|
||||||
{showCameraPoints && (
|
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||||
<>
|
<>
|
||||||
<mesh position={camPointPos}>
|
<mesh position={camPointPos}>
|
||||||
<sphereGeometry args={[0.3, 16, 16]} />
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
|
|||||||
* Default: 1
|
* Default: 1
|
||||||
*/
|
*/
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
|
||||||
|
renderOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
position = [0, 0, 0],
|
position = [0, 0, 0],
|
||||||
canvasSize = 1024,
|
canvasSize = 1024,
|
||||||
zoom = 1,
|
zoom = 1,
|
||||||
|
renderOrder = 10_000,
|
||||||
}) => {
|
}) => {
|
||||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
const [mapImage, setMapImage] = useState<
|
const [mapImage, setMapImage] = useState<
|
||||||
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
}, [draw]);
|
}, [draw]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow receiveShadow position={position}>
|
<mesh position={position} renderOrder={renderOrder}>
|
||||||
<planeGeometry args={[width, height]} />
|
<planeGeometry args={[width, height]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
toneMapped={false}
|
toneMapped={false}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
opacity={1}
|
opacity={1}
|
||||||
|
depthTest={false}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
side={THREE.DoubleSide}
|
side={THREE.DoubleSide}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useTexture } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||||
|
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||||
|
const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
|
||||||
|
const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
|
||||||
|
const SPEEDOMETER_RENDER_ORDER = 10_000;
|
||||||
|
|
||||||
|
interface EbikeSpeedometerProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EbikeSpeedometer({
|
||||||
|
width = 0.9,
|
||||||
|
height = 0.5,
|
||||||
|
}: EbikeSpeedometerProps): React.JSX.Element {
|
||||||
|
const needleGroupRef = useRef<THREE.Group>(null);
|
||||||
|
const speedFactorRef = useRef(0);
|
||||||
|
const [dialTexture, needleTexture] = useTexture([
|
||||||
|
SPEEDOMETER_DIAL_TEXTURE,
|
||||||
|
SPEEDOMETER_NEEDLE_TEXTURE,
|
||||||
|
]) as [THREE.Texture, THREE.Texture];
|
||||||
|
const needleWidth = width * 0.68;
|
||||||
|
const needleHeight = needleWidth / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
[dialTexture, needleTexture].forEach((texture) => {
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [dialTexture, needleTexture]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const targetSpeedFactor = THREE.MathUtils.clamp(
|
||||||
|
window.ebikeSpeedFactor ?? 0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||||
|
speedFactorRef.current,
|
||||||
|
targetSpeedFactor,
|
||||||
|
Math.min(1, delta * 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needleGroupRef.current) {
|
||||||
|
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
SPEEDOMETER_MIN_ANGLE,
|
||||||
|
SPEEDOMETER_MAX_ANGLE,
|
||||||
|
speedFactorRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||||
|
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={dialTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
|
||||||
|
<mesh
|
||||||
|
position={[0, needleHeight / 2, 0]}
|
||||||
|
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={needleTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
import { MissionNotification } from "@/components/ui/MissionNotification";
|
import { MissionNotification } from "@/components/ui/MissionNotification";
|
||||||
import {
|
import {
|
||||||
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
||||||
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||||
EBIKE_INTRO_RIDE_DURATION_MS,
|
EBIKE_INTRO_BREAKDOWN_DISTANCE,
|
||||||
EBIKE_SOUNDS,
|
EBIKE_SOUNDS,
|
||||||
} from "@/data/ebike/ebikeConfig";
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
@@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||||
const hasStartedBreakdown = useRef(false);
|
const hasStartedBreakdown = useRef(false);
|
||||||
|
const rideDistance = useRef(0);
|
||||||
|
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
|
||||||
|
const currentRidePosition = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||||
@@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
}, [introStep, movementMode, setIntroStep]);
|
}, [introStep, movementMode, setIntroStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "ebike-intro-ride") return undefined;
|
if (introStep !== "ebike-intro-ride") return;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
rideDistance.current = 0;
|
||||||
setIntroStep("ebike-breakdown");
|
lastRidePosition.current = null;
|
||||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
}, [introStep]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
window.clearTimeout(timeoutId);
|
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrameId = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const parkedPosition = window.ebikeParkedPosition;
|
||||||
|
if (parkedPosition) {
|
||||||
|
currentRidePosition.current.set(...parkedPosition);
|
||||||
|
if (!lastRidePosition.current) {
|
||||||
|
lastRidePosition.current = currentRidePosition.current.clone();
|
||||||
|
} else {
|
||||||
|
rideDistance.current += currentRidePosition.current.distanceTo(
|
||||||
|
lastRidePosition.current,
|
||||||
|
);
|
||||||
|
lastRidePosition.current.copy(currentRidePosition.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
|
||||||
|
setIntroStep("ebike-breakdown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
}, [introStep, setIntroStep]);
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [introStep, movementMode, setIntroStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||||
@@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
}, [introStep]);
|
}, [introStep]);
|
||||||
|
|
||||||
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
if (
|
||||||
|
introStep !== "reveal" &&
|
||||||
|
introStep !== "await-ebike-mount" &&
|
||||||
|
introStep !== "ebike-intro-ride" &&
|
||||||
|
introStep !== "ebike-breakdown"
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (introStep === "ebike-breakdown") {
|
||||||
|
return <MissionNotification mission="ebike" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MissionNotification
|
<MissionNotification
|
||||||
mission="ebike"
|
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||||
visible={introStep === "await-ebike-mount"}
|
visible={
|
||||||
|
introStep === "reveal" ||
|
||||||
|
introStep === "await-ebike-mount" ||
|
||||||
|
introStep === "ebike-intro-ride"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/logo/logo.jpg"
|
src="/assets/logo.png"
|
||||||
alt="Logo Altera"
|
alt="Logo Altera"
|
||||||
style={{ width: 120, height: "auto" }}
|
style={{ width: 120, height: "auto" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
|||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||||
|
|
||||||
export function GameUI(): React.JSX.Element {
|
export function GameUI(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
|||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
<HandTrackingVisualizer />
|
<HandTrackingVisualizer />
|
||||||
<Subtitles />
|
<Subtitles />
|
||||||
|
<TalkieDialogueOverlay />
|
||||||
<GameSettingsMenu />
|
<GameSettingsMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
interface MissionNotificationProps {
|
interface MissionNotificationProps {
|
||||||
mission: RepairMissionId;
|
mission?: RepairMissionId;
|
||||||
|
imagePath?: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionNotification({
|
export function MissionNotification({
|
||||||
mission,
|
mission,
|
||||||
|
imagePath,
|
||||||
visible = true,
|
visible = true,
|
||||||
}: MissionNotificationProps): React.JSX.Element {
|
}: MissionNotificationProps): React.JSX.Element {
|
||||||
|
const src =
|
||||||
|
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||||
@@ -19,7 +24,7 @@ export function MissionNotification({
|
|||||||
<span className="mission-notification__image-wrap">
|
<span className="mission-notification__image-wrap">
|
||||||
<img
|
<img
|
||||||
className="mission-notification__image"
|
className="mission-notification__image"
|
||||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
src={src}
|
||||||
alt="Nouvel objectif de mission"
|
alt="Nouvel objectif de mission"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
|
|||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
|
||||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
||||||
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
|
const LOADING_LOGO_PATH = "/assets/logo.png";
|
||||||
|
|
||||||
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { Suspense, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
|
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||||
|
const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4";
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
|
||||||
|
TALKIE_FIRST_VISIBLE_STEP,
|
||||||
|
);
|
||||||
|
|
||||||
|
const TALKIE_REST_Y = -1.55;
|
||||||
|
const TALKIE_ACTIVE_Y = -0.92;
|
||||||
|
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
|
||||||
|
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
|
||||||
|
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
|
||||||
|
const TALKIE_SCREEN_TEXTURE_SIZE = 512;
|
||||||
|
|
||||||
|
interface TalkieModelProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TalkieVideoResources {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
context: CanvasRenderingContext2D | null;
|
||||||
|
material: THREE.MeshBasicMaterial;
|
||||||
|
texture: THREE.CanvasTexture;
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTalkieVideoResources(): TalkieVideoResources {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = TALKIE_VIDEO_PATH;
|
||||||
|
video.crossOrigin = "anonymous";
|
||||||
|
video.loop = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = "auto";
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = TALKIE_SCREEN_TEXTURE_SIZE;
|
||||||
|
canvas.height = TALKIE_SCREEN_TEXTURE_SIZE;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.flipY = false;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
toneMapped: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { canvas, context, material, texture, video };
|
||||||
|
}
|
||||||
|
|
||||||
|
function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
|
||||||
|
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
||||||
|
const model = useMemo(() => scene.clone(true), [scene]);
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const screenRef = useRef<THREE.Mesh | null>(null);
|
||||||
|
const originalScreenMaterialRef = useRef<THREE.Material | null>(null);
|
||||||
|
const videoResourcesRef = useRef<TalkieVideoResources | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const videoResources = createTalkieVideoResources();
|
||||||
|
videoResourcesRef.current = videoResources;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
videoResources.video.pause();
|
||||||
|
videoResources.video.removeAttribute("src");
|
||||||
|
videoResources.video.load();
|
||||||
|
videoResources.texture.dispose();
|
||||||
|
videoResources.material.dispose();
|
||||||
|
videoResourcesRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.castShadow = false;
|
||||||
|
child.receiveShadow = false;
|
||||||
|
child.frustumCulled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = model.getObjectByName("écran");
|
||||||
|
if (screen instanceof THREE.Mesh) {
|
||||||
|
screenRef.current = screen;
|
||||||
|
originalScreenMaterialRef.current = Array.isArray(screen.material)
|
||||||
|
? (screen.material[0] ?? null)
|
||||||
|
: screen.material;
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const screen = screenRef.current;
|
||||||
|
const originalMaterial = originalScreenMaterialRef.current;
|
||||||
|
const videoResources = videoResourcesRef.current;
|
||||||
|
|
||||||
|
if (!videoResources) return;
|
||||||
|
|
||||||
|
if (screen) {
|
||||||
|
screen.material = active
|
||||||
|
? videoResources.material
|
||||||
|
: (originalMaterial ?? videoResources.material);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
void videoResources.video.play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoResources.video.pause();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
|
||||||
|
const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY;
|
||||||
|
groupRef.current.position.y = THREE.MathUtils.lerp(
|
||||||
|
groupRef.current.position.y,
|
||||||
|
targetY,
|
||||||
|
0.14,
|
||||||
|
);
|
||||||
|
|
||||||
|
groupRef.current.rotation.x =
|
||||||
|
TALKIE_BASE_ROTATION[0] +
|
||||||
|
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
groupRef.current.rotation.y =
|
||||||
|
TALKIE_BASE_ROTATION[1] +
|
||||||
|
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
groupRef.current.rotation.z =
|
||||||
|
TALKIE_BASE_ROTATION[2] +
|
||||||
|
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
|
||||||
|
const videoResources = videoResourcesRef.current;
|
||||||
|
|
||||||
|
if (active && videoResources?.context) {
|
||||||
|
const { canvas, context, texture, video } = videoResources;
|
||||||
|
context.fillStyle = "#02040a";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
const videoAspect = video.videoWidth / video.videoHeight;
|
||||||
|
const canvasAspect = canvas.width / canvas.height;
|
||||||
|
const drawWidth =
|
||||||
|
videoAspect > canvasAspect
|
||||||
|
? canvas.width
|
||||||
|
: canvas.height * videoAspect;
|
||||||
|
const drawHeight =
|
||||||
|
videoAspect > canvasAspect
|
||||||
|
? canvas.width / videoAspect
|
||||||
|
: canvas.height;
|
||||||
|
const drawX = (canvas.width - drawWidth) / 2;
|
||||||
|
const drawY = (canvas.height - drawHeight) / 2;
|
||||||
|
|
||||||
|
context.drawImage(video, drawX, drawY, drawWidth, drawHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
ref={groupRef}
|
||||||
|
position={[0, TALKIE_REST_Y, 0]}
|
||||||
|
rotation={TALKIE_BASE_ROTATION}
|
||||||
|
>
|
||||||
|
<primitive
|
||||||
|
object={model}
|
||||||
|
position={[0, -3.25, 0]}
|
||||||
|
rotation={[0, -1, 0]}
|
||||||
|
scale={1.5}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TalkieSignalLinesProps {
|
||||||
|
side: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TalkieSignalLines({
|
||||||
|
side,
|
||||||
|
}: TalkieSignalLinesProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
|
||||||
|
viewBox="0 0 90 120"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 48 C30 58 30 72 18 82" />
|
||||||
|
<path d="M34 34 C56 52 56 78 34 96" />
|
||||||
|
<path d="M52 20 C84 46 84 84 52 110" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||||
|
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const introStepIndex = GAME_STEPS.indexOf(introStep);
|
||||||
|
const hasTalkieBeenRevealed =
|
||||||
|
mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX;
|
||||||
|
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
|
||||||
|
|
||||||
|
if (!hasTalkieBeenRevealed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active talkie-dialogue-overlay--raised" : ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
|
||||||
|
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
|
||||||
|
<div className="talkie-dialogue-overlay__model-frame">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 0, 4.2], zoom: 62 }}
|
||||||
|
dpr={[1, 1.5]}
|
||||||
|
gl={{ alpha: true, antialias: true }}
|
||||||
|
orthographic
|
||||||
|
>
|
||||||
|
<ambientLight intensity={2.5} />
|
||||||
|
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<TalkieModel active={isNarratorDialogue} />
|
||||||
|
</Suspense>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(TALKIE_MODEL_PATH);
|
||||||
@@ -6,19 +6,20 @@ export interface CameraTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
position: [-3.5, 6, 0],
|
position: [-2.6, 4.5, 0],
|
||||||
rotation: [-10, -90, 0],
|
rotation: [-10, -90, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
position: [0, 1.5, -3],
|
position: [0, 1.3, -2.25],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
|
export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
|
||||||
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
|
export const EBIKE_WORLD_ROTATION_Y = -2.5;
|
||||||
|
export const EBIKE_WORLD_SCALE = 0.35;
|
||||||
|
|
||||||
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
|
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
|
||||||
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||||
|
|
||||||
export const EBIKE_MAX_SPEED = 3;
|
export const EBIKE_MAX_SPEED = 3;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
|
||||||
|
"/assets/world/UI/intro-mission-notification.png";
|
||||||
|
|
||||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||||
{
|
{
|
||||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
|
||||||
|
|
||||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
export const PLAYER_WALK_SPEED = 5;
|
export const PLAYER_WALK_SPEED = 5;
|
||||||
export const PLAYER_EBIKE_SPEED = 20;
|
export const PLAYER_EBIKE_SPEED = 30;
|
||||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
export const PLAYER_JUMP_SPEED = 9;
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
export const PLAYER_GRAVITY = 30;
|
export const PLAYER_GRAVITY = 30;
|
||||||
@@ -14,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
|||||||
export const PLAYER_FALL_RESPAWN_Y = -20;
|
export const PLAYER_FALL_RESPAWN_Y = -20;
|
||||||
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
||||||
|
|
||||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
|
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[0] + 1,
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[1],
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||||
|
];
|
||||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
|
||||||
|
|
||||||
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||||
|
|
||||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||||
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
|
|||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
label: "High",
|
label: "High",
|
||||||
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
|
chunkLoadRadius: 35,
|
||||||
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
|
chunkUnloadRadius: 45,
|
||||||
fogEnabled: false,
|
fogEnabled: false,
|
||||||
forceLodModels: false,
|
forceLodModels: false,
|
||||||
lodHighDetailDistance: 10,
|
lodHighDetailDistance: 10,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
|
||||||
|
export const LA_FABRIK_ROTATION_Y = 2.4107;
|
||||||
|
export const LA_FABRIK_HALF_EXTENTS = {
|
||||||
|
x: 8.5,
|
||||||
|
z: 7.5,
|
||||||
|
} as const;
|
||||||
|
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64];
|
||||||
|
export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.3, 62.5];
|
||||||
|
export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
|
||||||
|
|
||||||
|
export function isInsideLaFabrikFootprint(
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
padding = 0,
|
||||||
|
): boolean {
|
||||||
|
const dx = x - LA_FABRIK_CENTER[0];
|
||||||
|
const dz = z - LA_FABRIK_CENTER[2];
|
||||||
|
const cos = Math.cos(-LA_FABRIK_ROTATION_Y);
|
||||||
|
const sin = Math.sin(-LA_FABRIK_ROTATION_Y);
|
||||||
|
const localX = dx * cos - dz * sin;
|
||||||
|
const localZ = dx * sin + dz * cos;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding &&
|
||||||
|
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
|
||||||
|
);
|
||||||
|
}
|
||||||
+116
-5
@@ -942,11 +942,9 @@ canvas {
|
|||||||
.scene-loading-overlay__logo {
|
.scene-loading-overlay__logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: clamp(180px, 28vw, 320px);
|
width: clamp(207px, 32.2vw, 368px);
|
||||||
max-height: min(38vh, 320px);
|
max-height: min(43.7vh, 368px);
|
||||||
border-radius: 16px;
|
object-fit: contain;
|
||||||
object-fit: cover;
|
|
||||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__footer {
|
.scene-loading-overlay__footer {
|
||||||
@@ -1237,6 +1235,119 @@ canvas {
|
|||||||
color: #f9a8d4;
|
color: #f9a8d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dialogue talkie */
|
||||||
|
.talkie-dialogue-overlay {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 16;
|
||||||
|
width: clamp(190px, 18vw, 310px);
|
||||||
|
aspect-ratio: 1.05;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay--raised {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__model-frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: -18% -12% -6% -12%;
|
||||||
|
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__model-frame canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 38%;
|
||||||
|
z-index: 2;
|
||||||
|
width: 34%;
|
||||||
|
height: 50%;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 0.72;
|
||||||
|
animation: talkie-signal-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals--left {
|
||||||
|
left: 7%;
|
||||||
|
scale: -1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals--right {
|
||||||
|
right: 7%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals path {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(162, 210, 255, 0.92);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 4;
|
||||||
|
filter: drop-shadow(0 0 5px rgba(125, 211, 252, 0.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals path:nth-child(2) {
|
||||||
|
animation-delay: 90ms;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals path:nth-child(3) {
|
||||||
|
animation-delay: 180ms;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes talkie-radio-shake {
|
||||||
|
0%,
|
||||||
|
11%,
|
||||||
|
23%,
|
||||||
|
100% {
|
||||||
|
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
3%,
|
||||||
|
15%,
|
||||||
|
27% {
|
||||||
|
transform: translate3d(-2px, 1px, 0) rotate(-1.7deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
6%,
|
||||||
|
18%,
|
||||||
|
30% {
|
||||||
|
transform: translate3d(2px, -1px, 0) rotate(1.7deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
9%,
|
||||||
|
21%,
|
||||||
|
33% {
|
||||||
|
transform: translate3d(-1px, 0, 0) rotate(-0.8deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes talkie-signal-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.28;
|
||||||
|
transform: translate3d(-4px, 4px, 0) scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
18%,
|
||||||
|
38% {
|
||||||
|
opacity: 0.95;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: translate3d(4px, -6px, 0) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* In-game settings menu */
|
/* In-game settings menu */
|
||||||
.game-settings-menu {
|
.game-settings-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import type { AudioCategory } from "@/managers/AudioManager";
|
import type { AudioCategory } from "@/managers/AudioManager";
|
||||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
@@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: SettingsState = {
|
|||||||
subtitleLanguage: "fr",
|
subtitleLanguage: "fr",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SETTINGS_STORAGE_KEY = "la-fabrik-settings";
|
||||||
|
|
||||||
function clampVolume(volume: number): number {
|
function clampVolume(volume: number): number {
|
||||||
return Math.max(0, Math.min(1, volume));
|
return Math.max(0, Math.min(1, volume));
|
||||||
}
|
}
|
||||||
@@ -46,36 +49,50 @@ function setAudioCategoryVolume(
|
|||||||
return nextVolume;
|
return nextVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDefaultAudioSettings(): void {
|
function applyAudioSettings(
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
settings: Pick<SettingsState, "musicVolume" | "sfxVolume" | "dialogueVolume">,
|
||||||
"music",
|
): void {
|
||||||
DEFAULT_SETTINGS.musicVolume,
|
AudioManager.getInstance().setCategoryVolume("music", settings.musicVolume);
|
||||||
);
|
AudioManager.getInstance().setCategoryVolume("sfx", settings.sfxVolume);
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
|
||||||
"sfx",
|
|
||||||
DEFAULT_SETTINGS.sfxVolume,
|
|
||||||
);
|
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
AudioManager.getInstance().setCategoryVolume(
|
||||||
"dialogue",
|
"dialogue",
|
||||||
DEFAULT_SETTINGS.dialogueVolume,
|
settings.dialogueVolume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultAudioSettings();
|
applyAudioSettings(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>()((set) => ({
|
export const useSettingsStore = create<SettingsStore>()(
|
||||||
...DEFAULT_SETTINGS,
|
persist(
|
||||||
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
(set) => ({
|
||||||
setMusicVolume: (volume) =>
|
...DEFAULT_SETTINGS,
|
||||||
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
||||||
setSfxVolume: (volume) =>
|
setMusicVolume: (volume) =>
|
||||||
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
||||||
setDialogueVolume: (volume) =>
|
setSfxVolume: (volume) =>
|
||||||
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
||||||
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
setDialogueVolume: (volume) =>
|
||||||
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
||||||
resetSettings: () => {
|
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
||||||
applyDefaultAudioSettings();
|
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
||||||
set(DEFAULT_SETTINGS);
|
resetSettings: () => {
|
||||||
},
|
applyAudioSettings(DEFAULT_SETTINGS);
|
||||||
}));
|
set(DEFAULT_SETTINGS);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: SETTINGS_STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => window.localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
dialogueVolume: state.dialogueVolume,
|
||||||
|
musicVolume: state.musicVolume,
|
||||||
|
sfxVolume: state.sfxVolume,
|
||||||
|
subtitleLanguage: state.subtitleLanguage,
|
||||||
|
subtitlesEnabled: state.subtitlesEnabled,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (state) applyAudioSettings(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||||
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
||||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||||
@@ -46,73 +47,89 @@ const DEFAULT_STATE: WorldSettingsState = {
|
|||||||
graphics: { ...GRAPHICS_DEFAULTS },
|
graphics: { ...GRAPHICS_DEFAULTS },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings";
|
||||||
...DEFAULT_STATE,
|
|
||||||
|
|
||||||
setClouds: (cloudsUpdate) =>
|
export const useWorldSettingsStore = create<WorldSettingsStore>()(
|
||||||
set((state) => ({
|
persist(
|
||||||
clouds: { ...state.clouds, ...cloudsUpdate },
|
(set) => ({
|
||||||
})),
|
...DEFAULT_STATE,
|
||||||
|
|
||||||
setFog: (fogUpdate) =>
|
setClouds: (cloudsUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
fog: { ...state.fog, ...fogUpdate },
|
clouds: { ...state.clouds, ...cloudsUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWind: (windUpdate) =>
|
setFog: (fogUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, ...windUpdate },
|
fog: { ...state.fog, ...fogUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindSpeed: (speed) =>
|
setWind: (windUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, speed },
|
wind: { ...state.wind, ...windUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindDirection: (direction) =>
|
setWindSpeed: (speed) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, direction },
|
wind: { ...state.wind, speed },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindStrength: (strength) =>
|
setWindDirection: (direction) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, strength },
|
wind: { ...state.wind, direction },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGraphics: (graphicsUpdate) =>
|
setWindStrength: (strength) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, ...graphicsUpdate },
|
wind: { ...state.wind, strength },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGraphicsPreset: (preset) =>
|
setGraphics: (graphicsUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, preset },
|
graphics: { ...state.graphics, ...graphicsUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicGrass: (dynamicGrass) =>
|
setGraphicsPreset: (preset) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicGrass },
|
graphics: { ...state.graphics, preset },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicTrees: (dynamicTrees) =>
|
setDynamicGrass: (dynamicGrass) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicTrees },
|
graphics: { ...state.graphics, dynamicGrass },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicClouds: (dynamicClouds) =>
|
setDynamicTrees: (dynamicTrees) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicClouds },
|
graphics: { ...state.graphics, dynamicTrees },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setShadowsEnabled: (shadowsEnabled) =>
|
setDynamicClouds: (dynamicClouds) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, shadowsEnabled },
|
graphics: { ...state.graphics, dynamicClouds },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGrassDensity: (grassDensity) =>
|
setShadowsEnabled: (shadowsEnabled) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, grassDensity },
|
graphics: { ...state.graphics, shadowsEnabled },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
resetToDefaults: () => set(DEFAULT_STATE),
|
setGrassDensity: (grassDensity) =>
|
||||||
}));
|
set((state) => ({
|
||||||
|
graphics: { ...state.graphics, grassDensity },
|
||||||
|
})),
|
||||||
|
|
||||||
|
resetToDefaults: () => set(DEFAULT_STATE),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: WORLD_SETTINGS_STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => window.localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
clouds: state.clouds,
|
||||||
|
fog: state.fog,
|
||||||
|
graphics: state.graphics,
|
||||||
|
wind: state.wind,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
gl.shadowMap.enabled = true;
|
gl.shadowMap.enabled = true;
|
||||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.shadowMap.autoUpdate = true;
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
|
||||||
// The browser hands us a WEBGL_lose_context extension we can use to
|
// The browser hands us a WEBGL_lose_context extension we can use to
|
||||||
// ask the GPU to restore the context after a loss. Without this the
|
// ask the GPU to restore the context after a loss. Without this the
|
||||||
@@ -148,6 +149,7 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
gl.shadowMap.enabled = true;
|
gl.shadowMap.enabled = true;
|
||||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.shadowMap.autoUpdate = true;
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
logger.info("WebGL", "Context restored");
|
logger.info("WebGL", "Context restored");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,10 @@ import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
|||||||
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
||||||
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
||||||
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
|
||||||
import { SiteLayout } from "@/components/site/SiteLayout";
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
|
||||||
|
|
||||||
export function SitePage(): React.JSX.Element {
|
export function SitePage(): React.JSX.Element {
|
||||||
const currentStep = useSiteStore((state) => state.currentStep);
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return <SiteMobileBlocker />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStep === "disclaimer") {
|
if (currentStep === "disclaimer") {
|
||||||
return <SiteDisclaimerScreen />;
|
return <SiteDisclaimerScreen />;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
|
|||||||
|
|
||||||
interface StoredDebugControls {
|
interface StoredDebugControls {
|
||||||
cameraMode: CameraMode;
|
cameraMode: CameraMode;
|
||||||
|
handTrackingSource: HandTrackingSource;
|
||||||
sceneMode: SceneMode;
|
sceneMode: SceneMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ function isSceneMode(value: unknown): value is SceneMode {
|
|||||||
return value === "game" || value === "physics";
|
return value === "game" || value === "physics";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHandTrackingSource(value: unknown): value is HandTrackingSource {
|
||||||
|
return value === "browser" || value === "backend";
|
||||||
|
}
|
||||||
|
|
||||||
function getStoredDebugControls(): Partial<StoredDebugControls> {
|
function getStoredDebugControls(): Partial<StoredDebugControls> {
|
||||||
try {
|
try {
|
||||||
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
|
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
|
||||||
@@ -51,6 +56,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
|
|||||||
...(isCameraMode(parsedValue.cameraMode)
|
...(isCameraMode(parsedValue.cameraMode)
|
||||||
? { cameraMode: parsedValue.cameraMode }
|
? { cameraMode: parsedValue.cameraMode }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(isHandTrackingSource(parsedValue.handTrackingSource)
|
||||||
|
? { handTrackingSource: parsedValue.handTrackingSource }
|
||||||
|
: {}),
|
||||||
...(isSceneMode(parsedValue.sceneMode)
|
...(isSceneMode(parsedValue.sceneMode)
|
||||||
? { sceneMode: parsedValue.sceneMode }
|
? { sceneMode: parsedValue.sceneMode }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -94,7 +102,7 @@ export class Debug {
|
|||||||
this.controls = {
|
this.controls = {
|
||||||
cameraMode: storedControls.cameraMode ?? "player",
|
cameraMode: storedControls.cameraMode ?? "player",
|
||||||
fogEnabled: FOG_CONFIG.enabled,
|
fogEnabled: FOG_CONFIG.enabled,
|
||||||
handTrackingSource: "browser",
|
handTrackingSource: storedControls.handTrackingSource ?? "browser",
|
||||||
showDebugOverlay: true,
|
showDebugOverlay: true,
|
||||||
showHandTrackingSvg: false,
|
showHandTrackingSvg: false,
|
||||||
showInteractionSpheres: false,
|
showInteractionSpheres: false,
|
||||||
@@ -159,7 +167,7 @@ export class Debug {
|
|||||||
.name("Source")
|
.name("Source")
|
||||||
.onChange((value: HandTrackingSource) => {
|
.onChange((value: HandTrackingSource) => {
|
||||||
this.controls.handTrackingSource = value;
|
this.controls.handTrackingSource = value;
|
||||||
this.emit();
|
this.saveAndEmit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +254,7 @@ export class Debug {
|
|||||||
|
|
||||||
setHandTrackingSource(value: HandTrackingSource): void {
|
setHandTrackingSource(value: HandTrackingSource): void {
|
||||||
this.controls.handTrackingSource = value;
|
this.controls.handTrackingSource = value;
|
||||||
this.emit();
|
this.saveAndEmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
getFogEnabled(): boolean {
|
getFogEnabled(): boolean {
|
||||||
@@ -285,6 +293,7 @@ export class Debug {
|
|||||||
DEBUG_CONTROLS_STORAGE_KEY,
|
DEBUG_CONTROLS_STORAGE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
cameraMode: this.controls.cameraMode,
|
cameraMode: this.controls.cameraMode,
|
||||||
|
handTrackingSource: this.controls.handTrackingSource,
|
||||||
sceneMode: this.controls.sceneMode,
|
sceneMode: this.controls.sceneMode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -223,6 +223,22 @@ function CollisionModelInstance({
|
|||||||
scale: normalizedScale,
|
scale: normalizedScale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
useEffect(() => {
|
||||||
|
// Strip the door slab from the la fabrik collision octree so the player
|
||||||
|
// can walk through the doorway. The visual model is rendered separately
|
||||||
|
// by MergedStaticMapModel and is unaffected.
|
||||||
|
if (node.name !== "lafabrik") return;
|
||||||
|
|
||||||
|
const removed: THREE.Object3D[] = [];
|
||||||
|
sceneInstance.traverse((child) => {
|
||||||
|
if (child.name === "porte") {
|
||||||
|
removed.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const child of removed) {
|
||||||
|
child.removeFromParent();
|
||||||
|
}
|
||||||
|
}, [node.name, sceneInstance]);
|
||||||
const collisionPosition = useMemo(() => {
|
const collisionPosition = useMemo(() => {
|
||||||
if (node.name === "terrain") return position;
|
if (node.name === "terrain") return position;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
|||||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
import {
|
||||||
|
EBIKE_WORLD_POSITION,
|
||||||
|
EBIKE_WORLD_ROTATION_Y,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
|
||||||
|
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
|
||||||
|
|
||||||
interface StageAnchorProps {
|
interface StageAnchorProps {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -82,7 +88,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
const position = getRepairMissionPosition(mission, anchors);
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
|
|||||||
+41
-14
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import type { AmbientLight, DirectionalLight, Object3D } from "three";
|
import {
|
||||||
|
PCFShadowMap,
|
||||||
|
type AmbientLight,
|
||||||
|
type DirectionalLight,
|
||||||
|
type Object3D,
|
||||||
|
type WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
import {
|
import {
|
||||||
AMBIENT_INTENSITY_MAX,
|
AMBIENT_INTENSITY_MAX,
|
||||||
AMBIENT_INTENSITY_MIN,
|
AMBIENT_INTENSITY_MIN,
|
||||||
@@ -18,6 +24,7 @@ import {
|
|||||||
SUN_Z_MIN,
|
SUN_Z_MIN,
|
||||||
SUN_Z_STEP,
|
SUN_Z_STEP,
|
||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
|
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||||
|
|
||||||
@@ -26,8 +33,31 @@ const SHADOW_CAMERA_SIZE = 95;
|
|||||||
const SHADOW_CAMERA_NEAR = 0.5;
|
const SHADOW_CAMERA_NEAR = 0.5;
|
||||||
const SHADOW_CAMERA_FAR = 300;
|
const SHADOW_CAMERA_FAR = 300;
|
||||||
|
|
||||||
|
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||||
|
gl.shadowMap.enabled = true;
|
||||||
|
gl.shadowMap.type = PCFShadowMap;
|
||||||
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
||||||
|
sun.target = sunTarget;
|
||||||
|
sun.shadow.autoUpdate = true;
|
||||||
|
sun.shadow.needsUpdate = true;
|
||||||
|
sun.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
||||||
|
sun.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
||||||
|
sun.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
||||||
|
sun.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
||||||
|
sun.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
||||||
|
sun.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
||||||
|
sun.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
||||||
|
sun.shadow.camera.far = SHADOW_CAMERA_FAR;
|
||||||
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
export function Lighting(): React.JSX.Element {
|
export function Lighting(): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
const sunTarget = useRef<Object3D>(null);
|
const sunTarget = useRef<Object3D>(null);
|
||||||
@@ -35,19 +65,9 @@ export function Lighting(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sun.current || !sunTarget.current) return;
|
if (!sun.current || !sunTarget.current) return;
|
||||||
|
|
||||||
sun.current.target = sunTarget.current;
|
configureSunShadow(sun.current, sunTarget.current);
|
||||||
sun.current.shadow.autoUpdate = true;
|
configureRendererShadows(gl);
|
||||||
sun.current.shadow.needsUpdate = true;
|
}, [gl]);
|
||||||
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
|
||||||
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
|
||||||
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
|
||||||
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
|
||||||
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
|
||||||
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
|
||||||
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
|
||||||
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
|
|
||||||
sun.current.shadow.camera.updateProjectionMatrix();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||||
@@ -121,6 +141,13 @@ export function Lighting(): React.JSX.Element {
|
|||||||
castShadow
|
castShadow
|
||||||
/>
|
/>
|
||||||
<object3D ref={sunTarget} />
|
<object3D ref={sunTarget} />
|
||||||
|
<pointLight
|
||||||
|
position={LA_FABRIK_INTERIOR_LIGHT_POSITION}
|
||||||
|
color="#dbeafe"
|
||||||
|
intensity={1.2}
|
||||||
|
distance={14}
|
||||||
|
decay={1.6}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -4,6 +4,7 @@ import {
|
|||||||
PLAYER_SPAWN_POSITION_GAME,
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
|
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||||
@@ -99,7 +100,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
<GameMusic />
|
<GameMusic />
|
||||||
{mainState === "outro" ? <GameCinematics /> : null}
|
{mainState === "outro" ? <GameCinematics /> : null}
|
||||||
{mainState !== "intro" ? <GameDialogues /> : null}
|
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
<Player
|
||||||
|
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
|
||||||
|
octree={octree}
|
||||||
|
spawnPosition={playerSpawnPosition}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
GRASS_COLORS,
|
GRASS_COLORS,
|
||||||
GRASS_CONFIG,
|
GRASS_CONFIG,
|
||||||
} from "@/data/world/grassConfig";
|
} from "@/data/world/grassConfig";
|
||||||
|
import {
|
||||||
|
LA_FABRIK_CENTER,
|
||||||
|
LA_FABRIK_HALF_EXTENTS,
|
||||||
|
LA_FABRIK_ROTATION_Y,
|
||||||
|
} from "@/data/world/laFabrikConfig";
|
||||||
import {
|
import {
|
||||||
grassFragmentShader,
|
grassFragmentShader,
|
||||||
grassVertexShader,
|
grassVertexShader,
|
||||||
@@ -169,6 +174,17 @@ function createGrassMaterial(
|
|||||||
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
||||||
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
||||||
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
||||||
|
uLaFabrikCenter: {
|
||||||
|
value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]),
|
||||||
|
},
|
||||||
|
uLaFabrikHalfExtents: {
|
||||||
|
value: new THREE.Vector2(
|
||||||
|
LA_FABRIK_HALF_EXTENTS.x,
|
||||||
|
LA_FABRIK_HALF_EXTENTS.z,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y },
|
||||||
|
uLaFabrikNoGrassFeather: { value: 1.4 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export const grassVertexShader = /* glsl */ `
|
|||||||
uniform float uMaxBladeHeight;
|
uniform float uMaxBladeHeight;
|
||||||
uniform float uRandomHeightAmount;
|
uniform float uRandomHeightAmount;
|
||||||
uniform float uSurfaceOffset;
|
uniform float uSurfaceOffset;
|
||||||
|
uniform vec2 uLaFabrikCenter;
|
||||||
|
uniform vec2 uLaFabrikHalfExtents;
|
||||||
|
uniform float uLaFabrikRotation;
|
||||||
|
uniform float uLaFabrikNoGrassFeather;
|
||||||
|
|
||||||
float random(vec2 st) {
|
float random(vec2 st) {
|
||||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
||||||
@@ -132,6 +136,18 @@ export const grassVertexShader = /* glsl */ `
|
|||||||
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
|
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
|
||||||
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
|
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
|
||||||
|
|
||||||
|
vec2 laFabrikDelta = worldPos.xz - uLaFabrikCenter;
|
||||||
|
float laFabrikCos = cos(-uLaFabrikRotation);
|
||||||
|
float laFabrikSin = sin(-uLaFabrikRotation);
|
||||||
|
vec2 laFabrikLocal = vec2(
|
||||||
|
laFabrikDelta.x * laFabrikCos - laFabrikDelta.y * laFabrikSin,
|
||||||
|
laFabrikDelta.x * laFabrikSin + laFabrikDelta.y * laFabrikCos
|
||||||
|
);
|
||||||
|
vec2 laFabrikDistance = abs(laFabrikLocal) - uLaFabrikHalfExtents;
|
||||||
|
float laFabrikOutsideDistance = max(laFabrikDistance.x, laFabrikDistance.y);
|
||||||
|
float laFabrikGrassMask = smoothstep(0.0, uLaFabrikNoGrassFeather, laFabrikOutsideDistance);
|
||||||
|
heightModifier *= laFabrikGrassMask;
|
||||||
|
|
||||||
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
|
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
|
||||||
float tipFactor = color.g;
|
float tipFactor = color.g;
|
||||||
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
|
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController";
|
|||||||
|
|
||||||
interface PlayerProps {
|
interface PlayerProps {
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
|
initialLookAt?: Vector3Tuple | undefined;
|
||||||
spawnPosition: Vector3Tuple;
|
spawnPosition: Vector3Tuple;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Player({
|
export function Player({
|
||||||
|
initialLookAt,
|
||||||
spawnPosition,
|
spawnPosition,
|
||||||
octree,
|
octree,
|
||||||
}: PlayerProps): React.JSX.Element {
|
}: PlayerProps): React.JSX.Element {
|
||||||
@@ -18,12 +20,17 @@ export function Player({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
camera.position.set(...spawnPosition);
|
camera.position.set(...spawnPosition);
|
||||||
}, [camera, spawnPosition]);
|
if (initialLookAt) camera.lookAt(...initialLookAt);
|
||||||
|
}, [camera, initialLookAt, spawnPosition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PlayerCamera />
|
<PlayerCamera />
|
||||||
<PlayerController octree={octree} spawnPosition={spawnPosition} />
|
<PlayerController
|
||||||
|
initialLookAt={initialLookAt}
|
||||||
|
octree={octree}
|
||||||
|
spawnPosition={spawnPosition}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15;
|
|||||||
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
||||||
|
|
||||||
interface PlayerControllerProps {
|
interface PlayerControllerProps {
|
||||||
|
initialLookAt?: Vector3Tuple | undefined;
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
spawnPosition: Vector3Tuple;
|
spawnPosition: Vector3Tuple;
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
|
|||||||
function resetPlayerCapsule(
|
function resetPlayerCapsule(
|
||||||
capsule: Capsule,
|
capsule: Capsule,
|
||||||
spawnPosition: Vector3Tuple,
|
spawnPosition: Vector3Tuple,
|
||||||
|
initialLookAt: Vector3Tuple | undefined,
|
||||||
camera: THREE.Camera,
|
camera: THREE.Camera,
|
||||||
velocity: THREE.Vector3,
|
velocity: THREE.Vector3,
|
||||||
): void {
|
): void {
|
||||||
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
|
|||||||
capsule.end.set(...spawnPosition);
|
capsule.end.set(...spawnPosition);
|
||||||
velocity.set(0, 0, 0);
|
velocity.set(0, 0, 0);
|
||||||
camera.position.copy(capsule.end);
|
camera.position.copy(capsule.end);
|
||||||
|
if (initialLookAt) camera.lookAt(...initialLookAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
||||||
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerController({
|
export function PlayerController({
|
||||||
|
initialLookAt,
|
||||||
octree,
|
octree,
|
||||||
spawnPosition,
|
spawnPosition,
|
||||||
}: PlayerControllerProps): null {
|
}: PlayerControllerProps): null {
|
||||||
@@ -234,6 +238,7 @@ export function PlayerController({
|
|||||||
resetPlayerCapsule(
|
resetPlayerCapsule(
|
||||||
capsule.current,
|
capsule.current,
|
||||||
spawnPosition,
|
spawnPosition,
|
||||||
|
initialLookAt,
|
||||||
camera,
|
camera,
|
||||||
velocity.current,
|
velocity.current,
|
||||||
);
|
);
|
||||||
@@ -241,7 +246,7 @@ export function PlayerController({
|
|||||||
onFloor.current = false;
|
onFloor.current = false;
|
||||||
wantsJump.current = false;
|
wantsJump.current = false;
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
}, [camera, spawnPosition]);
|
}, [camera, initialLookAt, spawnPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
movementLockedRef.current = movementLocked;
|
movementLockedRef.current = movementLocked;
|
||||||
@@ -339,6 +344,7 @@ export function PlayerController({
|
|||||||
resetPlayerCapsule(
|
resetPlayerCapsule(
|
||||||
capsule.current,
|
capsule.current,
|
||||||
spawnPosition,
|
spawnPosition,
|
||||||
|
initialLookAt,
|
||||||
camera,
|
camera,
|
||||||
velocity.current,
|
velocity.current,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
VEGETATION_TYPES,
|
VEGETATION_TYPES,
|
||||||
type VegetationType,
|
type VegetationType,
|
||||||
} from "@/data/world/vegetationConfig";
|
} from "@/data/world/vegetationConfig";
|
||||||
|
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
|
||||||
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||||
|
|
||||||
interface VegetationSystemProps {
|
interface VegetationSystemProps {
|
||||||
@@ -60,6 +61,15 @@ function createVegetationChunks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeLaFabrikVegetation(
|
||||||
|
instances: VegetationInstance[],
|
||||||
|
): VegetationInstance[] {
|
||||||
|
return instances.filter((instance) => {
|
||||||
|
const [x, , z] = instance.position;
|
||||||
|
return !isInsideLaFabrikFootprint(x, z, 1.2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function VegetationSystem({
|
export function VegetationSystem({
|
||||||
onlyMapName = null,
|
onlyMapName = null,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
@@ -90,7 +100,10 @@ export function VegetationSystem({
|
|||||||
const entry = data.get(config.mapName);
|
const entry = data.get(config.mapName);
|
||||||
if (!entry || entry.instances.length === 0) return [];
|
if (!entry || entry.instances.length === 0) return [];
|
||||||
|
|
||||||
return createVegetationChunks(type, entry.instances);
|
const instances = removeLaFabrikVegetation(entry.instances);
|
||||||
|
if (instances.length === 0) return [];
|
||||||
|
|
||||||
|
return createVegetationChunks(type, instances);
|
||||||
});
|
});
|
||||||
}, [data, groups, models, onlyMapName]);
|
}, [data, groups, models, onlyMapName]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user