diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 158a9dc..749c1ed 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -6,6 +6,8 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { Subtitles } from "@/components/ui/Subtitles"; import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay"; +import { HandTrackingTutorial } from "@/components/ui/tutorial/HandTrackingTutorial"; +import { MovementTutorial } from "@/components/ui/tutorial/MovementTutorial"; export function GameUI(): React.JSX.Element { return ( @@ -15,6 +17,8 @@ export function GameUI(): React.JSX.Element { + + diff --git a/src/components/ui/tutorial/HandTrackingTutorial.tsx b/src/components/ui/tutorial/HandTrackingTutorial.tsx new file mode 100644 index 0000000..03fbb26 --- /dev/null +++ b/src/components/ui/tutorial/HandTrackingTutorial.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Hand } from "lucide-react"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; +import type { MissionStep } from "@/types/gameplay/repairMission"; +import { TutorialOverlay } from "@/components/ui/tutorial/TutorialOverlay"; + +// Repair steps where the hand-tracking tutorial is allowed to display. Covers +// the no-hand-tracking phase (fragmented, scanning) and the first hand-driven +// step (inspected) — beyond that the player has presumably learned. +const HAND_TUTORIAL_STEPS: ReadonlySet = new Set([ + "fragmented", + "scanning", + "inspected", +]); + +/** + * First-time hand-tracking tutorial. Visible during the early ebike repair + * steps until MediaPipe actually detects a hand on screen. Once dismissed it + * stays dismissed for the session. + */ +export function HandTrackingTutorial(): React.JSX.Element | null { + const mainState = useGameStore((state) => state.mainState); + const ebikeStep = useGameStore((state) => state.ebike.currentStep); + const { hands, status } = useHandTrackingSnapshot(); + const [dismissed, setDismissed] = useState(false); + + const isInShowWindow = + mainState === "ebike" && HAND_TUTORIAL_STEPS.has(ebikeStep); + const handsDetected = status !== "idle" && hands.length > 0; + + useEffect(() => { + if (handsDetected && !dismissed) { + // Sync the persistent dismissal flag with an external signal (the + // hand-tracking snapshot). Same shape as the resync pattern used + // elsewhere in the repo (e.g. PylonDownedPylon). + // eslint-disable-next-line react-hooks/set-state-in-effect + setDismissed(true); + } + }, [handsDetected, dismissed]); + + if (!isInShowWindow || dismissed) return null; + + return ( + + + + + } + text="Placez vos mains devant la caméra pour attraper les pièces. Sinon, utilisez la souris." + /> + ); +} diff --git a/src/components/ui/tutorial/MovementTutorial.tsx b/src/components/ui/tutorial/MovementTutorial.tsx new file mode 100644 index 0000000..e4491db --- /dev/null +++ b/src/components/ui/tutorial/MovementTutorial.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import type { GameStep } from "@/types/game"; +import { TutorialOverlay } from "@/components/ui/tutorial/TutorialOverlay"; + +const MOVEMENT_KEYS = new Set(["z", "q", "s", "d"]); +// Intro steps where the movement tutorial is allowed to display. From the +// reveal fade through the free-walk window before the ebike mount. +const MOVEMENT_TUTORIAL_STEPS: ReadonlySet = new Set([ + "reveal", + "await-ebike-mount", +]); + +function KeyCap({ label }: { label: string }): React.JSX.Element { + return {label}; +} + +/** + * First-time movement tutorial. Visible during the intro reveal and the + * walk-around step before the ebike mount, until the player presses any + * of Z, Q, S, D. Once dismissed it stays dismissed for the session. + */ +export function MovementTutorial(): React.JSX.Element | null { + const introStep = useGameStore((state) => state.intro.currentStep); + const [dismissed, setDismissed] = useState(false); + + const isInShowWindow = MOVEMENT_TUTORIAL_STEPS.has(introStep); + + useEffect(() => { + if (dismissed) return; + function onKeyDown(event: KeyboardEvent): void { + if (MOVEMENT_KEYS.has(event.key.toLowerCase())) { + setDismissed(true); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [dismissed]); + + if (!isInShowWindow || dismissed) return null; + + return ( + +