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) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
|||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
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 {
|
export function GameUI(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +17,8 @@ export function GameUI(): React.JSX.Element {
|
|||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
<HandTrackingVisualizer />
|
<HandTrackingVisualizer />
|
||||||
<HandTrackingFallback />
|
<HandTrackingFallback />
|
||||||
|
<MovementTutorial />
|
||||||
|
<HandTrackingTutorial />
|
||||||
<Subtitles />
|
<Subtitles />
|
||||||
<TalkieDialogueOverlay />
|
<TalkieDialogueOverlay />
|
||||||
<GameSettingsMenu />
|
<GameSettingsMenu />
|
||||||
|
|||||||
@@ -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<MissionStep> = 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 (
|
||||||
|
<TutorialOverlay
|
||||||
|
icon={
|
||||||
|
<div className="tutorial-overlay__hands">
|
||||||
|
<Hand size={96} strokeWidth={1.5} />
|
||||||
|
<Hand
|
||||||
|
size={96}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
style={{ transform: "scaleX(-1)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
text="Placez vos mains devant la caméra pour attraper les pièces. Sinon, utilisez la souris."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<GameStep> = new Set([
|
||||||
|
"reveal",
|
||||||
|
"await-ebike-mount",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function KeyCap({ label }: { label: string }): React.JSX.Element {
|
||||||
|
return <span className="tutorial-overlay__keycap">{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<TutorialOverlay
|
||||||
|
icon={
|
||||||
|
<div className="tutorial-overlay__keyboard">
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<KeyCap label="Z" />
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<KeyCap label="Q" />
|
||||||
|
<KeyCap label="S" />
|
||||||
|
<KeyCap label="D" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
text="Utilisez le clavier et la souris pour vous déplacer."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="tutorial-overlay" aria-live="polite">
|
||||||
|
<div className="tutorial-overlay__panel">
|
||||||
|
<div className="tutorial-overlay__icon">{icon}</div>
|
||||||
|
<p className="tutorial-overlay__text">{text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1812,6 +1812,73 @@ canvas {
|
|||||||
pointer-events: none;
|
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 {
|
.hand-tracking-fallback__icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
|
|||||||
Reference in New Issue
Block a user