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 { 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 {
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<HandTrackingFallback />
|
||||
<MovementTutorial />
|
||||
<HandTrackingTutorial />
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user