add: cinematic preview
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
CinematicCameraKeyframe,
|
CinematicCameraKeyframe,
|
||||||
CinematicDefinition,
|
CinematicDefinition,
|
||||||
@@ -124,7 +124,13 @@ function updateVector(
|
|||||||
return nextVector;
|
return nextVector;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorCinematicManifestPanel(): React.JSX.Element {
|
interface EditorCinematicManifestPanelProps {
|
||||||
|
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorCinematicManifestPanel({
|
||||||
|
onPreviewCinematic,
|
||||||
|
}: EditorCinematicManifestPanelProps): React.JSX.Element {
|
||||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
const [selectedCinematicId, setSelectedCinematicId] = useState("");
|
const [selectedCinematicId, setSelectedCinematicId] = useState("");
|
||||||
const [status, setStatus] = useState("Chargement des cinematics...");
|
const [status, setStatus] = useState("Chargement des cinematics...");
|
||||||
@@ -431,6 +437,16 @@ export function EditorCinematicManifestPanel(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-cinematic-manifest-preview"
|
||||||
|
type="button"
|
||||||
|
disabled={errors.length > 0 || !onPreviewCinematic}
|
||||||
|
onClick={() => onPreviewCinematic?.(selectedCinematic)}
|
||||||
|
>
|
||||||
|
<Play size={14} aria-hidden="true" />
|
||||||
|
Preview cinematic
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="editor-cinematic-manifest-delete"
|
className="editor-cinematic-manifest-delete"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
|
|
||||||
interface EditorControlsProps {
|
interface EditorControlsProps {
|
||||||
@@ -31,6 +32,7 @@ interface EditorControlsProps {
|
|||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||||
onPlayerMode?: (() => void) | undefined;
|
onPlayerMode?: (() => void) | undefined;
|
||||||
|
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export function EditorControls({
|
|||||||
onExportJson,
|
onExportJson,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
onPlayerMode,
|
onPlayerMode,
|
||||||
|
onPreviewCinematic,
|
||||||
isPlayerMode,
|
isPlayerMode,
|
||||||
}: EditorControlsProps): React.JSX.Element {
|
}: EditorControlsProps): React.JSX.Element {
|
||||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||||
@@ -240,7 +243,7 @@ export function EditorControls({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<EditorCinematicManifestPanel />
|
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||||
<EditorDialogueManifestPanel />
|
<EditorDialogueManifestPanel />
|
||||||
<EditorSrtPanel />
|
<EditorSrtPanel />
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import * as THREE from "three";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||||
|
|
||||||
|
export interface EditorCinematicPreviewRequest {
|
||||||
|
id: string;
|
||||||
|
cinematic: CinematicDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorSceneProps {
|
interface EditorSceneProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
@@ -18,6 +27,8 @@ interface EditorSceneProps {
|
|||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
|
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||||
|
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorScene({
|
export function EditorScene({
|
||||||
@@ -34,7 +45,11 @@ export function EditorScene({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
isPlayerMode = false,
|
isPlayerMode = false,
|
||||||
|
cinematicPreviewRequest = null,
|
||||||
|
onCinematicPreviewComplete,
|
||||||
}: EditorSceneProps): React.JSX.Element {
|
}: EditorSceneProps): React.JSX.Element {
|
||||||
|
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -74,10 +89,16 @@ export function EditorScene({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EditorCinematicPreviewPlayer
|
||||||
|
request={cinematicPreviewRequest}
|
||||||
|
onComplete={onCinematicPreviewComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
{isPlayerMode ? (
|
{isPlayerMode ? (
|
||||||
<FlyController disabled={false} />
|
<FlyController disabled={isCinematicPreviewing} />
|
||||||
) : (
|
) : (
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
enabled={!isCinematicPreviewing}
|
||||||
enableDamping
|
enableDamping
|
||||||
dampingFactor={0.05}
|
dampingFactor={0.05}
|
||||||
mouseButtons={{
|
mouseButtons={{
|
||||||
@@ -106,3 +127,76 @@ export function EditorScene({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorCinematicPreviewPlayerProps {
|
||||||
|
request: EditorCinematicPreviewRequest | null;
|
||||||
|
onComplete?: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorCinematicPreviewPlayer({
|
||||||
|
request,
|
||||||
|
onComplete,
|
||||||
|
}: EditorCinematicPreviewPlayerProps): null {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timelineRef.current?.kill();
|
||||||
|
timelineRef.current = null;
|
||||||
|
|
||||||
|
if (!request) return undefined;
|
||||||
|
|
||||||
|
const firstKeyframe = request.cinematic.cameraKeyframes[0];
|
||||||
|
if (!firstKeyframe) return undefined;
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...firstKeyframe.target);
|
||||||
|
camera.position.set(...firstKeyframe.position);
|
||||||
|
camera.lookAt(target);
|
||||||
|
|
||||||
|
const timeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
timelineRef.current = null;
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
|
||||||
|
const previousKeyframe = request.cinematic.cameraKeyframes[index];
|
||||||
|
if (!previousKeyframe) return;
|
||||||
|
|
||||||
|
const duration = keyframe.time - previousKeyframe.time;
|
||||||
|
timeline.to(
|
||||||
|
camera.position,
|
||||||
|
{
|
||||||
|
x: keyframe.position[0],
|
||||||
|
y: keyframe.position[1],
|
||||||
|
z: keyframe.position[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
timeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: keyframe.target[0],
|
||||||
|
y: keyframe.target[1],
|
||||||
|
z: keyframe.target[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timeline.kill();
|
||||||
|
if (timelineRef.current === timeline) timelineRef.current = null;
|
||||||
|
};
|
||||||
|
}, [camera, onComplete, request]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1863,6 +1863,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-cinematic-manifest-actions button,
|
.editor-cinematic-manifest-actions button,
|
||||||
|
.editor-cinematic-manifest-preview,
|
||||||
.editor-cinematic-manifest-delete,
|
.editor-cinematic-manifest-delete,
|
||||||
.editor-cinematic-keyframes-heading button,
|
.editor-cinematic-keyframes-heading button,
|
||||||
.editor-cinematic-keyframe-heading button {
|
.editor-cinematic-keyframe-heading button {
|
||||||
@@ -1881,6 +1882,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-cinematic-manifest-actions button:hover,
|
.editor-cinematic-manifest-actions button:hover,
|
||||||
|
.editor-cinematic-manifest-preview:hover,
|
||||||
.editor-cinematic-manifest-delete:hover,
|
.editor-cinematic-manifest-delete:hover,
|
||||||
.editor-cinematic-keyframes-heading button:hover,
|
.editor-cinematic-keyframes-heading button:hover,
|
||||||
.editor-cinematic-keyframe-heading button:hover {
|
.editor-cinematic-keyframe-heading button:hover {
|
||||||
@@ -1889,6 +1891,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-cinematic-manifest-actions button:disabled,
|
.editor-cinematic-manifest-actions button:disabled,
|
||||||
|
.editor-cinematic-manifest-preview:disabled,
|
||||||
.editor-cinematic-keyframe-heading button:disabled {
|
.editor-cinematic-keyframe-heading button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
@@ -1988,6 +1991,11 @@ canvas {
|
|||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-preview {
|
||||||
|
border-color: rgba(125, 211, 252, 0.24);
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-cinematic-keyframe-heading button {
|
.editor-cinematic-keyframe-heading button {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useCallback, useState } from "react";
|
|||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { EditorControls } from "@/components/editor/EditorControls";
|
import { EditorControls } from "@/components/editor/EditorControls";
|
||||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||||
|
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const [transformMode, setTransformMode] =
|
const [transformMode, setTransformMode] =
|
||||||
useState<TransformMode>("translate");
|
useState<TransformMode>("translate");
|
||||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||||
|
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
||||||
|
useState<EditorCinematicPreviewRequest | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
undoCount,
|
undoCount,
|
||||||
@@ -89,6 +93,20 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setIsPlayerMode((prev) => !prev);
|
setIsPlayerMode((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePreviewCinematic = useCallback(
|
||||||
|
(cinematic: CinematicDefinition) => {
|
||||||
|
setCinematicPreviewRequest({
|
||||||
|
id: window.crypto.randomUUID(),
|
||||||
|
cinematic,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCinematicPreviewComplete = useCallback(() => {
|
||||||
|
setCinematicPreviewRequest(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNodeTransform = useCallback(
|
const handleNodeTransform = useCallback(
|
||||||
(nodeIndex: number, updatedNode: MapNode) => {
|
(nodeIndex: number, updatedNode: MapNode) => {
|
||||||
setSceneData((prev) => {
|
setSceneData((prev) => {
|
||||||
@@ -172,6 +190,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
|
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||||
|
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
@@ -194,6 +214,7 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onExportJson={handleExportJson}
|
onExportJson={handleExportJson}
|
||||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||||
onPlayerMode={handlePlayerMode}
|
onPlayerMode={handlePlayerMode}
|
||||||
|
onPreviewCinematic={handlePreviewCinematic}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user