Compare commits
13 Commits
feat/intro
...
4faa226326
| Author | SHA1 | Date | |
|---|---|---|---|
| 4faa226326 | |||
| dd66966507 | |||
| 5893afe42a | |||
| 1ead7ab3a7 | |||
| 047c58678b | |||
| ed9051b0dc | |||
| 08be6bee48 | |||
| ce0eb90321 | |||
| 96d7ec7fc0 | |||
| 9ab4b4a002 | |||
| d13dd0fda0 | |||
| fbedb90bca | |||
| cff7744ad9 |
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||||
|
|
||||||
|
export interface CameraTransform {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
|
position: [-3.5, 6, 0],
|
||||||
|
rotation: [-10, -90, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
|
position: [0, 1.5, -3],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EbikeProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||||
|
scope: "Ebike",
|
||||||
|
position: position,
|
||||||
|
});
|
||||||
|
const model = useClonedObject(scene);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
const restingPosition = useRef<Vector3Tuple>([
|
||||||
|
position[0],
|
||||||
|
position[1] - PLAYER_EYE_HEIGHT,
|
||||||
|
position[2],
|
||||||
|
]);
|
||||||
|
const restingRotation = useRef<number>(0);
|
||||||
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (model) {
|
||||||
|
const fork = model.getObjectByName("fourche");
|
||||||
|
if (fork) {
|
||||||
|
forkRef.current = fork;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).ebikeVisualGroup = groupRef;
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
return () => {
|
||||||
|
(window as any).ebikeVisualGroup = null;
|
||||||
|
(window as any).ebikeParkedPosition = null;
|
||||||
|
(window as any).ebikeParkedRotation = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
restingPosition.current = [
|
||||||
|
groupRef.current.position.x,
|
||||||
|
groupRef.current.position.y,
|
||||||
|
groupRef.current.position.z,
|
||||||
|
];
|
||||||
|
restingRotation.current = groupRef.current.rotation.y;
|
||||||
|
|
||||||
|
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||||
|
const steerFactor = (window as any).ebikeSteerFactor || 0;
|
||||||
|
if (forkRef.current) {
|
||||||
|
// 15 degrees is 0.26 radians
|
||||||
|
const targetForkRotation = steerFactor * 0.26;
|
||||||
|
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
forkRef.current.rotation.z,
|
||||||
|
targetForkRotation,
|
||||||
|
12 * delta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groupRef.current.position.set(...restingPosition.current);
|
||||||
|
groupRef.current.rotation.set(0, restingRotation.current, 0);
|
||||||
|
|
||||||
|
// Reset fork rotation when parked
|
||||||
|
if (forkRef.current) {
|
||||||
|
forkRef.current.rotation.z = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
const dropPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleInteract = (): void => {
|
||||||
|
if (movementMode === "walk") {
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), restingRotation.current);
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + cameraOffset.x,
|
||||||
|
restingPosition.current[1] + cameraOffset.y,
|
||||||
|
restingPosition.current[2] + cameraOffset.z,
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[1] + THREE.MathUtils.radToDeg(restingRotation.current),
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const currentPos = new THREE.Vector3();
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.getWorldPosition(currentPos);
|
||||||
|
} else {
|
||||||
|
currentPos.set(...position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
|
||||||
|
const currentEuler = new THREE.Euler().setFromQuaternion(camera.quaternion, "YXZ");
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.x),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.y),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteractRef = useRef(handleInteract);
|
||||||
|
handleInteractRef.current = handleInteract;
|
||||||
|
|
||||||
|
const debugRef = useRef({ showCameraPoints: true });
|
||||||
|
const debugActions = useRef({
|
||||||
|
toggleRide: () => {
|
||||||
|
handleInteractRef.current();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useDebugFolder("Ebike", (folder) => {
|
||||||
|
folder
|
||||||
|
.add(debugRef.current, "showCameraPoints")
|
||||||
|
.name("Show Camera Points")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
debugRef.current.showCameraPoints = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(debugActions.current, "toggleRide")
|
||||||
|
.name("Monter / Descendre");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<group ref={groupRef} position={position}>
|
||||||
|
<primitive object={model} />
|
||||||
|
<InteractableObject
|
||||||
|
kind="trigger"
|
||||||
|
label={
|
||||||
|
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
radius={15}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[10, 13, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
</group>
|
||||||
|
{debugRef.current.showCameraPoints && (
|
||||||
|
<>
|
||||||
|
<mesh position={camPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="yellow"
|
||||||
|
emissive="yellow"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={dropPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="cyan"
|
||||||
|
emissive="cyan"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 = 11;
|
export const PLAYER_WALK_SPEED = 11;
|
||||||
|
export const PLAYER_EBIKE_SPEED = 25;
|
||||||
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;
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import {
|
|||||||
type MissionStep,
|
type MissionStep,
|
||||||
type RepairMissionId,
|
type RepairMissionId,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import {
|
||||||
|
PLAYER_WALK_SPEED,
|
||||||
|
PLAYER_EBIKE_SPEED,
|
||||||
|
} from "@/data/player/playerConfig";
|
||||||
|
|
||||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||||
|
export type PlayerMovementMode = "walk" | "ebike";
|
||||||
export type { MissionStep, RepairMissionId };
|
export type { MissionStep, RepairMissionId };
|
||||||
|
|
||||||
interface IntroState {
|
interface IntroState {
|
||||||
@@ -30,10 +35,16 @@ interface MissionFlowState {
|
|||||||
playerName: string;
|
playerName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerState {
|
||||||
|
movementMode: PlayerMovementMode;
|
||||||
|
currentSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mainState: MainGameState;
|
mainState: MainGameState;
|
||||||
isCinematicPlaying: boolean;
|
isCinematicPlaying: boolean;
|
||||||
missionFlow: MissionFlowState;
|
missionFlow: MissionFlowState;
|
||||||
|
player: PlayerState;
|
||||||
intro: IntroState;
|
intro: IntroState;
|
||||||
bike: MissionState & {
|
bike: MissionState & {
|
||||||
isRepaired: boolean;
|
isRepaired: boolean;
|
||||||
@@ -56,6 +67,7 @@ interface GameActions {
|
|||||||
hideDialog: () => void;
|
hideDialog: () => void;
|
||||||
setActivityCity: (activityCity: boolean) => void;
|
setActivityCity: (activityCity: boolean) => void;
|
||||||
setCanMove: (canMove: boolean) => void;
|
setCanMove: (canMove: boolean) => void;
|
||||||
|
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
|
||||||
setIntroStep: (step: GameStep) => void;
|
setIntroStep: (step: GameStep) => void;
|
||||||
setIntroState: (intro: Partial<IntroState>) => void;
|
setIntroState: (intro: Partial<IntroState>) => void;
|
||||||
setPlayerName: (playerName: string) => void;
|
setPlayerName: (playerName: string) => void;
|
||||||
@@ -209,6 +221,10 @@ function createInitialGameState(): GameState {
|
|||||||
dialogMessage: null,
|
dialogMessage: null,
|
||||||
playerName: "",
|
playerName: "",
|
||||||
},
|
},
|
||||||
|
player: {
|
||||||
|
movementMode: "walk",
|
||||||
|
currentSpeed: PLAYER_WALK_SPEED,
|
||||||
|
},
|
||||||
intro: {
|
intro: {
|
||||||
currentStep: "intro",
|
currentStep: "intro",
|
||||||
dialogueAudio: null,
|
dialogueAudio: null,
|
||||||
@@ -249,6 +265,14 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, activityCity },
|
missionFlow: { ...state.missionFlow, activityCity },
|
||||||
})),
|
})),
|
||||||
|
setPlayerMovementMode: (mode) =>
|
||||||
|
set((state) => ({
|
||||||
|
player: {
|
||||||
|
...state.player,
|
||||||
|
movementMode: mode,
|
||||||
|
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
|
||||||
|
},
|
||||||
|
})),
|
||||||
setCanMove: (canMove) =>
|
setCanMove: (canMove) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, canMove },
|
missionFlow: { ...state.missionFlow, canMove },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CinematicManifest,
|
CinematicManifest,
|
||||||
} from "@/types/cinematics/cinematics";
|
} from "@/types/cinematics/cinematics";
|
||||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
@@ -16,6 +17,11 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
|||||||
|
|
||||||
export function GameCinematics(): null {
|
export function GameCinematics(): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
const [dialogueManifest, setDialogueManifest] =
|
const [dialogueManifest, setDialogueManifest] =
|
||||||
useState<DialogueManifest | null>(null);
|
useState<DialogueManifest | null>(null);
|
||||||
@@ -171,3 +177,118 @@ function playCinematic(
|
|||||||
|
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cameraTransitionTimeline: gsap.core.Timeline | null = null;
|
||||||
|
let globalCamera: THREE.Camera | null = null;
|
||||||
|
|
||||||
|
export function setGlobalCamera(camera: THREE.Camera | null): void {
|
||||||
|
globalCamera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetLookAt: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...targetLookAt);
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: targetLookAt[0],
|
||||||
|
y: targetLookAt[1],
|
||||||
|
z: targetLookAt[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransformTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetRotation: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
// Convert target rotation in degrees to quaternion
|
||||||
|
const targetEuler = new THREE.Euler(
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[0]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[1]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[2]),
|
||||||
|
"YXZ"
|
||||||
|
);
|
||||||
|
const startQuaternion = camera.quaternion.clone();
|
||||||
|
const endQuaternion = new THREE.Quaternion().setFromEuler(targetEuler);
|
||||||
|
|
||||||
|
const transitionObj = { progress: 0 };
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => {
|
||||||
|
camera.quaternion.copy(startQuaternion).slerp(endQuaternion, transitionObj.progress);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
transitionObj,
|
||||||
|
{
|
||||||
|
progress: 1,
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
|
import { Ebike } from "@/components/ebike/Ebike";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -56,6 +57,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
{mainState === "intro" ? (
|
{mainState === "intro" ? (
|
||||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
||||||
) : null}
|
) : null}
|
||||||
|
<Ebike position={[0, 10, 0]} />
|
||||||
{GAME_REPAIR_ZONES.map((zone) => (
|
{GAME_REPAIR_ZONES.map((zone) => (
|
||||||
<RepairGame
|
<RepairGame
|
||||||
key={zone.mission}
|
key={zone.mission}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
import { PointerLockControls } from "@react-three/drei";
|
import { PointerLockControls } from "@react-three/drei";
|
||||||
|
import { setGlobalCamera } from "@/world/GameCinematics";
|
||||||
|
|
||||||
export function PlayerCamera(): React.JSX.Element {
|
export function PlayerCamera(): React.JSX.Element {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
return () => {
|
return () => {
|
||||||
|
setGlobalCamera(null);
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [camera]);
|
||||||
|
|
||||||
return <PointerLockControls />;
|
return <PointerLockControls />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
PLAYER_GRAVITY,
|
PLAYER_GRAVITY,
|
||||||
PLAYER_JUMP_SPEED,
|
PLAYER_JUMP_SPEED,
|
||||||
PLAYER_MAX_DELTA,
|
PLAYER_MAX_DELTA,
|
||||||
PLAYER_WALK_SPEED,
|
|
||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
@@ -28,6 +27,7 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
|||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike";
|
||||||
|
|
||||||
type Keys = {
|
type Keys = {
|
||||||
forward: boolean;
|
forward: boolean;
|
||||||
@@ -108,6 +108,73 @@ export function PlayerController({
|
|||||||
const wantsJump = useRef(false);
|
const wantsJump = useRef(false);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
||||||
|
const currentSpeed = useGameStore((state) => state.player.currentSpeed);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const movementModeRef = useRef(movementMode);
|
||||||
|
const prevMovementModeRef = useRef(movementMode);
|
||||||
|
const ebikeAngle = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movementModeRef.current = movementMode;
|
||||||
|
}, [movementMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
// Teleport player capsule to the bike's current parked position
|
||||||
|
const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [0, 8.2, 0];
|
||||||
|
const targetRot: number = (window as any).ebikeParkedRotation || 0;
|
||||||
|
|
||||||
|
const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
|
||||||
|
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
|
||||||
|
|
||||||
|
capsule.current.start.set(
|
||||||
|
targetPos[0],
|
||||||
|
bottomY,
|
||||||
|
targetPos[2],
|
||||||
|
);
|
||||||
|
capsule.current.end.set(
|
||||||
|
targetPos[0],
|
||||||
|
headY,
|
||||||
|
targetPos[2],
|
||||||
|
);
|
||||||
|
velocity.current.set(0, 0, 0);
|
||||||
|
onFloor.current = false;
|
||||||
|
wantsJump.current = false;
|
||||||
|
|
||||||
|
// Initialize ebikeAngle to the bike's actual parked orientation!
|
||||||
|
ebikeAngle.current = targetRot;
|
||||||
|
|
||||||
|
// Position the camera exactly at the EBIKE_CAMERA_TRANSFORM offset rotated by targetRot
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(_up, targetRot);
|
||||||
|
|
||||||
|
const camPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
camera.position.copy(camPos);
|
||||||
|
|
||||||
|
// Set the camera's exact rotation according to EBIKE_CAMERA_TRANSFORM.rotation + targetRot
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]);
|
||||||
|
const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + targetRot;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
} else if (movementMode === "walk" && prevMovementModeRef.current === "ebike") {
|
||||||
|
// Restore default walk FOV
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = 60;
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
// Dismount! Teleport player capsule 3 units to the right
|
||||||
|
const rightDir = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(_forward);
|
||||||
|
_forward.setY(0).normalize();
|
||||||
|
rightDir.crossVectors(_forward, _up).normalize();
|
||||||
|
|
||||||
|
const shift = rightDir.multiplyScalar(3);
|
||||||
|
capsule.current.translate(shift);
|
||||||
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
prevMovementModeRef.current = movementMode;
|
||||||
|
}, [movementMode, camera]);
|
||||||
|
|
||||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||||
|
|
||||||
@@ -220,6 +287,17 @@ export function PlayerController({
|
|||||||
|
|
||||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||||
|
|
||||||
|
// Rotate camera on Y-axis for ebike steering
|
||||||
|
if (movementModeRef.current === "ebike") {
|
||||||
|
const turnSpeed = 1.8; // radians per second
|
||||||
|
if (keys.current.left) {
|
||||||
|
ebikeAngle.current += turnSpeed * dt;
|
||||||
|
}
|
||||||
|
if (keys.current.right) {
|
||||||
|
ebikeAngle.current -= turnSpeed * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
camera.getWorldDirection(_forward);
|
camera.getWorldDirection(_forward);
|
||||||
_forward.setY(0);
|
_forward.setY(0);
|
||||||
if (_forward.lengthSq() > 0) {
|
if (_forward.lengthSq() > 0) {
|
||||||
@@ -231,14 +309,16 @@ export function PlayerController({
|
|||||||
if (!movementLocked) {
|
if (!movementLocked) {
|
||||||
if (keys.current.forward) _wishDir.add(_forward);
|
if (keys.current.forward) _wishDir.add(_forward);
|
||||||
if (keys.current.backward) _wishDir.sub(_forward);
|
if (keys.current.backward) _wishDir.sub(_forward);
|
||||||
|
if (movementModeRef.current !== "ebike") {
|
||||||
if (keys.current.left) _wishDir.sub(_right);
|
if (keys.current.left) _wishDir.sub(_right);
|
||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
const accel = onFloor.current
|
const accel = onFloor.current
|
||||||
? PLAYER_WALK_SPEED
|
? currentSpeed
|
||||||
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
|
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||||
velocity.current.x +=
|
velocity.current.x +=
|
||||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
velocity.current.z +=
|
velocity.current.z +=
|
||||||
@@ -282,7 +362,71 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (movementModeRef.current === "ebike") {
|
||||||
|
// Calculate dynamic steering factor
|
||||||
|
let targetSteer = 0;
|
||||||
|
if (keys.current.left) targetSteer = 1;
|
||||||
|
else if (keys.current.right) targetSteer = -1;
|
||||||
|
|
||||||
|
const currentSteer = (window as any).ebikeSteerFactor || 0;
|
||||||
|
const steerFactor = THREE.MathUtils.lerp(currentSteer, targetSteer, 8 * dt);
|
||||||
|
(window as any).ebikeSteerFactor = steerFactor;
|
||||||
|
|
||||||
|
// 1. Dynamic FOV stretch based on speed!
|
||||||
|
const speed = velocity.current.length();
|
||||||
|
const targetFov = 60 + Math.min(speed * 0.35, 9); // stretch FOV up to 9 degrees at high speed (halved by two)!
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = THREE.MathUtils.lerp(perspectiveCam.fov, targetFov, 6 * dt);
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
// 2. Camera lag & dynamic swing trailing
|
||||||
|
const cameraOffset = new THREE.Vector3(...EBIKE_CAMERA_TRANSFORM.position);
|
||||||
|
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
|
||||||
|
// Swing camera to optimize the view for both left and right turns:
|
||||||
|
// Since the camera is on the left (X = -3.5), it naturally trails beautifully in right turns,
|
||||||
|
// but cuts forward in left turns. We compensate by pushing the camera backward (+Z) during left turns!
|
||||||
|
const swingX = -Math.abs(steerFactor) * 1.5;
|
||||||
|
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
||||||
|
|
||||||
|
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||||
|
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
cameraOffset.add(cameraSwing);
|
||||||
|
|
||||||
|
const targetCamPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
|
||||||
|
// Smoothly lerp camera position to eliminate rigidity
|
||||||
|
camera.position.lerp(targetCamPos, 12 * dt);
|
||||||
|
|
||||||
|
// 3. Dynamic camera roll based on steering!
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[0]);
|
||||||
|
const yawRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) + ebikeAngle.current;
|
||||||
|
// COMMENTED OUT: Camera roll/tilt during turns (keeping it flat)
|
||||||
|
// const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]) - steerFactor * 0.08;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[2]);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
|
||||||
|
// 4. Synchronize visual e-bike position and apply leaning!
|
||||||
|
const ebikeVisual = (window as any).ebikeVisualGroup?.current;
|
||||||
|
if (ebikeVisual) {
|
||||||
|
ebikeVisual.position.set(
|
||||||
|
capsule.current.end.x,
|
||||||
|
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||||
|
capsule.current.end.z
|
||||||
|
);
|
||||||
|
// Lean (roll) the bike sideways in turns (up to 15 degrees)
|
||||||
|
const leanAngle = steerFactor * 0.26; // rotate in direction of turn!
|
||||||
|
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save player capsule end position and camera yaw globally so other components (like Ebike) can access it
|
||||||
|
(window as any).playerPos = [capsule.current.end.x, capsule.current.end.y, capsule.current.end.z];
|
||||||
|
(window as any).ebikeAngle = ebikeAngle.current;
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user