From 5f113cbba4313571f3637ecc7cec5fd12ebb5be3 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 01:43:25 +0200 Subject: [PATCH] feat(tutorial): add movement and hand-tracking onboarding overlays Mount two first-time tutorial overlays driven by the game state machine: - MovementTutorial: visible during the intro reveal and the free-walk step before the ebike mount, dismissed on the first Z/Q/S/D keydown. - HandTrackingTutorial: visible during the early ebike repair steps (fragmented, scanning, inspected), dismissed when MediaPipe detects any hand on screen. Both share a generic TutorialOverlay shell (transparent panel, dark blue border, lucide-react Hand / inline ZQSD keycap icons, centered text). The overlay sits at z-index 14, behind Subtitles (15) and the talkie overlay (16), so dialogue/subtitle UI stays in front. Dismissals stay persistent for the session: keyboard-triggered uses event-handler setState; hand-detection uses a guarded effect-driven setState (same pattern as PylonDownedPylon resync). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ui/GameUI.tsx | 4 ++ .../ui/tutorial/HandTrackingTutorial.tsx | 59 ++++++++++++++++ .../ui/tutorial/MovementTutorial.tsx | 57 ++++++++++++++++ .../ui/tutorial/TutorialOverlay.tsx | 23 +++++++ src/index.css | 67 +++++++++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 src/components/ui/tutorial/HandTrackingTutorial.tsx create mode 100644 src/components/ui/tutorial/MovementTutorial.tsx create mode 100644 src/components/ui/tutorial/TutorialOverlay.tsx 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 ( + +