Merge branch 'develop' into feat/polish-mission-2
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -7,6 +7,8 @@ import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { OutroVideoOverlay } from "@/components/ui/OutroVideoOverlay";
|
||||
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 (
|
||||
@@ -16,6 +18,8 @@ export function GameUI(): React.JSX.Element {
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<HandTrackingFallback />
|
||||
<MovementTutorial />
|
||||
<HandTrackingTutorial />
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
|
||||
@@ -4,29 +4,70 @@ import {
|
||||
type HandTrackingGloveHandedness,
|
||||
} from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
|
||||
// Simple schematic silhouettes used as a last-resort fallback when the
|
||||
// rigged glove model has failed to load. Both icons share the same
|
||||
// 48x48 viewBox and the same stroke/fill rules from the .css.
|
||||
// Hand silhouettes used as a last-resort fallback when the rigged glove
|
||||
// model has failed to load. Both icons share a 100x120 viewBox so finger
|
||||
// lengths and the thumb angle stay anatomically readable.
|
||||
|
||||
const OpenHandShape = (): React.JSX.Element => (
|
||||
<>
|
||||
<ellipse cx="9" cy="30" rx="3" ry="6" transform="rotate(-25 9 30)" />
|
||||
<rect x="14" y="8" width="4" height="22" rx="2" />
|
||||
<rect x="20" y="4" width="4" height="26" rx="2" />
|
||||
<rect x="26" y="6" width="4" height="24" rx="2" />
|
||||
<rect x="32" y="10" width="4" height="20" rx="2" />
|
||||
<rect x="10" y="26" width="28" height="18" rx="6" />
|
||||
</>
|
||||
<path
|
||||
d="M 28 116
|
||||
Q 22 100 22 80
|
||||
Q 22 65 28 58
|
||||
Q 22 52 14 46
|
||||
Q 6 40 8 28
|
||||
Q 12 18 22 20
|
||||
Q 30 24 30 36
|
||||
Q 32 46 36 50
|
||||
Q 36 38 36 28
|
||||
Q 36 18 42 18
|
||||
Q 48 18 48 28
|
||||
Q 48 40 50 50
|
||||
Q 50 32 50 14
|
||||
Q 50 6 56 6
|
||||
Q 62 6 62 14
|
||||
Q 62 32 62 50
|
||||
Q 64 38 64 20
|
||||
Q 64 12 70 12
|
||||
Q 76 12 76 20
|
||||
Q 76 38 78 50
|
||||
Q 78 40 78 32
|
||||
Q 78 24 84 24
|
||||
Q 90 24 90 32
|
||||
Q 90 44 92 56
|
||||
Q 96 80 92 100
|
||||
Q 86 116 82 116
|
||||
Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const FistShape = (): React.JSX.Element => (
|
||||
<>
|
||||
<ellipse cx="8" cy="26" rx="3" ry="5" />
|
||||
<rect x="10" y="14" width="28" height="30" rx="10" />
|
||||
<circle cx="15" cy="14" r="3" />
|
||||
<circle cx="21" cy="13" r="3" />
|
||||
<circle cx="27" cy="13" r="3" />
|
||||
<circle cx="33" cy="14" r="3" />
|
||||
<path
|
||||
d="M 18 70
|
||||
Q 14 50 24 38
|
||||
Q 28 30 36 34
|
||||
Q 40 26 48 30
|
||||
Q 54 22 60 28
|
||||
Q 68 24 74 32
|
||||
Q 84 32 88 46
|
||||
Q 92 64 88 82
|
||||
Q 82 104 64 112
|
||||
Q 42 116 26 108
|
||||
Q 14 96 18 70
|
||||
Z"
|
||||
/>
|
||||
<path
|
||||
d="M 18 70
|
||||
Q 6 66 8 80
|
||||
Q 8 94 18 96
|
||||
Q 28 94 26 84
|
||||
Q 22 76 18 70
|
||||
Z"
|
||||
/>
|
||||
<path d="M 32 38 Q 30 50 34 60" fill="none" strokeLinecap="round" />
|
||||
<path d="M 46 32 Q 44 46 48 58" fill="none" strokeLinecap="round" />
|
||||
<path d="M 60 32 Q 58 46 62 58" fill="none" strokeLinecap="round" />
|
||||
<path d="M 74 36 Q 72 50 76 60" fill="none" strokeLinecap="round" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,7 +107,7 @@ export function HandTrackingFallback(): React.JSX.Element | null {
|
||||
<svg
|
||||
key={`${handedness}-${index}`}
|
||||
className="hand-tracking-fallback__icon"
|
||||
viewBox="0 0 48 48"
|
||||
viewBox="0 0 100 120"
|
||||
style={{
|
||||
left: `${leftPercent}%`,
|
||||
top: `${topPercent}%`,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useHandTrackingGloveStatus } from "@/hooks/handTracking/useHandTrackingGloveStatus";
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
|
||||
const HAND_CONNECTIONS: Array<[number, number]> = [
|
||||
// MediaPipe indexes the 21 hand landmarks predictably:
|
||||
// 0 wrist, 1-4 thumb (base→tip), 5-8 index, 9-12 middle, 13-16 ring, 17-20 pinky.
|
||||
const FINGER_LANDMARKS: Array<readonly number[]> = [
|
||||
[1, 2, 3, 4],
|
||||
[5, 6, 7, 8],
|
||||
[9, 10, 11, 12],
|
||||
[13, 14, 15, 16],
|
||||
[17, 18, 19, 20],
|
||||
];
|
||||
const SKELETON_BONES: Array<[number, number]> = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
@@ -26,70 +34,187 @@ const HAND_CONNECTIONS: Array<[number, number]> = [
|
||||
[0, 17],
|
||||
];
|
||||
|
||||
const LANDMARK_FILL = "#67e8f9"; // cyan-300, opaque interior
|
||||
const LANDMARK_STROKE = "#0c4a6e"; // sky-900, dark blue outline
|
||||
const LANDMARK_STROKE_FIST = "#1e3a8a"; // blue-900, thicker accent when fist
|
||||
const CONNECTION_STROKE = "#ffffff"; // white bones
|
||||
const INDEX_TIP_LANDMARK = 8;
|
||||
const HAND_FILL = "#bfdbfe"; // blue-200, light interior
|
||||
const HAND_OUTLINE_COLOR = "#1e3a8a"; // blue-900, crisp dark outline
|
||||
const HAND_OUTLINE_RADIUS = 2; // px
|
||||
// Shrink the rendered hand around its centroid. Grab/physics keep using raw
|
||||
// landmarks elsewhere, so the silhouette is just visually smaller.
|
||||
const RENDER_SCALE = 0.65;
|
||||
const FINGER_THICKNESS_FACTOR = 0.08; // fraction of (scaled) hand length
|
||||
const WRIST_HALF_WIDTH = 0.28;
|
||||
const SKELETON_STROKE = "rgba(30, 58, 138, 0.22)";
|
||||
const SKELETON_DOT_FILL = "rgba(30, 58, 138, 0.35)";
|
||||
const FILTER_ID = "hand-tracking-outline";
|
||||
|
||||
export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
const { hands, status } = useHandTrackingSnapshot();
|
||||
const showHandTrackingSvg = useDebugStore((debug) =>
|
||||
debug.getShowHandTrackingSvg(),
|
||||
);
|
||||
const gloves = useHandTrackingGloveStatus((state) => state.gloves);
|
||||
const hasLoadedGlove = Object.values(gloves).some(
|
||||
(gloveStatus) => gloveStatus === "loaded",
|
||||
const showHandTrackingModel = useDebugStore((debug) =>
|
||||
debug.getShowHandTrackingModel(),
|
||||
);
|
||||
|
||||
if (
|
||||
status === "idle" ||
|
||||
hands.length === 0 ||
|
||||
(hasLoadedGlove && !showHandTrackingSvg)
|
||||
) {
|
||||
if (status === "idle" || hands.length === 0 || showHandTrackingModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
return (
|
||||
<svg className="hand-tracking-visualizer" aria-hidden="true">
|
||||
<defs>
|
||||
{/* Dilate the merged alpha of all child shapes by HAND_OUTLINE_RADIUS
|
||||
and subtract the original to get a 1-ring outline. Lets the palm
|
||||
polygon and the five finger tubes share a single crisp outline
|
||||
with no internal seams where they overlap. */}
|
||||
<filter id={FILTER_ID} x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feMorphology
|
||||
operator="dilate"
|
||||
radius={HAND_OUTLINE_RADIUS}
|
||||
in="SourceAlpha"
|
||||
result="dilated"
|
||||
/>
|
||||
<feComposite
|
||||
operator="out"
|
||||
in="dilated"
|
||||
in2="SourceAlpha"
|
||||
result="ringAlpha"
|
||||
/>
|
||||
<feFlood floodColor={HAND_OUTLINE_COLOR} result="ringColor" />
|
||||
<feComposite
|
||||
operator="in"
|
||||
in="ringColor"
|
||||
in2="ringAlpha"
|
||||
result="coloredRing"
|
||||
/>
|
||||
<feMerge>
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
<feMergeNode in="coloredRing" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{hands.map((hand, handIndex) => {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) return null;
|
||||
if (landmarks.length < 21) return null;
|
||||
|
||||
const landmarkStroke = hand.isFist
|
||||
? LANDMARK_STROKE_FIST
|
||||
: LANDMARK_STROKE;
|
||||
// Centroid of all 21 landmarks in pixel space (mirrored x).
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const lm of landmarks) {
|
||||
cx += (1 - lm.x) * viewportWidth;
|
||||
cy += lm.y * viewportHeight;
|
||||
}
|
||||
cx /= landmarks.length;
|
||||
cy /= landmarks.length;
|
||||
|
||||
// Render coordinates: shrink each landmark toward the centroid.
|
||||
const px = (i: number): number => {
|
||||
const lm = landmarks[i];
|
||||
return lm
|
||||
? cx + ((1 - lm.x) * viewportWidth - cx) * RENDER_SCALE
|
||||
: cx;
|
||||
};
|
||||
const py = (i: number): number => {
|
||||
const lm = landmarks[i];
|
||||
return lm ? cy + (lm.y * viewportHeight - cy) * RENDER_SCALE : cy;
|
||||
};
|
||||
|
||||
const handLengthPx = Math.hypot(px(12) - px(0), py(12) - py(0));
|
||||
const fingerThickness = Math.max(
|
||||
6,
|
||||
handLengthPx * FINGER_THICKNESS_FACTOR,
|
||||
);
|
||||
const halfFingerThickness = fingerThickness / 2;
|
||||
const dotRadius = Math.max(1.2, fingerThickness * 0.1);
|
||||
|
||||
// Perpendicular to the palm centerline (wrist → middle MCP), used to
|
||||
// place two synthetic wrist corners on either side of landmark 0.
|
||||
const cdx = px(9) - px(0);
|
||||
const cdy = py(9) - py(0);
|
||||
const clen = Math.hypot(cdx, cdy) || 1;
|
||||
const perpX = -cdy / clen;
|
||||
const perpY = cdx / clen;
|
||||
const thumbSide =
|
||||
(px(1) - px(0)) * perpX + (py(1) - py(0)) * perpY >= 0 ? 1 : -1;
|
||||
const wristHalfWidth = handLengthPx * WRIST_HALF_WIDTH;
|
||||
const wristThumbX = px(0) + perpX * wristHalfWidth * thumbSide;
|
||||
const wristThumbY = py(0) + perpY * wristHalfWidth * thumbSide;
|
||||
const wristPinkyX = px(0) - perpX * wristHalfWidth * thumbSide;
|
||||
const wristPinkyY = py(0) - perpY * wristHalfWidth * thumbSide;
|
||||
|
||||
// Palm outline: straight L between adjacent MCPs along the top (no
|
||||
// inter-finger dip — the morphology dilation rounds the MCP corners),
|
||||
// rounded heel via two Q curves bowing out to the synthetic wrist
|
||||
// corners.
|
||||
const palmD = [
|
||||
`M ${px(1)} ${py(1)}`,
|
||||
`L ${px(5)} ${py(5)}`,
|
||||
`L ${px(9)} ${py(9)}`,
|
||||
`L ${px(13)} ${py(13)}`,
|
||||
`L ${px(17)} ${py(17)}`,
|
||||
`Q ${wristPinkyX} ${wristPinkyY}, ${px(0)} ${py(0)}`,
|
||||
`Q ${wristThumbX} ${wristThumbY}, ${px(1)} ${py(1)}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
|
||||
// Each finger path starts halfFingerThickness inside the palm (toward
|
||||
// the next joint), so the rounded base cap sits hidden inside the palm
|
||||
// fill instead of bulging below the MCP.
|
||||
const fingerPathD = (joints: readonly number[]): string => {
|
||||
const baseIdx = joints[0];
|
||||
const nextIdx = joints[1];
|
||||
if (baseIdx === undefined || nextIdx === undefined) return "";
|
||||
const baseX = px(baseIdx);
|
||||
const baseY = py(baseIdx);
|
||||
const nextX = px(nextIdx);
|
||||
const nextY = py(nextIdx);
|
||||
const dx = nextX - baseX;
|
||||
const dy = nextY - baseY;
|
||||
const dlen = Math.hypot(dx, dy) || 1;
|
||||
const sx = baseX + (dx / dlen) * halfFingerThickness;
|
||||
const sy = baseY + (dy / dlen) * halfFingerThickness;
|
||||
return joints
|
||||
.map((idx, k) =>
|
||||
k === 0 ? `M ${sx} ${sy}` : `L ${px(idx)} ${py(idx)}`,
|
||||
)
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
return (
|
||||
<g key={`${hand.handedness}-${handIndex}`}>
|
||||
{HAND_CONNECTIONS.map(([from, to]) => {
|
||||
const fromPoint = landmarks[from];
|
||||
const toPoint = landmarks[to];
|
||||
if (!fromPoint || !toPoint) return null;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${from}-${to}`}
|
||||
x1={`${(1 - fromPoint.x) * 100}%`}
|
||||
y1={`${fromPoint.y * 100}%`}
|
||||
x2={`${(1 - toPoint.x) * 100}%`}
|
||||
y2={`${toPoint.y * 100}%`}
|
||||
stroke={CONNECTION_STROKE}
|
||||
strokeWidth="2.5"
|
||||
<g filter={`url(#${FILTER_ID})`}>
|
||||
<path d={palmD} fill={HAND_FILL} />
|
||||
{FINGER_LANDMARKS.map((joints, fingerIndex) => (
|
||||
<path
|
||||
key={fingerIndex}
|
||||
d={fingerPathD(joints)}
|
||||
fill="none"
|
||||
stroke={HAND_FILL}
|
||||
strokeWidth={fingerThickness}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</g>
|
||||
|
||||
{landmarks.map((landmark, landmarkIndex) => (
|
||||
{SKELETON_BONES.map(([from, to]) => (
|
||||
<line
|
||||
key={`bone-${from}-${to}`}
|
||||
x1={px(from)}
|
||||
y1={py(from)}
|
||||
x2={px(to)}
|
||||
y2={py(to)}
|
||||
stroke={SKELETON_STROKE}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{landmarks.map((_, landmarkIndex) => (
|
||||
<circle
|
||||
key={landmarkIndex}
|
||||
cx={`${(1 - landmark.x) * 100}%`}
|
||||
cy={`${landmark.y * 100}%`}
|
||||
r={landmarkIndex === INDEX_TIP_LANDMARK ? 6 : 4}
|
||||
fill={LANDMARK_FILL}
|
||||
stroke={landmarkStroke}
|
||||
strokeWidth={hand.isFist ? 2.5 : 2}
|
||||
key={`dot-${landmarkIndex}`}
|
||||
cx={px(landmarkIndex)}
|
||||
cy={py(landmarkIndex)}
|
||||
r={dotRadius}
|
||||
fill={SKELETON_DOT_FILL}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user