merge mission & intro

This commit is contained in:
math-pixel
2026-05-12 14:03:53 +02:00
66 changed files with 4021 additions and 991 deletions
+16 -13
View File
@@ -16,25 +16,28 @@ export function DocsDocument({
frContent,
}: DocsDocumentProps): React.JSX.Element {
const { language, toggleLanguage } = useDocsLanguage();
const hasAlternateContent = frContent !== content;
const translatedContent = language === "fr" ? frContent : content;
return (
<div className="docs-content">
<header className="docs-content__header">
<span>{title}</span>
<button
className="docs-language-toggle"
type="button"
onClick={toggleLanguage}
aria-label="Changer la langue de la documentation"
>
<span className={language === "fr" ? "is-active" : undefined}>
FR
</span>
<span className={language === "en" ? "is-active" : undefined}>
EN
</span>
</button>
{hasAlternateContent ? (
<button
className="docs-language-toggle"
type="button"
onClick={toggleLanguage}
aria-label="Changer la langue de la documentation"
>
<span className={language === "fr" ? "is-active" : undefined}>
FR
</span>
<span className={language === "en" ? "is-active" : undefined}>
EN
</span>
</button>
) : null}
</header>
<article className="docs-section">
+235 -157
View File
@@ -1,6 +1,7 @@
import {
Box,
Braces,
ChevronDown,
Download,
Expand,
Keyboard,
@@ -11,6 +12,8 @@ import {
RotateCw,
Save,
Undo2,
Unlock,
X,
} from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
@@ -25,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number;
redoCount: number;
onUndo: () => void;
@@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"],
] as const;
interface EditorPanelGroupProps {
title: string;
summary?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
function EditorPanelGroup({
title,
summary,
defaultOpen = false,
children,
}: EditorPanelGroupProps): React.JSX.Element {
return (
<details className="editor-panel-group" open={defaultOpen}>
<summary className="editor-panel-group-summary">
<span>{title}</span>
<span className="editor-panel-group-meta">
{summary ? <span>{summary}</span> : null}
<ChevronDown size={15} aria-hidden="true" />
</span>
</summary>
<div className="editor-panel-group-content">{children}</div>
</details>
);
}
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -57,6 +90,9 @@ export function EditorControls({
mapNodes,
nodesCount,
selectedNodeName,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount,
redoCount,
onUndo,
@@ -79,173 +115,215 @@ export function EditorControls({
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header>
<section
className="editor-control-section"
aria-labelledby="transform-heading"
>
<div className="editor-section-heading">
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</div>
<div className="editor-transform-buttons">
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
<EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
<EditorPanelGroup title="Shortcuts" summary="Keys">
<section
className="editor-control-section"
aria-labelledby="shortcuts-heading"
>
<Undo2 size={15} aria-hidden="true" />
Undo
<span>{undoCount}</span>
</button>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<div className="editor-section-heading">
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</div>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<dl className="editor-shortcuts-list">
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
</EditorPanelGroup>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
<section
className="editor-control-section"
aria-labelledby="transform-heading"
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
<div className="editor-section-heading">
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
<div className="editor-transform-buttons">
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="shortcuts-heading"
>
<div className="editor-section-heading">
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</div>
<dl className="editor-shortcuts-list">
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
<section className="editor-json-section" aria-labelledby="json-heading">
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<Undo2 size={15} aria-hidden="true" />
Undo
<span>{undoCount}</span>
</button>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
<EditorDialogueManifestPanel />
<EditorSrtPanel />
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
<div className="editor-selected-actions">
<button
type="button"
onClick={onSelectionLockToggle}
aria-pressed={isSelectionLocked}
aria-label={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
title={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
>
{isSelectionLocked ? (
<Lock size={14} aria-hidden="true" />
) : (
<Unlock size={14} aria-hidden="true" />
)}
</button>
<button
type="button"
onClick={onClearSelection}
aria-label="Clear selection"
title="Clear selection"
>
<X size={14} aria-hidden="true" />
</button>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-json-section"
aria-labelledby="json-heading"
>
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
</EditorPanelGroup>
<EditorPanelGroup title="Cinematics" summary="Timeline">
<EditorCinematicManifestPanel
onPreviewCinematic={onPreviewCinematic}
/>
</EditorPanelGroup>
<EditorPanelGroup title="Dialogues" summary="Manifest">
<EditorDialogueManifestPanel />
</EditorPanelGroup>
<EditorPanelGroup title="SRT" summary="Subtitles">
<EditorSrtPanel />
</EditorPanelGroup>
</aside>
</>
);
+14 -2
View File
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
return {
onClick: (event) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(index);
},
onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData,
selectedNodeIndex,
onSelectNode,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
<axesHelper args={[10]} />
<group
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onClick={(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null);
}}
>
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
);
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
+12 -2
View File
@@ -17,6 +17,7 @@ interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
@@ -35,6 +36,7 @@ export function EditorScene({
sceneData,
selectedNodeIndex,
onSelectNode,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
@@ -68,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) {
case "escape":
onSelectNode(null);
if (!isSelectionLocked) onSelectNode(null);
break;
case "t":
onTransformModeChange("translate");
@@ -85,7 +87,14 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]);
}, [
isSelectionLocked,
selectedNodeIndex,
onSelectNode,
onTransformModeChange,
onUndo,
onRedo,
]);
return (
<>
@@ -113,6 +122,7 @@ export function EditorScene({
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
+16 -16
View File
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useAnimations } from "@react-three/drei";
import type { AnimationAction } from "three";
import * as THREE from "three";
import {
AnimatedModelContext,
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import type { ModelTransformProps } from "@/types/three/three";
export interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
@@ -37,15 +36,13 @@ export function AnimatedModel({
onAnimationEnd,
children,
}: AnimatedModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene, animations } = useLoggedGLTF(modelPath, {
scope: "AnimatedModel",
position,
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const { actions, names, mixer } = useAnimations(animations, scene);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0;
@@ -146,19 +143,22 @@ export function AnimatedModel({
names,
};
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => {
scene.position.set(...position);
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
const parsedScale =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(
parsedScale[0] ?? 1,
parsedScale[1] ?? 1,
parsedScale[2] ?? 1,
);
}, [scene, position, rotation, scale]);
return (
<AnimatedModelContext.Provider value={contextValue}>
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={parsedScale}
>
<primitive object={model} />
</group>
<primitive object={scene} />
{children}
</AnimatedModelContext.Provider>
);
+49 -14
View File
@@ -26,35 +26,59 @@ export const docGroups: DocGroup[] = [
subtitle: "Runtime structure",
meta: "02",
},
{
path: "/docs/scene-runtime",
title: "Scene Runtime",
subtitle: "Loading and spawn gates",
meta: "03",
},
{
path: "/docs/repair-game",
title: "Repair Game",
subtitle: "Gameplay implementation",
meta: "04",
},
{
path: "/docs/interaction",
title: "Interaction System",
subtitle: "Trigger, grab, hand input",
meta: "05",
},
{
path: "/docs/target-architecture",
title: "Target Architecture",
subtitle: "Next direction",
meta: "03",
meta: "06",
},
{
path: "/docs/technical-editor",
title: "Editor Technical Notes",
subtitle: "Implementation details",
meta: "04",
meta: "07",
},
{
path: "/docs/audio",
title: "Audio Technical Notes",
subtitle: "Music, dialogue, SRT, and SFX",
meta: "08",
},
{
path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline",
meta: "05",
meta: "09",
},
{
path: "/docs/zustand",
title: "Zustand Game State",
subtitle: "Progression store",
meta: "06",
title: "Zustand Stores",
subtitle: "Game, settings, subtitles",
meta: "10",
},
{
path: "/docs/mission-flow",
title: "Mission Flow",
subtitle: "Intro and mission 2 prototype",
meta: "07",
path: "/docs/three-debugging",
title: "Three Debugging",
subtitle: "Step into Three.js internals",
meta: "11",
},
],
},
@@ -65,25 +89,36 @@ export const docGroups: DocGroup[] = [
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
meta: "08",
meta: "12",
},
{
path: "/docs/main-feature",
title: "Main Feature",
subtitle: "Repair-game prototype",
meta: "09",
meta: "13",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
meta: "10",
meta: "14",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "11",
meta: "15",
},
],
},
{
label: "Review",
sections: [
{
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "16",
},
],
},
+1 -1
View File
@@ -11,5 +11,5 @@ export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 100, 0];
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+16 -1
View File
@@ -10,7 +10,9 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult {
octree: Octree | null;
gameplayReady: boolean;
showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
}
@@ -21,15 +23,26 @@ export function useWorldSceneLoading({
}: UseWorldSceneLoadingOptions): UseWorldSceneLoadingResult {
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const sceneReady =
(sceneMode === "game" && gameMapLoaded) ||
(sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setGameMapLoaded(true);
}, []);
const handleGameStageLoaded = useCallback(() => {
setGameStageLoaded(true);
onLoadingStateChange?.({
currentStep: "Initialisation gameplay",
progress: 0.96,
status: "loading",
});
}, [onLoadingStateChange]);
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setOctree(nextOctree);
@@ -74,7 +87,9 @@ export function useWorldSceneLoading({
return {
octree,
gameplayReady,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
};
+89 -1
View File
@@ -1142,6 +1142,61 @@ canvas {
line-height: 1.45;
}
.editor-panel-group {
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-panel-group-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 12px;
color: #ffffff;
cursor: pointer;
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
list-style: none;
text-transform: uppercase;
user-select: none;
}
.editor-panel-group-summary::-webkit-details-marker {
display: none;
}
.editor-panel-group-summary:hover {
color: #f2f2f2;
}
.editor-panel-group-meta {
display: inline-flex;
align-items: center;
gap: 8px;
color: #777777;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
text-transform: none;
}
.editor-panel-group-meta svg {
transition: transform 160ms ease;
}
.editor-panel-group[open] .editor-panel-group-meta svg {
transform: rotate(180deg);
}
.editor-panel-group-content > .editor-control-section:first-child,
.editor-panel-group-content > .editor-json-section:first-child,
.editor-panel-group-content > .editor-cinematic-manifest-section:first-child,
.editor-panel-group-content > .editor-dialogue-manifest-section:first-child,
.editor-panel-group-content > .editor-srt-section:first-child {
border-top: 0;
}
.editor-control-section {
padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1313,7 +1368,8 @@ canvas {
}
.editor-selected-info {
display: flex;
display: grid;
grid-template-columns: 17px 1fr auto;
align-items: center;
gap: 11px;
background: #ffffff;
@@ -1323,6 +1379,38 @@ canvas {
color: #050505;
}
.editor-selected-actions {
display: inline-flex;
gap: 6px;
}
.editor-selected-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 27px;
height: 27px;
padding: 0;
color: #050505;
background: rgba(0, 0, 0, 0.06);
border: 0;
border-radius: 9px;
cursor: pointer;
transition:
background 160ms ease,
transform 160ms ease;
}
.editor-selected-actions button:hover {
background: rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
}
.editor-selected-actions button[aria-pressed="true"] {
color: #ffffff;
background: #050505;
}
.editor-selected-info strong,
.editor-selected-info span {
display: block;
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
<DocsDocument
content={animation}
frContent={animation}
meta="08"
meta="15"
title="Animation & 3D Model System"
/>
);
+2 -3
View File
@@ -1,14 +1,13 @@
import architecture from "../../../../docs/technical/architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { architectureFr } from "@/data/docs/docsTranslations";
export function DocsArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={architecture}
frContent={architectureFr}
frContent={architecture}
meta="02"
title="Architecture actuelle"
title="Current Architecture"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import audio from "../../../../docs/technical/audio.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAudioPage(): React.JSX.Element {
return (
<DocsDocument
content={audio}
frContent={audio}
meta="08"
title="Audio Technical Notes"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import codeReview from "../../../../docs/code-review-preparation.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsCodeReviewPage(): React.JSX.Element {
return (
<DocsDocument
content={codeReview}
frContent={codeReview}
meta="16"
title="Code Review Prep"
/>
);
}
+2 -3
View File
@@ -1,13 +1,12 @@
import editor from "../../../../docs/user/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { editorFr } from "@/data/docs/docsTranslations";
export function DocsEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={editor}
frContent={editorFr}
meta="09"
frContent={editor}
meta="14"
title="Editor User Guide"
/>
);
+2 -3
View File
@@ -1,13 +1,12 @@
import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { featuresFr } from "@/data/docs/docsTranslations";
export function DocsFeaturesPage(): React.JSX.Element {
return (
<DocsDocument
content={features}
frContent={featuresFr}
meta="06"
frContent={features}
meta="12"
title="Features"
/>
);
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
<DocsDocument
content={handTracking}
frContent={handTracking}
meta="05"
meta="09"
title="Hand Tracking Technical Notes"
/>
);
+13
View File
@@ -0,0 +1,13 @@
import interaction from "../../../../docs/technical/interaction.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsInteractionPage(): React.JSX.Element {
return (
<DocsDocument
content={interaction}
frContent={interaction}
meta="05"
title="Interaction System"
/>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
<DocsDocument
content={mainFeature}
frContent={mainFeature}
meta="07"
meta="13"
title="Main Feature"
/>
);
+1 -2
View File
@@ -1,12 +1,11 @@
import readme from "../../../README.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { readmeFr } from "@/data/docs/docsTranslations";
export function DocsReadmePage(): React.JSX.Element {
return (
<DocsDocument
content={readme}
frContent={readmeFr}
frContent={readme}
meta="01"
title="README"
/>
+13
View File
@@ -0,0 +1,13 @@
import repairGame from "../../../../docs/technical/repair-game.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsRepairGamePage(): React.JSX.Element {
return (
<DocsDocument
content={repairGame}
frContent={repairGame}
meta="04"
title="Repair Game"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import sceneRuntime from "../../../../docs/technical/scene-runtime.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsSceneRuntimePage(): React.JSX.Element {
return (
<DocsDocument
content={sceneRuntime}
frContent={sceneRuntime}
meta="03"
title="Scene Runtime"
/>
);
}
+3 -4
View File
@@ -1,14 +1,13 @@
import targetArchitecture from "../../../../docs/technical/target-architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { targetArchitectureFr } from "@/data/docs/docsTranslations";
export function DocsTargetArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={targetArchitecture}
frContent={targetArchitectureFr}
meta="03"
title="Architecture cible"
frContent={targetArchitecture}
meta="06"
title="Target Architecture"
/>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsTechnicalEditorPage(): React.JSX.Element {
<DocsDocument
content={technicalEditor}
frContent={technicalEditor}
meta="04"
meta="07"
title="Editor Technical Notes"
/>
);
+13
View File
@@ -0,0 +1,13 @@
import threeDebugging from "../../../../docs/technical/three-debugging.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsThreeDebuggingPage(): React.JSX.Element {
return (
<DocsDocument
content={threeDebugging}
frContent={threeDebugging}
meta="11"
title="Three Debugging"
/>
);
}
+3 -4
View File
@@ -1,14 +1,13 @@
import zustand from "../../../../docs/technical/zustand.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { zustandFr } from "@/data/docs/docsTranslations";
export function DocsZustandPage(): React.JSX.Element {
return (
<DocsDocument
content={zustand}
frContent={zustandFr}
meta="05"
title="Zustand Game State"
frContent={zustand}
meta="10"
title="Zustand Stores"
/>
);
}
+13
View File
@@ -67,6 +67,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{
...INITIAL_SCENE_LOADING_STATE,
@@ -112,6 +113,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index);
}, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
@@ -246,6 +255,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
@@ -276,6 +286,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
+4 -4
View File
@@ -38,13 +38,13 @@ export function HomePage(): React.JSX.Element {
const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => {
setSceneLoadingState((currentState) => {
const shouldRestartProgress = currentState.status === "ready";
if (currentState.status === "ready" && nextState.status === "loading") {
return currentState;
}
return {
...nextState,
progress: shouldRestartProgress
? nextState.progress
: Math.max(currentState.progress, nextState.progress),
progress: Math.max(currentState.progress, nextState.progress),
};
});
},
+12 -1
View File
@@ -8,16 +8,22 @@ import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
import {
DocsAnimationRoute,
DocsAudioRoute,
DocsArchitectureRoute,
DocsCodeReviewRoute,
DocsEditorRoute,
DocsFeaturesRoute,
DocsHandTrackingRoute,
DocsInteractionRoute,
DocsLayoutRoute,
DocsMainFeatureRoute,
DocsMissionFlowRoute,
DocsReadmeRoute,
DocsRepairGameRoute,
DocsSceneRuntimeRoute,
DocsTargetArchitectureRoute,
DocsTechnicalEditorRoute,
DocsThreeDebuggingRoute,
DocsZustandRoute,
} from "@/routes/DocsRoute";
@@ -46,15 +52,20 @@ const docsRoute = createRoute({
const docsChildRoutes = [
{ path: "/", component: DocsReadmeRoute },
{ path: "architecture", component: DocsArchitectureRoute },
{ path: "scene-runtime", component: DocsSceneRuntimeRoute },
{ path: "repair-game", component: DocsRepairGameRoute },
{ path: "interaction", component: DocsInteractionRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "audio", component: DocsAudioRoute },
{ path: "hand-tracking", component: DocsHandTrackingRoute },
{ path: "zustand", component: DocsZustandRoute },
{ path: "mission-flow", component: DocsMissionFlowRoute },
{ path: "three-debugging", component: DocsThreeDebuggingRoute },
{ path: "features", component: DocsFeaturesRoute },
{ path: "main-feature", component: DocsMainFeatureRoute },
{ path: "editor", component: DocsEditorRoute },
{ path: "animation", component: DocsAnimationRoute },
{ path: "code-review", component: DocsCodeReviewRoute },
].map(({ path, component }) =>
createRoute({
getParentRoute: () => docsRoute,
+37 -5
View File
@@ -43,10 +43,26 @@ const LazyDocsTargetArchitecturePage = lazyNamed(
() => import("@/pages/docs/target-architecture/page"),
"DocsTargetArchitecturePage",
);
const LazyDocsSceneRuntimePage = lazyNamed(
() => import("@/pages/docs/scene-runtime/page"),
"DocsSceneRuntimePage",
);
const LazyDocsRepairGamePage = lazyNamed(
() => import("@/pages/docs/repair-game/page"),
"DocsRepairGamePage",
);
const LazyDocsInteractionPage = lazyNamed(
() => import("@/pages/docs/interaction/page"),
"DocsInteractionPage",
);
const LazyDocsTechnicalEditorPage = lazyNamed(
() => import("@/pages/docs/technical-editor/page"),
"DocsTechnicalEditorPage",
);
const LazyDocsAudioPage = lazyNamed(
() => import("@/pages/docs/audio/page"),
"DocsAudioPage",
);
const LazyDocsHandTrackingPage = lazyNamed(
() => import("@/pages/docs/hand-tracking/page"),
"DocsHandTrackingPage",
@@ -55,10 +71,6 @@ const LazyDocsZustandPage = lazyNamed(
() => import("@/pages/docs/zustand/page"),
"DocsZustandPage",
);
const LazyDocsMissionFlowPage = lazyNamed(
() => import("@/pages/docs/mission-flow/page"),
"DocsMissionFlowPage",
);
const LazyDocsFeaturesPage = lazyNamed(
() => import("@/pages/docs/features/page"),
"DocsFeaturesPage",
@@ -75,20 +87,40 @@ const LazyDocsAnimationPage = lazyNamed(
() => import("@/pages/docs/animation/page"),
"DocsAnimationPage",
);
const LazyDocsCodeReviewPage = lazyNamed(
() => import("@/pages/docs/code-review/page"),
"DocsCodeReviewPage",
);
const LazyDocsMissionFlowPage = lazyNamed(
() => import("@/pages/docs/mission-flow/page"),
"DocsMissionFlowPage",
);
const LazyDocsThreeDebuggingPage = lazyNamed(
() => import("@/pages/docs/three-debugging/page"),
"DocsThreeDebuggingPage",
);
export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
export const DocsArchitectureRoute = createDocsRoute(LazyDocsArchitecturePage);
export const DocsSceneRuntimeRoute = createDocsRoute(LazyDocsSceneRuntimePage);
export const DocsRepairGameRoute = createDocsRoute(LazyDocsRepairGamePage);
export const DocsInteractionRoute = createDocsRoute(LazyDocsInteractionPage);
export const DocsTargetArchitectureRoute = createDocsRoute(
LazyDocsTargetArchitecturePage,
);
export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage,
);
export const DocsAudioRoute = createDocsRoute(LazyDocsAudioPage);
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);
export const DocsMainFeatureRoute = createDocsRoute(LazyDocsMainFeaturePage);
export const DocsEditorRoute = createDocsRoute(LazyDocsEditorPage);
export const DocsAnimationRoute = createDocsRoute(LazyDocsAnimationPage);
export const DocsCodeReviewRoute = createDocsRoute(LazyDocsCodeReviewPage);
export const DocsMissionFlowRoute = createDocsRoute(LazyDocsMissionFlowPage);
export const DocsThreeDebuggingRoute = createDocsRoute(
LazyDocsThreeDebuggingPage,
);
+4 -1
View File
@@ -22,6 +22,7 @@ export function GameCinematics(): null {
const playedCinematicsRef = useRef(new Set<string>());
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
@@ -59,7 +60,9 @@ export function GameCinematics(): null {
useFrame(({ clock }) => {
if (!manifest) return;
const elapsedTime = clock.getElapsedTime();
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.cinematics.forEach((cinematic) => {
if (cinematic.timecode === undefined) return;
+4 -1
View File
@@ -12,6 +12,7 @@ export function GameDialogues(): null {
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
const playedDialoguesRef = useRef(new Set<string>());
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
const startedAtRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
@@ -38,7 +39,9 @@ export function GameDialogues(): null {
useFrame(({ clock }) => {
if (!manifest) return;
const elapsedTime = clock.getElapsedTime();
startedAtRef.current ??= clock.getElapsedTime();
const elapsedTime = clock.getElapsedTime() - startedAtRef.current;
manifest.dialogues.forEach((dialogue) => {
if (dialogue.timecode === undefined) return;
+83 -47
View File
@@ -1,5 +1,12 @@
import type { ReactNode } from "react";
import { Component, Suspense, useEffect, useState } from "react";
import {
Component,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { GameMapCollision } from "@/world/GameMapCollision";
@@ -20,6 +27,7 @@ interface ErrorBoundaryProps {
fallback: ReactNode;
modelUrl: string | null;
node: MapNode;
onSettled: () => void;
}
interface ErrorBoundaryState {
@@ -50,6 +58,7 @@ class ModelErrorBoundary extends Component<
},
error,
);
this.props.onSettled();
}
render(): ReactNode {
@@ -68,19 +77,39 @@ interface GameMapProps {
buildOctree?: boolean;
}
const MAP_RENDER_BATCH_SIZE = 12;
export function GameMap({
buildOctree = true,
onLoaded,
onLoadingStateChange,
onOctreeReady,
}: GameMapProps): React.JSX.Element {
const settledMapNodesRef = useRef(new Set<number>());
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [mapLoaded, setMapLoaded] = useState(false);
const [visibleNodeCount, setVisibleNodeCount] = useState(0);
const visibleMapNodes = mapNodes.slice(0, visibleNodeCount);
const mapReady = mapLoaded && visibleNodeCount >= mapNodes.length;
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
const mapReady = mapLoaded && settledMapNodeCount >= mapNodes.length;
const handleMapNodeSettled = useCallback((index: number) => {
if (settledMapNodesRef.current.has(index)) return;
settledMapNodesRef.current.add(index);
setSettledMapNodeCount(settledMapNodesRef.current.size);
}, []);
const showEmptyMap = useCallback(
(currentStep: string) => {
setMapNodes([]);
setMapLoaded(true);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({
currentStep,
progress: 0.7,
status: "loading",
});
},
[onLoadingStateChange],
);
useEffect(() => {
onLoadingStateChange?.({
@@ -94,11 +123,7 @@ export function GameMap({
const sceneData = await loadMapSceneData();
if (!sceneData) {
logger.warn("GameMap", "map.json not found");
onLoadingStateChange?.({
currentStep: "Map introuvable",
progress: 1,
status: "loading",
});
showEmptyMap("Map introuvable");
return;
}
@@ -128,9 +153,10 @@ export function GameMap({
setMapNodes(loadedMapNodes);
setMapLoaded(true);
setVisibleNodeCount(0);
settledMapNodesRef.current.clear();
setSettledMapNodeCount(0);
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
currentStep: "Chargement des modèles de la map",
progress: 0.25,
status: "loading",
});
@@ -138,63 +164,41 @@ export function GameMap({
logger.error("GameMap", "Error loading map", {
error: error instanceof Error ? error : new Error(String(error)),
});
onLoadingStateChange?.({
currentStep: "Erreur de chargement de la map",
progress: 1,
status: "loading",
});
showEmptyMap("Erreur de chargement de la map");
}
};
loadMap();
}, [onLoaded, onLoadingStateChange]);
useEffect(() => {
if (mapNodes.length === 0 || visibleNodeCount >= mapNodes.length) return;
const frameId = window.requestAnimationFrame(() => {
setVisibleNodeCount((current) =>
Math.min(current + MAP_RENDER_BATCH_SIZE, mapNodes.length),
);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [mapNodes.length, visibleNodeCount]);
}, [onLoadingStateChange, showEmptyMap]);
useEffect(() => {
if (mapNodes.length === 0) return;
const renderProgress =
mapNodes.length === 0 ? 1 : visibleNodeCount / mapNodes.length;
mapNodes.length === 0 ? 1 : settledMapNodeCount / mapNodes.length;
onLoadingStateChange?.({
currentStep: "Montage progressif des models",
currentStep: "Chargement des modèles de la map",
progress: 0.25 + renderProgress * 0.45,
status: "loading",
});
}, [mapNodes.length, onLoadingStateChange, visibleNodeCount]);
}, [mapNodes.length, onLoadingStateChange, settledMapNodeCount]);
return (
<>
<group>
{visibleMapNodes.map((mapNode, index) => (
{mapNodes.map((mapNode, index) => (
<ModelErrorBoundary
key={index}
fallback={<FallbackMapNode node={mapNode.node} />}
modelUrl={mapNode.modelUrl}
node={mapNode.node}
onSettled={() => handleMapNodeSettled(index)}
>
{mapNode.modelUrl ? (
<Suspense fallback={<FallbackMapNode node={mapNode.node} />}>
<ModelInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
/>
</Suspense>
) : (
<FallbackMapNode node={mapNode.node} />
)}
<MapNodeInstance
node={mapNode.node}
modelUrl={mapNode.modelUrl}
onSettled={() => handleMapNodeSettled(index)}
/>
</ModelErrorBoundary>
))}
</group>
@@ -210,12 +214,40 @@ export function GameMap({
);
}
function MapNodeInstance({
node,
modelUrl,
onSettled,
}: {
node: MapNode;
modelUrl: string | null;
onSettled: () => void;
}): React.JSX.Element {
useEffect(() => {
if (modelUrl !== null) return;
onSettled();
}, [modelUrl, onSettled]);
if (!modelUrl) {
return <FallbackMapNode node={node} />;
}
return (
<Suspense fallback={<FallbackMapNode node={node} />}>
<ModelInstance node={node} modelUrl={modelUrl} onLoaded={onSettled} />
</Suspense>
);
}
function ModelInstance({
node,
modelUrl,
onLoaded,
}: {
node: MapNode;
modelUrl: string;
onLoaded: () => void;
}): React.JSX.Element {
const { position, rotation, scale } = node;
const { scene } = useLoggedGLTF(modelUrl, {
@@ -226,6 +258,10 @@ function ModelInstance({
});
const sceneInstance = useClonedObject(scene);
useEffect(() => {
onLoaded();
}, [onLoaded]);
return (
<primitive
object={sceneInstance}
+18 -4
View File
@@ -102,11 +102,19 @@ export function GameMapCollision({
}: GameMapCollisionProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const collisionNodes = nodes.filter(isCollisionNode);
const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length;
const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return;
loadedNotifiedRef.current = true;
onLoaded?.();
}, [onLoaded]);
const handleCollisionNodeSettled = useCallback((index: number) => {
if (settledCollisionNodesRef.current.has(index)) return;
@@ -122,9 +130,9 @@ export function GameMapCollision({
status: "loading",
});
onOctreeReady(octree);
onLoaded?.();
notifyLoaded();
},
[onLoaded, onLoadingStateChange, onOctreeReady],
[notifyLoaded, onLoadingStateChange, onOctreeReady],
);
useOctreeGraphNode(
@@ -138,7 +146,12 @@ export function GameMapCollision({
if (!mapReady) return;
if (collisionNodes.length === 0) {
onLoaded?.();
notifyLoaded();
return;
}
if (collisionReady && !buildOctree) {
notifyLoaded();
return;
}
@@ -150,10 +163,11 @@ export function GameMapCollision({
status: "loading",
});
}, [
buildOctree,
collisionNodes.length,
collisionReady,
mapReady,
onLoaded,
notifyLoaded,
onLoadingStateChange,
]);
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
const GAME_MUSIC_VOLUME = 0.45;
const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null {
useEffect(() => {
+38 -27
View File
@@ -1,4 +1,4 @@
import { Suspense } from "react";
import { Suspense, useEffect } from "react";
import { Physics } from "@react-three/rapier";
import {
PLAYER_SPAWN_POSITION_GAME,
@@ -8,6 +8,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
import { useGameStore } from "@/managers/stores/useGameStore";
import { GameFlow } from "@/components/game/GameFlow";
import {
ZoneDebugVisuals,
@@ -33,23 +34,19 @@ interface WorldProps {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
}
function hasBootFlag(name: string): boolean {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has(name);
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const mainState = useGameStore((state) => state.mainState);
const { status, usageStatus } = useHandTrackingSnapshot();
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const noCinematics = hasBootFlag("noCinematics");
const noDialogues = hasBootFlag("noDialogues");
const noMap = hasBootFlag("noMap");
const noMusic = hasBootFlag("noMusic");
const noOctree = hasBootFlag("noOctree");
const noPlayer = hasBootFlag("noPlayer");
const {
octree,
gameplayReady,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
@@ -57,6 +54,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
const showHandTrackingGloves =
sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive");
const spawnPlayer =
cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null);
return (
<>
@@ -77,30 +77,41 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
<ZoneDebugVisuals />
<NPCHelper position={[1, 12, -55]} />
<PyloneDestroyed position={[1, 15, -45]} />
{noMusic ? null : <GameMusic />}
{noCinematics ? null : <GameCinematics />}
{noDialogues ? null : <GameDialogues />}
{noMap ? null : (
<GameMap
buildOctree={!noOctree}
onLoaded={handleGameMapLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
)}
{noMap || showGameStage ? (
<GameMap
onLoaded={handleGameMapLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={handleOctreeReady}
/>
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent />
</Physics>
) : null}
{spawnPlayer ? (
<>
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
<GameDialogues />
<Player octree={octree} spawnPosition={playerSpawnPosition} />
</>
) : null}
</>
) : (
<TestMap onOctreeReady={handleOctreeReady} />
)}
{cameraMode !== "debug" && !noPlayer ? (
{sceneMode !== "game" && spawnPlayer ? (
<Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null}
</>
);
}
function GameStageLoaded({ onLoaded }: { onLoaded: () => void }): null {
useEffect(() => {
onLoaded();
}, [onLoaded]);
return null;
}
+1 -1
View File
@@ -154,7 +154,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
<AnimatedModel
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
defaultAnimation="Idle"
defaultAnimation="Dance"
position={[0, 0, -5]}
scale={1}
/>
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Vector3Tuple } from "@/types/three/three";
@@ -16,7 +16,7 @@ export function Player({
}: PlayerProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
useEffect(() => {
useLayoutEffect(() => {
camera.position.set(...spawnPosition);
}, [camera, spawnPosition]);
+19 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { Capsule } from "three/addons/math/Capsule.js";
@@ -57,6 +57,18 @@ const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3();
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
return new Capsule(
new THREE.Vector3(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
spawnPosition[2],
),
new THREE.Vector3(...spawnPosition),
PLAYER_CAPSULE_RADIUS,
);
}
function isPlayerInputLocked(): boolean {
return (
useSettingsStore.getState().isSettingsMenuOpen ||
@@ -94,17 +106,11 @@ export function PlayerController({
const velocity = useRef(new THREE.Vector3());
const onFloor = useRef(false);
const wantsJump = useRef(false);
const canMove = useGameStore((state) => state.missionFlow.canMove);
const initializedRef = useRef(false); const canMove = useGameStore((state) => state.missionFlow.canMove);
const capsule = useRef(
new Capsule(
new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0),
new THREE.Vector3(0, PLAYER_EYE_HEIGHT - PLAYER_CAPSULE_RADIUS, 0),
PLAYER_CAPSULE_RADIUS,
),
);
const capsule = useRef(createSpawnCapsule(spawnPosition));
useEffect(() => {
useLayoutEffect(() => {
capsule.current.start.set(
spawnPosition[0],
spawnPosition[1] - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS,
@@ -115,6 +121,7 @@ export function PlayerController({
onFloor.current = false;
wantsJump.current = false;
camera.position.copy(capsule.current.end);
initializedRef.current = true;
}, [camera, spawnPosition]);
useEffect(() => {
@@ -201,6 +208,8 @@ export function PlayerController({
}, []);
useFrame((_, delta) => {
if (!initializedRef.current) return;
if (isPlayerInputLocked() || !canMove) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);