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 (
+
+
+
+
+
+
+
+
+ }
+ text="Utilisez le clavier et la souris pour vous déplacer."
+ />
+ );
+}
diff --git a/src/components/ui/tutorial/TutorialOverlay.tsx b/src/components/ui/tutorial/TutorialOverlay.tsx
new file mode 100644
index 0000000..c6327af
--- /dev/null
+++ b/src/components/ui/tutorial/TutorialOverlay.tsx
@@ -0,0 +1,23 @@
+interface TutorialOverlayProps {
+ icon: React.ReactNode;
+ text: string;
+}
+
+/**
+ * Full-screen instructional overlay shown during onboarding moments
+ * (movement intro, hand-tracking intro, ...). Pure presentation: parent
+ * decides when to mount it and when to unmount it.
+ */
+export function TutorialOverlay({
+ icon,
+ text,
+}: TutorialOverlayProps): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/index.css b/src/index.css
index b797892..e191d42 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1812,6 +1812,73 @@ canvas {
pointer-events: none;
}
+.tutorial-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 14;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(96, 165, 250, 0.55);
+ pointer-events: none;
+}
+
+.tutorial-overlay__panel {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 36px;
+ padding: 56px 72px;
+ max-width: 640px;
+ background: transparent;
+ border: 2px solid #1e3a8a;
+ border-radius: 24px;
+ color: #1e3a8a;
+}
+
+.tutorial-overlay__icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tutorial-overlay__text {
+ font-family: var(--font-body);
+ font-size: 1.1rem;
+ font-weight: 500;
+ line-height: 1.45;
+ text-align: center;
+ margin: 0;
+}
+
+.tutorial-overlay__keyboard {
+ display: grid;
+ grid-template-columns: repeat(3, 64px);
+ gap: 8px;
+ font-family: var(--font-primary);
+}
+
+.tutorial-overlay__keycap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ background: #e0f2fe;
+ border: 2px solid #1e3a8a;
+ border-radius: 10px;
+ font-size: 1.6rem;
+ font-weight: 700;
+ color: #1e3a8a;
+}
+
+.tutorial-overlay__hands {
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ color: #1e3a8a;
+}
+
.hand-tracking-fallback__icon {
position: absolute;
width: 80px;