Feat/map-environment #6
@@ -13,6 +13,7 @@ import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGam
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionConfig,
|
||||
@@ -66,6 +67,7 @@ export function RepairGame({
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
@@ -105,7 +107,7 @@ export function RepairGame({
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
@@ -113,7 +115,7 @@ export function RepairGame({
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
worldPosition={snappedPosition}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -18,15 +18,15 @@ function toPascalCase(value: string): string {
|
||||
|
||||
export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||
const detail = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "intro":
|
||||
return state.intro.currentStep;
|
||||
case "bike":
|
||||
return state.bike.currentStep;
|
||||
case "ebike":
|
||||
return state.ebike.currentStep;
|
||||
case "pylone":
|
||||
return state.pylone.currentStep;
|
||||
case "ferme":
|
||||
@@ -37,7 +37,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
});
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
||||
const setEbikeState = useGameStore((state) => state.setEbikeState);
|
||||
const setPyloneState = useGameStore((state) => state.setPyloneState);
|
||||
const setFermeState = useGameStore((state) => state.setFermeState);
|
||||
const setOutroState = useGameStore((state) => state.setOutroState);
|
||||
@@ -67,8 +67,8 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
|
||||
if (!isMissionStep(nextSubState)) return;
|
||||
|
||||
if (mainState === "bike") {
|
||||
setBikeState({ currentStep: nextSubState });
|
||||
if (mainState === "ebike") {
|
||||
setEbikeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
function setDebugMainState(nextMainState: MainGameState): void {
|
||||
setMainState(nextMainState);
|
||||
|
||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||
setBikeState({ currentStep: "waiting" });
|
||||
if (nextMainState === "ebike" && ebikeStep === "locked") {
|
||||
setEbikeState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||
|
||||
export const TEST_SCENE_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
label: "Bike",
|
||||
mission: "ebike",
|
||||
label: "E-bike",
|
||||
color: "#38bdf8",
|
||||
position: [-12, 0, -12],
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export const TEST_SCENE_REPAIR_ZONES = [
|
||||
position: [12, 0, -12],
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
mission: "bike" | "pylone" | "ferme";
|
||||
mission: "ebike" | "pylone" | "ferme";
|
||||
label: string;
|
||||
color: string;
|
||||
position: Vector3Tuple;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export const BIKE_REPAIR_POSITION = [
|
||||
export const EBIKE_REPAIR_POSITION = [
|
||||
42.2399, 4.5484, 34.6468,
|
||||
] as const satisfies Vector3Tuple;
|
||||
|
||||
const REPAIR_MISSION_POSITIONS = {
|
||||
bike: BIKE_REPAIR_POSITION,
|
||||
ebike: EBIKE_REPAIR_POSITION,
|
||||
pylone: [64, 0, -66],
|
||||
ferme: [-24, 0, 42],
|
||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
export const REPAIR_MISSION_POSITION_ENTRIES = [
|
||||
{ mission: "bike", position: REPAIR_MISSION_POSITIONS.bike },
|
||||
{ mission: "ebike", position: REPAIR_MISSION_POSITIONS.ebike },
|
||||
{ mission: "pylone", position: REPAIR_MISSION_POSITIONS.pylone },
|
||||
{ mission: "ferme", position: REPAIR_MISSION_POSITIONS.ferme },
|
||||
] as const satisfies readonly {
|
||||
|
||||
@@ -14,8 +14,8 @@ const DEFAULT_REPAIR_CASE = {
|
||||
} satisfies RepairMissionCaseConfig;
|
||||
|
||||
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
bike: {
|
||||
id: "bike",
|
||||
ebike: {
|
||||
id: "ebike",
|
||||
label: "E-bike",
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
@@ -25,10 +25,10 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
requiredReplacementPartId: "bike-cooling-core-replacement",
|
||||
requiredReplacementPartId: "ebike-cooling-core-replacement",
|
||||
brokenParts: [
|
||||
{
|
||||
id: "bike-cooling-core",
|
||||
id: "ebike-cooling-core",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
nodeName: "refroidisseur",
|
||||
@@ -37,17 +37,17 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "bike-cooling-core-replacement",
|
||||
id: "ebike-cooling-core-replacement",
|
||||
label: "Replacement cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-radio-distractor",
|
||||
id: "ebike-radio-distractor",
|
||||
label: "Radio module",
|
||||
modelPath: "/models/talkie/model.gltf",
|
||||
},
|
||||
{
|
||||
id: "bike-glove-distractor",
|
||||
id: "ebike-glove-distractor",
|
||||
label: "Insulation glove",
|
||||
modelPath: "/models/gant_l/model.gltf",
|
||||
},
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
|
||||
import type {
|
||||
TerrainSurfaceColorConfig,
|
||||
TerrainSurfaceProjectionConfig,
|
||||
} from "@/types/world/terrainSurface";
|
||||
|
||||
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||
export const TERRAIN_WATER_HEIGHT = 0.8;
|
||||
|
||||
const TERRAIN_TILE_SIZE = 1;
|
||||
export const TERRAIN_TILE_SIZE = 1;
|
||||
export const TERRAIN_SURFACE_COLOR_TOLERANCE = 5;
|
||||
export const TERRAIN_SURFACE_PROJECTION =
|
||||
{} satisfies TerrainSurfaceProjectionConfig;
|
||||
|
||||
export const TERRAIN_COLORS = {
|
||||
grass1: {
|
||||
@@ -54,3 +60,5 @@ export const TERRAIN_COLORS = {
|
||||
kind: "rock",
|
||||
},
|
||||
} satisfies Record<string, TerrainSurfaceColorConfig>;
|
||||
|
||||
export type TerrainColorKey = keyof typeof TERRAIN_COLORS;
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
export function useRepairMovementLocked(): boolean {
|
||||
return useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return isRepairMovementLocked(state.bike.currentStep);
|
||||
case "ebike":
|
||||
return isRepairMovementLocked(state.ebike.currentStep);
|
||||
case "pylone":
|
||||
return isRepairMovementLocked(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
|
||||
@@ -26,7 +26,7 @@ interface IntroState {
|
||||
currentStep: GameStep;
|
||||
dialogueAudio: string | null;
|
||||
hasCompleted: boolean;
|
||||
isBikeUnlocked: boolean;
|
||||
isEbikeUnlocked: boolean;
|
||||
}
|
||||
|
||||
interface MissionState {
|
||||
@@ -46,7 +46,7 @@ export interface GameState {
|
||||
isCinematicPlaying: boolean;
|
||||
missionFlow: MissionFlowState;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
ebike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
};
|
||||
pylone: MissionState & {
|
||||
@@ -70,13 +70,13 @@ interface GameActions {
|
||||
setIntroStep: (step: GameStep) => void;
|
||||
setIntroState: (intro: Partial<IntroState>) => void;
|
||||
setPlayerName: (playerName: string) => void;
|
||||
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
||||
setEbikeState: (ebike: Partial<GameState["ebike"]>) => void;
|
||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
|
||||
setOutroState: (outro: Partial<GameState["outro"]>) => void;
|
||||
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
|
||||
completeIntro: () => void;
|
||||
completeBike: () => void;
|
||||
completeEbike: () => void;
|
||||
completePylone: () => void;
|
||||
completeFerme: () => void;
|
||||
completeMission: (mission: RepairMissionId) => void;
|
||||
@@ -104,24 +104,24 @@ function isBoolean(value: unknown): value is boolean {
|
||||
|
||||
function completeIntroState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "bike",
|
||||
mainState: "ebike",
|
||||
intro: {
|
||||
...state.intro,
|
||||
hasCompleted: true,
|
||||
isBikeUnlocked: true,
|
||||
isEbikeUnlocked: true,
|
||||
},
|
||||
bike: {
|
||||
...state.bike,
|
||||
ebike: {
|
||||
...state.ebike,
|
||||
currentStep: "locked",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeBikeState(state: GameState): GameStateUpdate {
|
||||
function completeEbikeState(state: GameState): GameStateUpdate {
|
||||
return {
|
||||
mainState: "pylone",
|
||||
bike: {
|
||||
...state.bike,
|
||||
ebike: {
|
||||
...state.ebike,
|
||||
currentStep: "done",
|
||||
isRepaired: true,
|
||||
},
|
||||
@@ -180,8 +180,8 @@ function completeMissionState(
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
switch (mission) {
|
||||
case "bike":
|
||||
return completeBikeState(state);
|
||||
case "ebike":
|
||||
return completeEbikeState(state);
|
||||
case "pylone":
|
||||
return completePyloneState(state);
|
||||
case "ferme":
|
||||
@@ -236,9 +236,9 @@ function createInitialGameState(): GameState {
|
||||
currentStep: "intro",
|
||||
dialogueAudio: null,
|
||||
hasCompleted: false,
|
||||
isBikeUnlocked: false,
|
||||
isEbikeUnlocked: false,
|
||||
},
|
||||
bike: {
|
||||
ebike: {
|
||||
currentStep: "locked",
|
||||
dialogueAudio: null,
|
||||
isRepaired: false,
|
||||
@@ -273,9 +273,9 @@ function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
|
||||
hasCompleted: isBoolean(value.hasCompleted)
|
||||
? value.hasCompleted
|
||||
: initial.hasCompleted,
|
||||
isBikeUnlocked: isBoolean(value.isBikeUnlocked)
|
||||
? value.isBikeUnlocked
|
||||
: initial.isBikeUnlocked,
|
||||
isEbikeUnlocked: isBoolean(value.isEbikeUnlocked)
|
||||
? value.isEbikeUnlocked
|
||||
: initial.isEbikeUnlocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ function hydrateMissionFlowState(
|
||||
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||
if (!isRecord(value)) return initial;
|
||||
|
||||
const bike = hydrateMissionState(initial.bike, value.bike);
|
||||
const ebike = hydrateMissionState(initial.ebike, value.ebike);
|
||||
const pylone = hydrateMissionState(initial.pylone, value.pylone);
|
||||
const ferme = hydrateMissionState(initial.ferme, value.ferme);
|
||||
const outro = isRecord(value.outro) ? value.outro : null;
|
||||
@@ -337,12 +337,12 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||
value.missionFlow,
|
||||
),
|
||||
intro: hydrateIntroState(initial.intro, value.intro),
|
||||
bike: {
|
||||
...bike,
|
||||
ebike: {
|
||||
...ebike,
|
||||
isRepaired:
|
||||
isRecord(value.bike) && isBoolean(value.bike.isRepaired)
|
||||
? value.bike.isRepaired
|
||||
: initial.bike.isRepaired,
|
||||
isRecord(value.ebike) && isBoolean(value.ebike.isRepaired)
|
||||
? value.ebike.isRepaired
|
||||
: initial.ebike.isRepaired,
|
||||
},
|
||||
pylone: {
|
||||
...pylone,
|
||||
@@ -384,7 +384,7 @@ function pickGameState(state: GameStore): GameState {
|
||||
isCinematicPlaying: state.isCinematicPlaying,
|
||||
missionFlow: state.missionFlow,
|
||||
intro: state.intro,
|
||||
bike: state.bike,
|
||||
ebike: state.ebike,
|
||||
pylone: state.pylone,
|
||||
ferme: state.ferme,
|
||||
outro: state.outro,
|
||||
@@ -415,8 +415,8 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
set((state) => ({
|
||||
missionFlow: { ...state.missionFlow, playerName },
|
||||
})),
|
||||
setBikeState: (bike) =>
|
||||
set((state) => ({ bike: { ...state.bike, ...bike } })),
|
||||
setEbikeState: (ebike) =>
|
||||
set((state) => ({ ebike: { ...state.ebike, ...ebike } })),
|
||||
setPyloneState: (pylone) =>
|
||||
set((state) => ({ pylone: { ...state.pylone, ...pylone } })),
|
||||
setFermeState: (ferme) =>
|
||||
@@ -426,7 +426,7 @@ export const useGameStore = create<GameStore>()((set) => ({
|
||||
setMissionStep: (mission, step) =>
|
||||
set((state) => setMissionStepState(state, mission, step)),
|
||||
completeIntro: () => set(completeIntroState),
|
||||
completeBike: () => set((state) => completeMissionState(state, "bike")),
|
||||
completeEbike: () => set((state) => completeMissionState(state, "ebike")),
|
||||
completePylone: () => set((state) => completeMissionState(state, "pylone")),
|
||||
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
|
||||
completeMission: (mission) =>
|
||||
|
||||
@@ -26,8 +26,8 @@ export function HandTrackingProvider({
|
||||
const sceneMode = useSceneMode();
|
||||
const repairNeedsHands = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "bike":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.bike.currentStep);
|
||||
case "ebike":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.ebike.currentStep);
|
||||
case "pylone":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.pylone.currentStep);
|
||||
case "ferme":
|
||||
|
||||
+2
-2
@@ -27,11 +27,11 @@ export const GAME_STEPS: readonly GameStep[] = [
|
||||
|
||||
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
|
||||
|
||||
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
|
||||
export type MainGameState = "intro" | "ebike" | "pylone" | "ferme" | "outro";
|
||||
|
||||
export const MAIN_GAME_STATES: readonly MainGameState[] = [
|
||||
"intro",
|
||||
"bike",
|
||||
"ebike",
|
||||
"pylone",
|
||||
"ferme",
|
||||
"outro",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
Vector3Tuple,
|
||||
} from "@/types/three/three";
|
||||
|
||||
export type RepairMissionId = "bike" | "pylone" | "ferme";
|
||||
export type RepairMissionId = "ebike" | "pylone" | "ferme";
|
||||
|
||||
export interface RepairMissionCaseConfig {
|
||||
position: Vector3Tuple;
|
||||
@@ -54,7 +54,7 @@ export type MissionStep =
|
||||
| "reassembling"
|
||||
| "done";
|
||||
|
||||
const REPAIR_MISSION_IDS = ["bike", "pylone", "ferme"] as const;
|
||||
const REPAIR_MISSION_IDS = ["ebike", "pylone", "ferme"] as const;
|
||||
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||
REPAIR_MISSION_IDS,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
type TerrainSurfaceKind =
|
||||
import type * as THREE from "three";
|
||||
|
||||
export type TerrainSurfaceKind =
|
||||
| "grass"
|
||||
| "path"
|
||||
| "water"
|
||||
@@ -6,7 +8,19 @@ type TerrainSurfaceKind =
|
||||
| "dirt"
|
||||
| "rock";
|
||||
|
||||
type TerrainSurfaceRgb = readonly [number, number, number];
|
||||
export type TerrainSurfaceRgb = readonly [number, number, number];
|
||||
|
||||
export interface TerrainSurfaceUv {
|
||||
u: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceProjectionConfig {
|
||||
flipX?: boolean;
|
||||
flipZ?: boolean;
|
||||
offsetX?: number;
|
||||
offsetZ?: number;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceBounds {
|
||||
minX: number;
|
||||
@@ -23,3 +37,15 @@ export interface TerrainSurfaceColorConfig {
|
||||
modelPath?: string;
|
||||
tileSize?: number;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceSample {
|
||||
rgb: TerrainSurfaceRgb;
|
||||
key: string | null;
|
||||
config: TerrainSurfaceColorConfig | null;
|
||||
}
|
||||
|
||||
export interface TerrainSurfaceData {
|
||||
bounds: TerrainSurfaceBounds;
|
||||
imageData: ImageData;
|
||||
raycastTarget: THREE.Object3D;
|
||||
}
|
||||
|
||||
@@ -301,9 +301,9 @@ function MapNodeInstance({
|
||||
}): React.JSX.Element | null {
|
||||
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const hideEbikeMapModel =
|
||||
node.name === "ebike" && mainState === "bike" && bikeStep !== "locked";
|
||||
node.name === "ebike" && mainState === "ebike" && ebikeStep !== "locked";
|
||||
|
||||
useEffect(() => {
|
||||
if (modelUrl !== null || isGeneratedModel) return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import {
|
||||
BIKE_REPAIR_POSITION,
|
||||
EBIKE_REPAIR_POSITION,
|
||||
REPAIR_MISSION_POSITION_ENTRIES,
|
||||
} from "@/data/gameplay/repairMissionAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
@@ -34,19 +34,19 @@ function StageAnchor({
|
||||
|
||||
function EbikeMissionTrigger(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
|
||||
if (mainState !== "bike" || bikeStep !== "locked") return null;
|
||||
if (mainState !== "ebike" || ebikeStep !== "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={BIKE_REPAIR_POSITION}>
|
||||
<group position={EBIKE_REPAIR_POSITION}>
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label="Réparer l'e-bike"
|
||||
position={BIKE_REPAIR_POSITION}
|
||||
position={EBIKE_REPAIR_POSITION}
|
||||
radius={4}
|
||||
onPress={() => setMissionStep("bike", "waiting")}
|
||||
onPress={() => setMissionStep("ebike", "waiting")}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.3, 16, 16]} />
|
||||
|
||||
Reference in New Issue
Block a user