merge mission & intro
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={animation}
|
||||
frContent={animation}
|
||||
meta="08"
|
||||
meta="15"
|
||||
title="Animation & 3D Model System"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={handTracking}
|
||||
frContent={handTracking}
|
||||
meta="05"
|
||||
meta="09"
|
||||
title="Hand Tracking Technical Notes"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={mainFeature}
|
||||
frContent={mainFeature}
|
||||
meta="07"
|
||||
meta="13"
|
||||
title="Main Feature"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsTechnicalEditorPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={technicalEditor}
|
||||
frContent={technicalEditor}
|
||||
meta="04"
|
||||
meta="07"
|
||||
title="Editor Technical Notes"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user