Merge branch 'develop' into feat/polish-mission-2
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -33,9 +33,19 @@ const _up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
interface EbikeProps {
|
||||
position: Vector3Tuple;
|
||||
/**
|
||||
* When true (default), the parked position is snapped to the world terrain
|
||||
* height. Pass false in test scenes that don't render the world terrain so
|
||||
* the bike stays at the explicit Y of {@link position} instead of floating
|
||||
* at the (invisible) terrain height.
|
||||
*/
|
||||
snapToTerrain?: boolean;
|
||||
}
|
||||
|
||||
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
export function Ebike({
|
||||
position,
|
||||
snapToTerrain = true,
|
||||
}: EbikeProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||
scope: "Ebike",
|
||||
@@ -45,7 +55,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const parkedPosition = useMemo<Vector3Tuple>(() => {
|
||||
const [x, y, z] = position;
|
||||
const height = terrainHeight.getHeight(x, z) ?? y;
|
||||
const height = snapToTerrain ? (terrainHeight.getHeight(x, z) ?? y) : y;
|
||||
const bottomOffset = getObjectBottomOffset(model, [
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_SCALE,
|
||||
@@ -53,7 +63,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
]);
|
||||
|
||||
return [x, height + bottomOffset, z];
|
||||
}, [model, position, terrainHeight]);
|
||||
}, [model, position, snapToTerrain, terrainHeight]);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
@@ -119,12 +129,6 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
|
||||
// State for debug visualization (synced from refs during useFrame)
|
||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||
const [debugRestingPosition, setDebugRestingPosition] =
|
||||
useState<Vector3Tuple>([
|
||||
parkedPosition[0],
|
||||
parkedPosition[1],
|
||||
parkedPosition[2],
|
||||
]);
|
||||
|
||||
// Keep movementModeRef in sync — useFrame closures capture React state at
|
||||
// render time and can become stale between renders.
|
||||
@@ -135,7 +139,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
// SpotLight target must be in the scene to define the cone direction.
|
||||
useEffect(() => {
|
||||
threeScene.add(headlightTarget);
|
||||
return () => { threeScene.remove(headlightTarget); };
|
||||
return () => {
|
||||
threeScene.remove(headlightTarget);
|
||||
};
|
||||
}, [threeScene, headlightTarget]);
|
||||
|
||||
// Link the target to the SpotLight once it mounts.
|
||||
@@ -192,7 +198,9 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
|
||||
} else {
|
||||
const names: string[] = [];
|
||||
model.traverse((c) => { if (c.name) names.push(c.name); });
|
||||
model.traverse((c) => {
|
||||
if (c.name) names.push(c.name);
|
||||
});
|
||||
console.warn("[Ebike] Fork not found. All nodes:", names);
|
||||
}
|
||||
}, [model]);
|
||||
@@ -222,11 +230,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
useFrame((_, delta) => {
|
||||
// ── SpotLight headlight — tune the constants below ────────────────────────
|
||||
// ── SpotLight headlight — tune these four constants ───────────────────────
|
||||
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
|
||||
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
|
||||
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
|
||||
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
|
||||
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
|
||||
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
|
||||
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
|
||||
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
|
||||
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
|
||||
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
if (headlightRef.current && phareRef.current && groupRef.current) {
|
||||
phareRef.current.getWorldPosition(_phareWorldPos);
|
||||
@@ -307,9 +315,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
// Sync debug visualization state (throttled to avoid excessive re-renders)
|
||||
if (showCameraPoints) {
|
||||
setDebugRestingPosition([...restingPositionRef.current]);
|
||||
}
|
||||
// Debug visualization positions are derived elsewhere when needed.
|
||||
} else {
|
||||
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
|
||||
groupRef.current.position.set(...restingPositionRef.current);
|
||||
@@ -326,24 +332,26 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}
|
||||
});
|
||||
|
||||
// Debug visualization positions computed from state (not refs)
|
||||
const camPointPos: Vector3Tuple = [
|
||||
debugRestingPosition[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||
debugRestingPosition[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||
debugRestingPosition[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||
];
|
||||
const dropPointPos: Vector3Tuple = [
|
||||
debugRestingPosition[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||
];
|
||||
const interactionLabel =
|
||||
mainState === "ebike"
|
||||
? "Réparer l'e-bike"
|
||||
? "Lancer le repair game"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike";
|
||||
|
||||
// Hide the interact prompt while the player is actively riding the bike
|
||||
// (driving input pressed) so the "Descendre du bike" label doesn't
|
||||
// pollute the view. The prompt comes back the moment the bike comes to
|
||||
// a stop. window.ebikeDriveInputActive is published every frame by
|
||||
// PlayerController based on whether a movement key is currently held.
|
||||
const [isEbikeDriving, setIsEbikeDriving] = useState(false);
|
||||
useFrame(() => {
|
||||
const driving =
|
||||
movementMode === "ebike" && window.ebikeDriveInputActive === true;
|
||||
if (driving !== isEbikeDriving) setIsEbikeDriving(driving);
|
||||
});
|
||||
const showInteractPrompt = !isEbikeDriving;
|
||||
|
||||
const handleInteract = useCallback((): void => {
|
||||
if (window.ebikeBreakdownActive === true) return;
|
||||
|
||||
@@ -382,9 +390,15 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||
];
|
||||
|
||||
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||
});
|
||||
animateCameraTransformTransition(
|
||||
targetCamPos,
|
||||
targetRotation,
|
||||
1,
|
||||
() => {
|
||||
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||
},
|
||||
{ lockInput: false },
|
||||
);
|
||||
} else {
|
||||
const currentPos = new THREE.Vector3();
|
||||
if (groupRef.current) {
|
||||
@@ -410,9 +424,15 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||
];
|
||||
|
||||
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||
useGameStore.getState().setPlayerMovementMode("walk");
|
||||
});
|
||||
animateCameraTransformTransition(
|
||||
targetCamPos,
|
||||
targetRotation,
|
||||
1,
|
||||
() => {
|
||||
useGameStore.getState().setPlayerMovementMode("walk");
|
||||
},
|
||||
{ lockInput: false },
|
||||
);
|
||||
}
|
||||
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
|
||||
|
||||
@@ -451,25 +471,36 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
{/* radius 20 → ~7 unités monde (scale 0.35).
|
||||
Sphère omnidirectionnelle pour que le raycast fonctionne
|
||||
quelle que soit l'orientation de la caméra (montée ou à pied). */}
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
position={parkedPosition}
|
||||
radius={5}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[8, 15, 12]} />
|
||||
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
{showInteractPrompt ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
position={parkedPosition}
|
||||
radius={5}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[8, 15, 12]} />
|
||||
<meshBasicMaterial
|
||||
colorWrite={false}
|
||||
color={"red"}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
|
||||
{/* GPS + Speedmeter – same group so they are perfectly co-localised.
|
||||
GPS: full circle (Fresnel mask), renderOrder 10 000
|
||||
Speedmeter: upper-half arc overlay, renderOrder 10 001
|
||||
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
|
||||
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
|
||||
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
|
||||
<EbikeSpeedmeter
|
||||
width={3}
|
||||
height={1.5}
|
||||
position={[0, 0.4, 0]}
|
||||
gaugeInnerR={0.33}
|
||||
gaugeOuterR={0.445}
|
||||
gaugeWidth={2.5}
|
||||
gaugeHeight={2.1}
|
||||
gaugeOffsetX={0}
|
||||
@@ -499,8 +530,8 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
ref={headlightRef}
|
||||
intensity={100}
|
||||
color="#ffca60"
|
||||
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
|
||||
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
|
||||
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
|
||||
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
|
||||
distance={50}
|
||||
decay={2.5}
|
||||
castShadow={false}
|
||||
|
||||
@@ -181,6 +181,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
|
||||
// Sync texture into uniform when it changes (canvas resize)
|
||||
useEffect(() => {
|
||||
// External Three.js material uniform sync — intentional side effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
shaderMat.uniforms.map.value = texture;
|
||||
}, [shaderMat, texture]);
|
||||
|
||||
@@ -196,6 +198,8 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
// Resize the canvas whenever canvasSize changes (texture declared above)
|
||||
useEffect(() => {
|
||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||
// External Three.js texture invalidation — intentional side effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
texture.needsUpdate = true;
|
||||
}, [canvasSize, offscreenCanvas, texture]);
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ export function EbikeSpeedmeter({
|
||||
);
|
||||
|
||||
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||||
/* External Three.js canvas+texture sync — intentional side effects in useFrame. */
|
||||
/* eslint-disable react-hooks/immutability */
|
||||
useFrame((_, delta) => {
|
||||
// 1. Smooth speed factor
|
||||
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
|
||||
@@ -151,7 +153,7 @@ export function EbikeSpeedmeter({
|
||||
// Default centre: horizontal middle + needle-pivot height.
|
||||
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
|
||||
const cx = size * (0.5 + gaugeOffsetX);
|
||||
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
|
||||
const cy = size * (1 - NEEDLE_PIVOT_UV_Y + gaugeOffsetY); // default ≈ 0.88 × size
|
||||
|
||||
const outerR = size * gaugeOuterR;
|
||||
const innerR = size * gaugeInnerR;
|
||||
@@ -164,7 +166,7 @@ export function EbikeSpeedmeter({
|
||||
// Radial gradient using #3F67DD — slightly transparent at inner edge,
|
||||
// fully solid at outer edge for a depth effect.
|
||||
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
|
||||
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||||
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||||
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
|
||||
|
||||
// Annular sector shape (outer arc + inner arc reversed)
|
||||
@@ -181,6 +183,7 @@ export function EbikeSpeedmeter({
|
||||
}
|
||||
|
||||
fillTexture.needsUpdate = true;
|
||||
/* eslint-enable react-hooks/immutability */
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -212,11 +215,12 @@ export function EbikeSpeedmeter({
|
||||
</mesh>
|
||||
|
||||
{/* Needle — pivot at bottom-centre of the arc */}
|
||||
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
|
||||
<mesh
|
||||
position={[0, needleHeight / 2, 0]}
|
||||
renderOrder={renderOrder + 1}
|
||||
>
|
||||
<group
|
||||
ref={needleGroupRef}
|
||||
position={[0, -height * 0.38, 0.002]}
|
||||
rotation={[0, 0, 0]}
|
||||
>
|
||||
<mesh position={[0, needleHeight / 2, 0]} renderOrder={renderOrder + 1}>
|
||||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||
<meshBasicMaterial
|
||||
map={needleTexture}
|
||||
|
||||
@@ -49,6 +49,9 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
useEffect(() => {
|
||||
if (step === "arrived") {
|
||||
hasPlayedFirstAudioRef.current = false;
|
||||
// Reset the "raised" latch when a new run begins. This is derived
|
||||
// resync from the step prop and runs once per step transition.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsRaised(false);
|
||||
}
|
||||
}, [step]);
|
||||
@@ -133,7 +136,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
void (async () => {
|
||||
const m = await loadDialogueManifest();
|
||||
if (!m) return;
|
||||
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
await playDialogueById(
|
||||
m,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
},
|
||||
{ once: true },
|
||||
@@ -143,7 +149,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
}
|
||||
} else if (step === "npc-return" && !isStraightening) {
|
||||
|
||||
@@ -34,7 +34,10 @@ const _target = new THREE.Vector3();
|
||||
* Compute the Y rotation (radians) for a model whose default forward
|
||||
* direction is +Z, so that it faces from `from` toward `to`.
|
||||
*/
|
||||
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number {
|
||||
function faceToward(
|
||||
from: THREE.Vector3,
|
||||
to: readonly [number, number, number],
|
||||
): number {
|
||||
const dx = to[0] - from.x;
|
||||
const dz = to[2] - from.z;
|
||||
return Math.atan2(dx, dz);
|
||||
@@ -92,6 +95,12 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
const { actions } = useAnimations(animations, model);
|
||||
|
||||
// ─── playAnim ─────────────────────────────────────────────────────────────
|
||||
// NOTE: actions is intentionally in the dep array so this callback is
|
||||
// recreated when drei's internal state populates the actions map.
|
||||
// External THREE.AnimationAction lifecycle methods (fadeOut/fadeIn/play +
|
||||
// setLoop/clampWhenFinished mutations) are intentional side effects on
|
||||
// drei-managed objects.
|
||||
/* eslint-disable react-hooks/immutability */
|
||||
const playAnim = useCallback(
|
||||
(name: NPCAnimation, fade = ANIM_FADE): void => {
|
||||
if (currentAnimRef.current === name) return;
|
||||
@@ -110,6 +119,7 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
/* eslint-enable react-hooks/immutability */
|
||||
|
||||
// ─── Async audio after pylon is raised ────────────────────────────────────
|
||||
const playPostRaiseAudioAndAdvance = useCallback(async () => {
|
||||
@@ -131,6 +141,9 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
}, [setMissionStep]);
|
||||
|
||||
// ─── Step-driven animation ────────────────────────────────────────────────
|
||||
// Fires when step changes OR when playAnim changes (i.e. when actions load).
|
||||
// playAnim mutates drei-managed AnimationAction internals (intentional).
|
||||
/* eslint-disable react-hooks/immutability */
|
||||
useEffect(() => {
|
||||
currentAnimRef.current = null;
|
||||
if (step === "arrived") {
|
||||
@@ -196,7 +209,10 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
currentPosRef.current.lerp(_target, t);
|
||||
} else if (!isStraightening && currentAnimRef.current === "walk") {
|
||||
playAnim("idle");
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION);
|
||||
savedRotationYRef.current = faceToward(
|
||||
currentPosRef.current,
|
||||
PYLON_WORLD_POSITION,
|
||||
);
|
||||
}
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else if (step === "inspected" || step === "done") {
|
||||
@@ -208,8 +224,15 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// ── Rotation ──────────────────────────────────────────────────────────
|
||||
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") {
|
||||
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT);
|
||||
if (
|
||||
step === "npc-return" &&
|
||||
!isCompleted &&
|
||||
currentAnimRef.current === "walk"
|
||||
) {
|
||||
const walkRotY = faceToward(
|
||||
currentPosRef.current,
|
||||
PYLON_FARMER_NPC_WALK_LOOK_AT,
|
||||
);
|
||||
group.rotation.set(0, walkRotY, 0);
|
||||
} else {
|
||||
group.rotation.set(0, savedRotationYRef.current, 0);
|
||||
@@ -217,6 +240,7 @@ function PylonFarmerNPCContent(): React.JSX.Element {
|
||||
|
||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
||||
});
|
||||
/* eslint-enable react-hooks/immutability */
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||
|
||||
@@ -32,7 +32,9 @@ export function PylonLightingEffect(): null {
|
||||
const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor));
|
||||
|
||||
// Target colours — updated reactively when isActive changes
|
||||
const targetAmbientRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.ambientColor));
|
||||
const targetAmbientRef = useRef(
|
||||
new THREE.Color(LIGHTING_DEFAULTS.ambientColor),
|
||||
);
|
||||
const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor));
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
|
||||
const BUBBLE_RADIUS_METERS = 10;
|
||||
const BUBBLE_GROW_DURATION_SECONDS = 2.5;
|
||||
const BUBBLE_SHRINK_DURATION_SECONDS = 1.2;
|
||||
const BUBBLE_COLOR = "#060814";
|
||||
const BUBBLE_OPACITY = 0.92;
|
||||
const BUBBLE_SHELL_RADIUS = 1; // sphere geometry baked at radius=1, scale = radius
|
||||
|
||||
/**
|
||||
* Dark sphere shroud rendered around the active repair model when the
|
||||
* focus state is active. Grows from 0 -> BUBBLE_RADIUS_METERS using a
|
||||
* GSAP `expo.out` ease so the player visually transitions from the open
|
||||
* map to an isolated repair "cocoon". Reverses on focus end.
|
||||
*
|
||||
* The sphere uses BackSide rendering so the player remains inside the
|
||||
* shroud when they stand near the repair model. A subtle decor pass
|
||||
* (grid floor + soft directional light + light fog) is rendered as a
|
||||
* sibling group so it appears once the bubble has expanded.
|
||||
*/
|
||||
export function RepairFocusBubble(): React.JSX.Element | null {
|
||||
const active = useRepairFocusStore((state) => state.active);
|
||||
const center = useRepairFocusStore((state) => state.center);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const decorRef = useRef<THREE.Group>(null);
|
||||
const scaleRef = useRef({ value: 0.0001 });
|
||||
const decorOpacityRef = useRef({ value: 0 });
|
||||
|
||||
const sphereGeometry = useMemo(
|
||||
() => new THREE.SphereGeometry(BUBBLE_SHELL_RADIUS, 48, 32),
|
||||
[],
|
||||
);
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: BUBBLE_COLOR,
|
||||
side: THREE.BackSide,
|
||||
transparent: true,
|
||||
opacity: BUBBLE_OPACITY,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
sphereGeometry.dispose();
|
||||
sphereMaterial.dispose();
|
||||
};
|
||||
}, [sphereGeometry, sphereMaterial]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetScale = active ? BUBBLE_RADIUS_METERS : 0.0001;
|
||||
const targetDecor = active ? 1 : 0;
|
||||
const duration = active
|
||||
? BUBBLE_GROW_DURATION_SECONDS
|
||||
: BUBBLE_SHRINK_DURATION_SECONDS;
|
||||
|
||||
const scaleTween = gsap.to(scaleRef.current, {
|
||||
value: targetScale,
|
||||
duration,
|
||||
ease: active ? "expo.out" : "expo.in",
|
||||
onUpdate: () => {
|
||||
const mesh = meshRef.current;
|
||||
if (mesh) mesh.scale.setScalar(scaleRef.current.value);
|
||||
},
|
||||
});
|
||||
|
||||
const decorTween = gsap.to(decorOpacityRef.current, {
|
||||
value: targetDecor,
|
||||
duration: duration * 0.8,
|
||||
delay: active ? duration * 0.4 : 0,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => {
|
||||
const decor = decorRef.current;
|
||||
if (!decor) return;
|
||||
decor.traverse((child) => {
|
||||
if (
|
||||
child instanceof THREE.Mesh &&
|
||||
child.material instanceof THREE.Material
|
||||
) {
|
||||
const material = child.material as THREE.Material & {
|
||||
opacity?: number;
|
||||
transparent?: boolean;
|
||||
};
|
||||
if (typeof material.opacity === "number") {
|
||||
material.opacity = decorOpacityRef.current.value;
|
||||
material.transparent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
scaleTween.kill();
|
||||
decorTween.kill();
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
// Render even when inactive so the shrink tween can play out; visibility
|
||||
// is implicit via near-zero scale.
|
||||
return (
|
||||
<group ref={groupRef} position={center}>
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
geometry={sphereGeometry}
|
||||
material={sphereMaterial}
|
||||
renderOrder={-1}
|
||||
frustumCulled={false}
|
||||
/>
|
||||
<group ref={decorRef}>
|
||||
{/* Subtle grid floor visible only inside the bubble */}
|
||||
<gridHelper
|
||||
args={[BUBBLE_RADIUS_METERS * 1.6, 24, "#1f2937", "#111827"]}
|
||||
position={[0, -0.5, 0]}
|
||||
/>
|
||||
{/* Soft directional light for the repair model */}
|
||||
<directionalLight
|
||||
position={[2, 4, 3]}
|
||||
intensity={0.6}
|
||||
color="#cbd5f5"
|
||||
/>
|
||||
<ambientLight intensity={0.25} color="#1e293b" />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
RepairScannedBrokenPart,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
@@ -72,8 +73,20 @@ export function RepairGame({
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
// For the ebike mission, use the bike's live parked world position once
|
||||
// the repair flow leaves the waiting/locked phase so the repair happens
|
||||
// wherever the player parked the bike, not at the static zone anchor.
|
||||
// window.ebikeParkedPosition is set by Ebike when the player drops the
|
||||
// bike and stays stable through the rest of the repair flow.
|
||||
const livePosition = useMemo<Vector3Tuple>(() => {
|
||||
if (mission !== "ebike" || mainState !== mission) return position;
|
||||
if (step === "locked" || step === "waiting") return position;
|
||||
const parked = window.ebikeParkedPosition;
|
||||
if (!parked) return position;
|
||||
return [parked[0], parked[1], parked[2]];
|
||||
}, [mainState, mission, position, step]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const snappedPosition = useTerrainSnappedPosition(livePosition);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
|
||||
|
||||
@@ -98,6 +111,25 @@ export function RepairGame({
|
||||
};
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
// Drive the global focus bubble: active during the immersive repair
|
||||
// phases so the world dims/hides outside the dark sphere shroud.
|
||||
const focusCenterX = snappedPosition[0];
|
||||
const focusCenterY = snappedPosition[1];
|
||||
const focusCenterZ = snappedPosition[2];
|
||||
useEffect(() => {
|
||||
const inFocusPhase =
|
||||
mainState === mission && shouldFocusBubbleBeActive(step);
|
||||
if (inFocusPhase) {
|
||||
useRepairFocusStore
|
||||
.getState()
|
||||
.setFocus(true, [focusCenterX, focusCenterY, focusCenterZ]);
|
||||
return () => {
|
||||
useRepairFocusStore.getState().setFocus(false);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
@@ -131,6 +163,7 @@ export function RepairGame({
|
||||
{step === "fragmented" ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
rotation={config.modelRotation ?? [0, 0, 0]}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
/>
|
||||
@@ -148,6 +181,7 @@ export function RepairGame({
|
||||
<>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
rotation={config.modelRotation ?? [0, 0, 0]}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
hideNodeNames={brokenNodeNames}
|
||||
@@ -200,6 +234,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function shouldFocusBubbleBeActive(step: MissionStep): boolean {
|
||||
return (
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling"
|
||||
);
|
||||
}
|
||||
|
||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
|
||||
@@ -5,7 +5,6 @@ import { HandTrackingFallback } from "@/components/ui/HandTrackingFallback";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||
|
||||
@@ -14,7 +13,6 @@ export function GameUI(): React.JSX.Element {
|
||||
<>
|
||||
<DebugOverlayLayout />
|
||||
<Crosshair />
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<HandTrackingFallback />
|
||||
|
||||
@@ -9,10 +9,14 @@ export function InteractPrompt(): React.JSX.Element | null {
|
||||
if (cameraMode !== "player") return null;
|
||||
if (!focused || holding || focused.kind !== "trigger") return null;
|
||||
|
||||
const label = focused.label?.trim() ?? "";
|
||||
|
||||
return (
|
||||
<div className="interact-prompt" aria-live="polite">
|
||||
<kbd className="interact-prompt__key">{INTERACT_KEY.toUpperCase()}</kbd>
|
||||
<span className="interact-prompt__label">{focused.label}</span>
|
||||
{label.length > 0 ? (
|
||||
<span className="interact-prompt__label">{label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
|
||||
export function RepairMovementLockIndicator(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
|
||||
if (cameraMode !== "player") return null;
|
||||
if (!movementLocked) return null;
|
||||
|
||||
return (
|
||||
<div className="repair-movement-lock-indicator" aria-live="polite">
|
||||
<span
|
||||
className="repair-movement-lock-indicator__dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Déplacement verrouillé pendant la réparation</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
MAIN_GAME_STATES,
|
||||
} from "@/data/game/gameStateConfig";
|
||||
import {
|
||||
getMissionStepsFor,
|
||||
isMissionStep,
|
||||
MISSION_STEPS,
|
||||
} from "@/data/gameplay/repairMissionState";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MainGameState } from "@/types/game";
|
||||
@@ -53,7 +53,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
? GAME_STEPS
|
||||
: mainState === "outro"
|
||||
? ["waiting", "started"]
|
||||
: MISSION_STEPS;
|
||||
: mainState === "ebike" || mainState === "pylon" || mainState === "farm"
|
||||
? getMissionStepsFor(mainState)
|
||||
: [];
|
||||
|
||||
function setSubState(nextSubState: string): void {
|
||||
if (mainState === "intro") {
|
||||
|
||||
@@ -6,11 +6,7 @@ export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
|
||||
|
||||
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
||||
-16.13,
|
||||
3.2,
|
||||
52.46
|
||||
];
|
||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [-16.13, 3.2, 52.46];
|
||||
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
|
||||
@@ -24,6 +24,20 @@ export const MISSION_STEPS = [
|
||||
] as const satisfies readonly MissionStep[];
|
||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
||||
|
||||
const PYLON_ONLY_MISSION_STEPS = new Set<MissionStep>([
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"narrator-outro",
|
||||
]);
|
||||
|
||||
export function getMissionStepsFor(
|
||||
mission: RepairMissionId,
|
||||
): readonly MissionStep[] {
|
||||
if (mission === "pylon") return MISSION_STEPS;
|
||||
return MISSION_STEPS.filter((step) => !PYLON_ONLY_MISSION_STEPS.has(step));
|
||||
}
|
||||
|
||||
export function isRepairMissionId(value: string): value is RepairMissionId {
|
||||
return REPAIR_MISSION_ID_VALUES.has(value);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import {
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
EBIKE_WORLD_SCALE,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
|
||||
const REPAIR_INTERACT_UI_PATH = "/assets/world/UI/interagir.webm";
|
||||
const REPAIR_BROKEN_UI_PATH = "/assets/world/UI/cassé.webm";
|
||||
@@ -20,7 +24,8 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.3,
|
||||
modelScale: EBIKE_WORLD_SCALE,
|
||||
modelRotation: [0, EBIKE_WORLD_ROTATION_Y, 0],
|
||||
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
|
||||
@@ -4,11 +4,7 @@ import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
// Zones qui active la coupure de courant
|
||||
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
||||
id: "pylon-approach",
|
||||
position: [
|
||||
5,
|
||||
4,
|
||||
-21.5
|
||||
],
|
||||
position: [5, 4, -21.5],
|
||||
radius: 10,
|
||||
height: 18,
|
||||
oneShot: true,
|
||||
|
||||
@@ -30,8 +30,8 @@ export const CHARACTER_CONFIGS = {
|
||||
position: [-40.5, 0, 45.5],
|
||||
rotation: [0, -0.35, 0],
|
||||
scale: [1.55, 1.55, 1.55],
|
||||
animations: ["Dance"],
|
||||
defaultAnimation: "Dance",
|
||||
animations: ["idle", "walk"],
|
||||
defaultAnimation: "idle",
|
||||
},
|
||||
gerant: {
|
||||
id: "gerant",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
|
||||
export function useRepairMovementLocked(): boolean {
|
||||
return useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "ebike":
|
||||
return isRepairMovementLocked(state.ebike.currentStep);
|
||||
case "pylon":
|
||||
return isRepairMovementLocked(state.pylon.currentStep);
|
||||
case "farm":
|
||||
return isRepairMovementLocked(state.farm.currentStep);
|
||||
case "intro":
|
||||
case "outro":
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isRepairMovementLocked(step: MissionStep): boolean {
|
||||
return (
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling" ||
|
||||
step === "done"
|
||||
);
|
||||
}
|
||||
+26
-13
@@ -809,35 +809,48 @@ canvas {
|
||||
|
||||
.interact-prompt {
|
||||
position: fixed;
|
||||
bottom: 30%;
|
||||
bottom: 12%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.interact-prompt__key {
|
||||
.interact-prompt__key,
|
||||
.interact-prompt__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
height: 36px;
|
||||
background: rgba(10, 12, 20, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
font-family: "Inter", sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.interact-prompt__key {
|
||||
width: 36px;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
letter-spacing: 0;
|
||||
/* 3D keyboard key effect: top highlight, bottom inner darkening,
|
||||
and a thin bottom drop so the key reads as physically pressed-up. */
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25),
|
||||
inset 0 -3px 0 rgba(0, 0, 0, 0.45),
|
||||
0 2px 0 rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.interact-prompt__label {
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.03em;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.repair-movement-lock-indicator {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from "zustand";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
/**
|
||||
* Tracks whether a repair mini-game is currently in its "focused" phase
|
||||
* (fragmented / scanning / repairing / reassembling). When active, a dark
|
||||
* sphere expands around the repair model to visually isolate the player
|
||||
* from the rest of the map. The store also exposes the world-space center
|
||||
* of the bubble so map content can dim/hide content outside it if needed.
|
||||
*/
|
||||
interface RepairFocusStore {
|
||||
active: boolean;
|
||||
center: Vector3Tuple;
|
||||
setFocus: (active: boolean, center?: Vector3Tuple) => void;
|
||||
}
|
||||
|
||||
export const useRepairFocusStore = create<RepairFocusStore>((set) => ({
|
||||
active: false,
|
||||
center: [0, 0, 0],
|
||||
setFocus: (active, center) =>
|
||||
set((state) => ({
|
||||
active,
|
||||
center: center ?? state.center,
|
||||
})),
|
||||
}));
|
||||
@@ -64,6 +64,13 @@ export interface RepairMissionConfig {
|
||||
description: string;
|
||||
modelPath: string;
|
||||
modelScale?: ModelTransformProps["scale"];
|
||||
/**
|
||||
* World-space rotation applied to the model when mounted by RepairGame
|
||||
* (fragmented + repairing steps). Should match the rotation used by the
|
||||
* source object in the world (e.g. parked Ebike) so the fragmented model
|
||||
* lines up visually with the inspection model.
|
||||
*/
|
||||
modelRotation?: Vector3Tuple;
|
||||
stageUiPath: string;
|
||||
interactUiPath: string;
|
||||
brokenUiPath: string;
|
||||
|
||||
@@ -85,7 +85,7 @@ export class Debug {
|
||||
fogEnabled: boolean;
|
||||
handTrackingSource: HandTrackingSource;
|
||||
showDebugOverlay: boolean;
|
||||
showHandTrackingSvg: boolean;
|
||||
showHandTrackingModel: boolean;
|
||||
showInteractionSpheres: boolean;
|
||||
showPerf: boolean;
|
||||
sceneMode: SceneMode;
|
||||
@@ -108,7 +108,7 @@ export class Debug {
|
||||
fogEnabled: FOG_CONFIG.enabled,
|
||||
handTrackingSource: storedControls.handTrackingSource ?? "browser",
|
||||
showDebugOverlay: true,
|
||||
showHandTrackingSvg: false,
|
||||
showHandTrackingModel: false,
|
||||
showInteractionSpheres: false,
|
||||
showPerf: true,
|
||||
sceneMode: storedControls.sceneMode ?? "game",
|
||||
@@ -156,10 +156,10 @@ export class Debug {
|
||||
const handTrackingFolder = this.createFolder("Hand Tracking");
|
||||
|
||||
handTrackingFolder
|
||||
?.add(this.controls, "showHandTrackingSvg")
|
||||
.name("Show SVG")
|
||||
?.add(this.controls, "showHandTrackingModel")
|
||||
.name("Show Model")
|
||||
.onChange((value: boolean) => {
|
||||
this.controls.showHandTrackingSvg = value;
|
||||
this.controls.showHandTrackingModel = value;
|
||||
this.emit();
|
||||
});
|
||||
|
||||
@@ -281,12 +281,12 @@ export class Debug {
|
||||
return this.controls.showInteractionSpheres;
|
||||
}
|
||||
|
||||
getShowHandTrackingSvg(): boolean {
|
||||
return this.controls.showHandTrackingSvg;
|
||||
getShowHandTrackingModel(): boolean {
|
||||
return this.controls.showHandTrackingModel;
|
||||
}
|
||||
|
||||
setShowHandTrackingSvg(value: boolean): void {
|
||||
this.controls.showHandTrackingSvg = value;
|
||||
setShowHandTrackingModel(value: boolean): void {
|
||||
this.controls.showHandTrackingModel = value;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,23 @@ export class ExplodedModel {
|
||||
}
|
||||
|
||||
private createParts(model: THREE.Object3D): ExplodedPart[] {
|
||||
const root =
|
||||
model.children.length === 1 && model.children[0]
|
||||
? model.children[0]
|
||||
: model;
|
||||
const directChildren = root.children.filter((child) => hasMesh(child));
|
||||
// Drill down through single-mesh-bearing branches until we find a node
|
||||
// with multiple mesh-bearing children (the natural "explosion group" the
|
||||
// modeler authored). Falls back to flat mesh list only if no such group
|
||||
// exists. This avoids exploding leaves in local space when wrapper nodes
|
||||
// (e.g. "Empty" + "Moto" > "Eclatement") sit above the actual group.
|
||||
let current = model;
|
||||
while (true) {
|
||||
const meshChildren = current.children.filter((child) => hasMesh(child));
|
||||
if (meshChildren.length === 1 && meshChildren[0]) {
|
||||
current = meshChildren[0];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const directChildren = current.children.filter((child) => hasMesh(child));
|
||||
const sourceObjects =
|
||||
directChildren.length > 1 ? directChildren : getMeshes(root);
|
||||
directChildren.length > 1 ? directChildren : getMeshes(current);
|
||||
|
||||
if (sourceObjects.length === 0) return [];
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||
import { FogSystem } from "@/world/fog/FogSystem";
|
||||
@@ -24,6 +25,9 @@ export function Environment(): React.JSX.Element {
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
const showSky = isMapModelVisible("sky", { groups, models });
|
||||
// Hide vegetation while the repair focus bubble is active so the cocoon
|
||||
// shroud is not pierced by tall trees / bushes around the repair model.
|
||||
const repairFocusActive = useRepairFocusStore((state) => state.active);
|
||||
|
||||
if (sceneMode === "physics") {
|
||||
return (
|
||||
@@ -52,7 +56,7 @@ export function Environment(): React.JSX.Element {
|
||||
<WaterSystem />
|
||||
<CloudSystem />
|
||||
<GrassSystem />
|
||||
<VegetationSystem />
|
||||
{repairFocusActive ? null : <VegetationSystem />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,7 +250,10 @@ export function animateCameraTransformTransition(
|
||||
targetRotation: Vector3Tuple,
|
||||
duration: number = 1,
|
||||
onComplete?: () => void,
|
||||
options: { lockInput?: boolean } = {},
|
||||
): void {
|
||||
const { lockInput = true } = options;
|
||||
|
||||
if (!globalCamera) {
|
||||
logger.warn("GameCinematics", "Camera not found for transition");
|
||||
onComplete?.();
|
||||
@@ -260,7 +263,9 @@ export function animateCameraTransformTransition(
|
||||
const camera = globalCamera;
|
||||
|
||||
cameraTransitionTimeline?.kill();
|
||||
useGameStore.getState().setCinematicPlaying(true);
|
||||
if (lockInput) {
|
||||
useGameStore.getState().setCinematicPlaying(true);
|
||||
}
|
||||
|
||||
// Convert target rotation in degrees to quaternion
|
||||
const targetEuler = new THREE.Euler(
|
||||
@@ -282,7 +287,9 @@ export function animateCameraTransformTransition(
|
||||
},
|
||||
onComplete: () => {
|
||||
cameraTransitionTimeline = null;
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
if (lockInput) {
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
}
|
||||
onComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { FarmNarrativeFlow } from "@/components/gameplay/farm/FarmNarrativeFlow";
|
||||
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
OUTRO_STAGE_ANCHOR,
|
||||
} from "@/data/gameplay/gameStageAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import {
|
||||
isFarmNarrativeStep,
|
||||
@@ -25,13 +27,7 @@ import {
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
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}`;
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
|
||||
interface StageAnchorProps {
|
||||
color: string;
|
||||
@@ -96,6 +92,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
||||
const repairFocusActive = useRepairFocusStore((state) => state.active);
|
||||
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
|
||||
@@ -110,7 +107,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<PylonLightingEffect />
|
||||
<PylonDownedPylon />
|
||||
{isDebugEnabled() ? (
|
||||
{isDebugEnabled() && !repairFocusActive ? (
|
||||
<>
|
||||
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
|
||||
@@ -131,6 +128,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
<RepairMissionTrigger key={config.mission} config={config} />
|
||||
))}
|
||||
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
|
||||
<RepairFocusBubble />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-3
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/data/player/playerConfig";
|
||||
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||
@@ -32,7 +33,6 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
|
||||
import { Player } from "@/world/player/Player";
|
||||
import { TestMap } from "@/world/debug/TestMap";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
||||
|
||||
interface WorldProps {
|
||||
@@ -41,7 +41,7 @@ interface WorldProps {
|
||||
|
||||
function hasTrackedHand(
|
||||
hands: HandTrackingHand[],
|
||||
handedness: HandTrackingGloveHandedness,
|
||||
handedness: "left" | "right",
|
||||
): boolean {
|
||||
return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
|
||||
}
|
||||
@@ -60,6 +60,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
(state) => state.showPlayerModel,
|
||||
);
|
||||
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const showHandTrackingModel = useDebugStore((debug) =>
|
||||
debug.getShowHandTrackingModel(),
|
||||
);
|
||||
const { hands, status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
@@ -74,7 +77,10 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||
const showHandTrackingGloves =
|
||||
status === "connected" && usageStatus !== "inactive" && hands.length > 0;
|
||||
showHandTrackingModel &&
|
||||
status === "connected" &&
|
||||
usageStatus !== "inactive" &&
|
||||
hands.length > 0;
|
||||
const showLeftHandTrackingGlove =
|
||||
showHandTrackingGloves && hasTrackedHand(hands, "left");
|
||||
const showRightHandTrackingGlove =
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Component, useRef, useState, useEffect } from "react";
|
||||
import * as THREE from "three";
|
||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||
import { Line } from "@react-three/drei";
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { RepairFocusBubble } from "@/components/three/gameplay/RepairFocusBubble";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
@@ -239,11 +241,16 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||
<group position={zone.position}>
|
||||
<RepairPlaygroundZoneMarker color={zone.color} />
|
||||
</group>
|
||||
{zone.mission === "ebike" ? (
|
||||
<Ebike position={zone.position} snapToTerrain={false} />
|
||||
) : null}
|
||||
<RepairGame mission={zone.mission} position={zone.position} />
|
||||
</group>
|
||||
))}
|
||||
</Physics>
|
||||
|
||||
<RepairFocusBubble />
|
||||
|
||||
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
|
||||
<group
|
||||
position={TEST_SCENE_GPS_PREVIEW_POSITION}
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
PLAYER_MAX_DELTA,
|
||||
PLAYER_XZ_DAMPING_FACTOR,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
@@ -154,9 +153,7 @@ export function PlayerController({
|
||||
}: PlayerControllerProps): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const sceneMode = useSceneMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const movementLockedRef = useRef(movementLocked);
|
||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||
const velocity = useRef(new THREE.Vector3());
|
||||
const fallDuration = useRef(0);
|
||||
@@ -249,17 +246,6 @@ export function PlayerController({
|
||||
initializedRef.current = true;
|
||||
}, [camera, initialLookAt, spawnPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
movementLockedRef.current = movementLocked;
|
||||
|
||||
if (!movementLocked) return;
|
||||
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
wantsJump.current = false;
|
||||
velocity.current.setX(0);
|
||||
velocity.current.setZ(0);
|
||||
}, [movementLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
@@ -267,20 +253,11 @@ export function PlayerController({
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, true)) {
|
||||
if (movementLockedRef.current) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === JUMP_KEY) {
|
||||
if (movementLockedRef.current) {
|
||||
wantsJump.current = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
wantsJump.current = true;
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -386,7 +363,7 @@ export function PlayerController({
|
||||
}
|
||||
|
||||
_wishDir.set(0, 0, 0);
|
||||
if (!movementLocked && !isEbikeBreakdown) {
|
||||
if (!isEbikeBreakdown) {
|
||||
if (keys.current.forward) _wishDir.add(_forward);
|
||||
if (keys.current.backward) _wishDir.sub(_forward);
|
||||
if (!isEbikeMounted) {
|
||||
|
||||
Reference in New Issue
Block a user