Merge branch 'develop' into feat/repair-game
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CinematicCameraKeyframe,
|
||||
CinematicDefinition,
|
||||
CinematicDialogueCue,
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
|
||||
type CinematicPatch = Partial<Omit<CinematicDefinition, "timecode">> & {
|
||||
timecode?: number | undefined;
|
||||
};
|
||||
|
||||
type VectorAxis = 0 | 1 | 2;
|
||||
const VECTOR_AXES: { label: "X" | "Y" | "Z"; axis: VectorAxis }[] = [
|
||||
{ label: "X", axis: 0 },
|
||||
{ label: "Y", axis: 1 },
|
||||
{ label: "Z", axis: 2 },
|
||||
];
|
||||
|
||||
function createCinematic(index: number): CinematicDefinition {
|
||||
return {
|
||||
id: `new_cinematic_${index}`,
|
||||
cameraKeyframes: [
|
||||
{ time: 0, position: [0, 3, 8], target: [0, 1.5, 0] },
|
||||
{ time: 3, position: [6, 3, 8], target: [0, 1.5, 0] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createKeyframe(
|
||||
previousKeyframe: CinematicCameraKeyframe,
|
||||
): CinematicCameraKeyframe {
|
||||
return {
|
||||
time: previousKeyframe.time + 3,
|
||||
position: [...previousKeyframe.position],
|
||||
target: [...previousKeyframe.target],
|
||||
};
|
||||
}
|
||||
|
||||
function createDialogueCue(
|
||||
dialogues: DialogueDefinition[],
|
||||
previousCue: CinematicDialogueCue | null,
|
||||
): CinematicDialogueCue {
|
||||
return {
|
||||
time: previousCue ? previousCue.time + 1 : 0,
|
||||
dialogueId: dialogues[0]?.id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function getManifestErrors(
|
||||
manifest: CinematicManifest | null,
|
||||
dialogueIds: Set<string>,
|
||||
): string[] {
|
||||
if (!manifest) return ["Manifeste absent."];
|
||||
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
manifest.cinematics.forEach((cinematic, cinematicIndex) => {
|
||||
const label = cinematic.id || `Cinematique ${cinematicIndex + 1}`;
|
||||
|
||||
if (!cinematic.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||
if (ids.has(cinematic.id)) errors.push(`${label}: id duplique.`);
|
||||
ids.add(cinematic.id);
|
||||
|
||||
if (
|
||||
cinematic.timecode !== undefined &&
|
||||
(!Number.isFinite(cinematic.timecode) || cinematic.timecode < 0)
|
||||
) {
|
||||
errors.push(`${label}: timecode invalide.`);
|
||||
}
|
||||
|
||||
if (cinematic.cameraKeyframes.length < 2) {
|
||||
errors.push(`${label}: au moins deux keyframes camera sont requises.`);
|
||||
}
|
||||
|
||||
cinematic.cameraKeyframes.forEach((keyframe, keyframeIndex) => {
|
||||
const previousKeyframe = cinematic.cameraKeyframes[keyframeIndex - 1];
|
||||
|
||||
if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
|
||||
errors.push(`${label}: keyframe ${keyframeIndex + 1} time invalide.`);
|
||||
}
|
||||
|
||||
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||
errors.push(`${label}: les temps des keyframes doivent augmenter.`);
|
||||
}
|
||||
});
|
||||
|
||||
cinematic.dialogueCues?.forEach((cue, cueIndex) => {
|
||||
if (!Number.isFinite(cue.time) || cue.time < 0) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} time invalide.`);
|
||||
}
|
||||
|
||||
if (!cue.dialogueId.trim()) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} id obligatoire.`);
|
||||
} else if (dialogueIds.size > 0 && !dialogueIds.has(cue.dialogueId)) {
|
||||
errors.push(`${label}: dialogue cue ${cueIndex + 1} dialogue inconnu.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function saveCinematicManifest(
|
||||
manifest: CinematicManifest,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-cinematics", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde des cinematics impossible");
|
||||
}
|
||||
}
|
||||
|
||||
function getPatchedCinematic(
|
||||
cinematic: CinematicDefinition,
|
||||
patch: CinematicPatch,
|
||||
): CinematicDefinition {
|
||||
const nextCinematic: CinematicDefinition = {
|
||||
id: patch.id ?? cinematic.id,
|
||||
cameraKeyframes: patch.cameraKeyframes ?? cinematic.cameraKeyframes,
|
||||
};
|
||||
|
||||
const dialogueCues = patch.dialogueCues ?? cinematic.dialogueCues;
|
||||
if (dialogueCues) {
|
||||
nextCinematic.dialogueCues = dialogueCues;
|
||||
}
|
||||
|
||||
if ("timecode" in patch) {
|
||||
if (patch.timecode !== undefined) nextCinematic.timecode = patch.timecode;
|
||||
} else if (cinematic.timecode !== undefined) {
|
||||
nextCinematic.timecode = cinematic.timecode;
|
||||
}
|
||||
|
||||
return nextCinematic;
|
||||
}
|
||||
|
||||
function updateVector(
|
||||
vector: Vector3Tuple,
|
||||
axis: VectorAxis,
|
||||
value: number,
|
||||
): Vector3Tuple {
|
||||
const nextVector: Vector3Tuple = [...vector];
|
||||
nextVector[axis] = value;
|
||||
return nextVector;
|
||||
}
|
||||
|
||||
interface EditorCinematicManifestPanelProps {
|
||||
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||
}
|
||||
|
||||
export function EditorCinematicManifestPanel({
|
||||
onPreviewCinematic,
|
||||
}: EditorCinematicManifestPanelProps): React.JSX.Element {
|
||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
const [selectedCinematicId, setSelectedCinematicId] = useState("");
|
||||
const [status, setStatus] = useState("Chargement des cinematics...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dialogueIds = new Set(
|
||||
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
|
||||
);
|
||||
const errors = getManifestErrors(manifest, dialogueIds);
|
||||
const selectedCinematic =
|
||||
manifest?.cinematics.find(
|
||||
(cinematic) => cinematic.id === selectedCinematicId,
|
||||
) ??
|
||||
manifest?.cinematics[0] ??
|
||||
null;
|
||||
|
||||
async function handleLoad(): Promise<void> {
|
||||
setStatus("Chargement des cinematics...");
|
||||
|
||||
try {
|
||||
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
|
||||
loadCinematicManifest(),
|
||||
loadDialogueManifest(),
|
||||
]);
|
||||
setManifest(loadedManifest);
|
||||
setDialogueManifest(loadedDialogueManifest);
|
||||
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||
: "Manifeste cinematics introuvable ou invalide.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde des cinematics...");
|
||||
|
||||
try {
|
||||
await saveCinematicManifest(manifest);
|
||||
setStatus("Manifeste sauvegarde dans public/cinematics.json.");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCinematic(): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const cinematic = createCinematic(manifest.cinematics.length + 1);
|
||||
setManifest({
|
||||
...manifest,
|
||||
cinematics: [...manifest.cinematics, cinematic],
|
||||
});
|
||||
setSelectedCinematicId(cinematic.id);
|
||||
setStatus("Nouvelle cinematic ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveCinematic(cinematicId: string): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const nextCinematics = manifest.cinematics.filter(
|
||||
(cinematic) => cinematic.id !== cinematicId,
|
||||
);
|
||||
setManifest({ ...manifest, cinematics: nextCinematics });
|
||||
setSelectedCinematicId(nextCinematics[0]?.id ?? "");
|
||||
setStatus("Cinematic supprimee localement.");
|
||||
}
|
||||
|
||||
function updateSelectedCinematic(
|
||||
patch: CinematicPatch,
|
||||
nextId = selectedCinematicId,
|
||||
): void {
|
||||
if (!manifest || !selectedCinematic) return;
|
||||
|
||||
setManifest({
|
||||
...manifest,
|
||||
cinematics: manifest.cinematics.map((cinematic) =>
|
||||
cinematic.id === selectedCinematic.id
|
||||
? getPatchedCinematic(cinematic, patch)
|
||||
: cinematic,
|
||||
),
|
||||
});
|
||||
setSelectedCinematicId(nextId);
|
||||
}
|
||||
|
||||
function updateKeyframe(
|
||||
keyframeIndex: number,
|
||||
patch: Partial<CinematicCameraKeyframe>,
|
||||
): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: selectedCinematic.cameraKeyframes.map(
|
||||
(keyframe, index) =>
|
||||
index === keyframeIndex ? { ...keyframe, ...patch } : keyframe,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddKeyframe(): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const previousKeyframe =
|
||||
selectedCinematic.cameraKeyframes[
|
||||
selectedCinematic.cameraKeyframes.length - 1
|
||||
];
|
||||
if (!previousKeyframe) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: [
|
||||
...selectedCinematic.cameraKeyframes,
|
||||
createKeyframe(previousKeyframe),
|
||||
],
|
||||
});
|
||||
setStatus("Keyframe ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveKeyframe(keyframeIndex: number): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
cameraKeyframes: selectedCinematic.cameraKeyframes.filter(
|
||||
(_keyframe, index) => index !== keyframeIndex,
|
||||
),
|
||||
});
|
||||
setStatus("Keyframe supprimee localement.");
|
||||
}
|
||||
|
||||
function updateDialogueCue(
|
||||
cueIndex: number,
|
||||
patch: Partial<CinematicDialogueCue>,
|
||||
): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: dialogueCues.map((cue, index) =>
|
||||
index === cueIndex ? { ...cue, ...patch } : cue,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddDialogueCue(): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||
const previousCue = dialogueCues[dialogueCues.length - 1] ?? null;
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: [
|
||||
...dialogueCues,
|
||||
createDialogueCue(dialogueManifest?.dialogues ?? [], previousCue),
|
||||
],
|
||||
});
|
||||
setStatus("Dialogue cue ajoutee localement.");
|
||||
}
|
||||
|
||||
function handleRemoveDialogueCue(cueIndex: number): void {
|
||||
if (!selectedCinematic) return;
|
||||
|
||||
updateSelectedCinematic({
|
||||
dialogueCues: (selectedCinematic.dialogueCues ?? []).filter(
|
||||
(_cue, index) => index !== cueIndex,
|
||||
),
|
||||
});
|
||||
setStatus("Dialogue cue supprimee localement.");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
|
||||
.then(([loadedManifest, loadedDialogueManifest]) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setDialogueManifest(loadedDialogueManifest);
|
||||
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||
: "Manifeste cinematics introuvable ou invalide.",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="editor-cinematic-manifest-section"
|
||||
aria-labelledby="cinematic-manifest-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="cinematic-manifest-heading">Cinematics</h3>
|
||||
<span>{manifest?.cinematics.length ?? 0} items</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-cinematic-manifest-actions">
|
||||
<button type="button" onClick={() => void handleLoad()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Reload
|
||||
</button>
|
||||
<button type="button" disabled={!manifest} onClick={handleAddCinematic}>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || errors.length > 0 || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{manifest && (
|
||||
<label className="editor-cinematic-manifest-select">
|
||||
Cinematic
|
||||
<select
|
||||
value={selectedCinematic?.id ?? ""}
|
||||
onChange={(event) => setSelectedCinematicId(event.target.value)}
|
||||
>
|
||||
{manifest.cinematics.map((cinematic) => (
|
||||
<option key={cinematic.id} value={cinematic.id}>
|
||||
{cinematic.id || "Cinematic sans id"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedCinematic && (
|
||||
<div className="editor-cinematic-manifest-form">
|
||||
<label>
|
||||
ID
|
||||
<input
|
||||
value={selectedCinematic.id}
|
||||
onChange={(event) =>
|
||||
updateSelectedCinematic(
|
||||
{ id: event.target.value },
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Timecode global optionnel
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={selectedCinematic.timecode ?? ""}
|
||||
placeholder="Aucun"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
updateSelectedCinematic({
|
||||
timecode: value === "" ? undefined : Number(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="editor-cinematic-keyframes">
|
||||
<div className="editor-cinematic-keyframes-heading">
|
||||
<strong>Camera keyframes</strong>
|
||||
<button type="button" onClick={handleAddKeyframe}>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
Add keyframe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedCinematic.cameraKeyframes.map(
|
||||
(keyframe, keyframeIndex) => (
|
||||
<div
|
||||
className="editor-cinematic-keyframe"
|
||||
key={`${selectedCinematic.id}-${keyframeIndex}`}
|
||||
>
|
||||
<div className="editor-cinematic-keyframe-heading">
|
||||
<strong>Keyframe {keyframeIndex + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedCinematic.cameraKeyframes.length <= 2}
|
||||
onClick={() => handleRemoveKeyframe(keyframeIndex)}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Time
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={keyframe.time}
|
||||
onChange={(event) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
time: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<VectorInputs
|
||||
label="Position"
|
||||
value={keyframe.position}
|
||||
onChange={(axis, value) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
position: updateVector(keyframe.position, axis, value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<VectorInputs
|
||||
label="Target"
|
||||
value={keyframe.target}
|
||||
onChange={(axis, value) =>
|
||||
updateKeyframe(keyframeIndex, {
|
||||
target: updateVector(keyframe.target, axis, value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="editor-cinematic-dialogue-cues">
|
||||
<div className="editor-cinematic-dialogue-cues-heading">
|
||||
<strong>Dialogue cues</strong>
|
||||
<button type="button" onClick={handleAddDialogueCue}>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
Add dialogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(selectedCinematic.dialogueCues ?? []).length === 0 ? (
|
||||
<p>Aucun dialogue synchronise avec cette cinematic.</p>
|
||||
) : (
|
||||
(selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => (
|
||||
<div
|
||||
className="editor-cinematic-dialogue-cue"
|
||||
key={`${selectedCinematic.id}-dialogue-${cueIndex}`}
|
||||
>
|
||||
<div className="editor-cinematic-dialogue-cue-heading">
|
||||
<strong>Dialogue {cueIndex + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveDialogueCue(cueIndex)}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Time
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={cue.time}
|
||||
onChange={(event) =>
|
||||
updateDialogueCue(cueIndex, {
|
||||
time: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Dialogue
|
||||
<select
|
||||
value={cue.dialogueId}
|
||||
onChange={(event) =>
|
||||
updateDialogueCue(cueIndex, {
|
||||
dialogueId: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{dialogueManifest?.dialogues.length ? (
|
||||
dialogueManifest.dialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
{dialogue.id}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={cue.dialogueId}>
|
||||
{cue.dialogueId || "Aucun dialogue disponible"}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</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
|
||||
className="editor-cinematic-manifest-delete"
|
||||
type="button"
|
||||
onClick={() => handleRemoveCinematic(selectedCinematic.id)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete cinematic
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="editor-cinematic-manifest-status">{status}</p>
|
||||
<div
|
||||
className={`editor-cinematic-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{errors.length === 0
|
||||
? "Manifeste local valide."
|
||||
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||
</strong>
|
||||
{errors.length > 0 && (
|
||||
<ul>
|
||||
{errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface VectorInputsProps {
|
||||
label: string;
|
||||
value: Vector3Tuple;
|
||||
onChange: (axis: VectorAxis, value: number) => void;
|
||||
}
|
||||
|
||||
function VectorInputs({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: VectorInputsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="editor-cinematic-vector-inputs">
|
||||
<span>{label}</span>
|
||||
{VECTOR_AXES.map(({ label: axisLabel, axis }) => (
|
||||
<label key={axisLabel}>
|
||||
{axisLabel}
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={value[axis]}
|
||||
onChange={(event) => onChange(axis, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Save,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||
|
||||
interface EditorControlsProps {
|
||||
@@ -28,6 +32,7 @@ interface EditorControlsProps {
|
||||
onExportJson: () => void;
|
||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||
onPlayerMode?: (() => void) | undefined;
|
||||
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||
isPlayerMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -59,6 +64,7 @@ export function EditorControls({
|
||||
onExportJson,
|
||||
onSaveToServer,
|
||||
onPlayerMode,
|
||||
onPreviewCinematic,
|
||||
isPlayerMode,
|
||||
}: EditorControlsProps): React.JSX.Element {
|
||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||
@@ -236,6 +242,10 @@ export function EditorControls({
|
||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||
<EditorDialogueManifestPanel />
|
||||
<EditorSrtPanel />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
|
||||
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
|
||||
timecode?: number | undefined;
|
||||
};
|
||||
|
||||
function createDialogue(
|
||||
index: number,
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueDefinition {
|
||||
return {
|
||||
id: `new_dialogue_${index}`,
|
||||
voice,
|
||||
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
|
||||
subtitleCueIndex: getNextCueIndex(manifest, voice),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextCueIndex(
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): number {
|
||||
const cueIndexes = manifest.dialogues
|
||||
.filter((dialogue) => dialogue.voice === voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex);
|
||||
|
||||
return Math.max(0, ...cueIndexes) + 1;
|
||||
}
|
||||
|
||||
function getVoiceSpeaker(
|
||||
manifest: DialogueManifest,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueSpeaker {
|
||||
return (
|
||||
manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur"
|
||||
);
|
||||
}
|
||||
|
||||
function getFrenchSrtPath(voice: DialogueVoiceId): string {
|
||||
return `/sounds/dialogue/subtitles/fr/${voice}.srt`;
|
||||
}
|
||||
|
||||
function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string {
|
||||
return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`;
|
||||
}
|
||||
|
||||
function appendSrtCueIfMissing(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
speaker: DialogueSpeaker,
|
||||
): string {
|
||||
const cues = parseSrt(content);
|
||||
if (cues.some((cue) => cue.index === cueIndex)) return content;
|
||||
|
||||
const trimmedContent = content.trim();
|
||||
const cueBlock = createSrtCueBlock(cueIndex, speaker);
|
||||
return trimmedContent
|
||||
? `${trimmedContent}\n\n${cueBlock}\n`
|
||||
: `${cueBlock}\n`;
|
||||
}
|
||||
|
||||
async function saveSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-srt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice, language: "fr", content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||
}
|
||||
}
|
||||
|
||||
async function createFrenchSrtCue(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
): Promise<void> {
|
||||
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||
const response = await fetch(srtPath);
|
||||
const content = response.ok ? await response.text() : "";
|
||||
const nextContent = appendSrtCueIfMissing(
|
||||
content,
|
||||
dialogue.subtitleCueIndex,
|
||||
getVoiceSpeaker(manifest, dialogue.voice),
|
||||
);
|
||||
|
||||
await saveSrtFile(dialogue.voice, nextContent);
|
||||
}
|
||||
|
||||
function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
||||
if (!manifest) return ["Manifeste absent."];
|
||||
|
||||
const errors: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
|
||||
manifest.dialogues.forEach((dialogue, index) => {
|
||||
const label = dialogue.id || `Dialogue ${index + 1}`;
|
||||
|
||||
if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||
if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`);
|
||||
ids.add(dialogue.id);
|
||||
|
||||
if (!dialogue.audio.startsWith("/sounds/dialogue/")) {
|
||||
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
|
||||
errors.push(`${label}: cue SRT invalide.`);
|
||||
}
|
||||
|
||||
if (
|
||||
dialogue.timecode !== undefined &&
|
||||
(!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0)
|
||||
) {
|
||||
errors.push(`${label}: timecode invalide.`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function saveDialogueManifest(manifest: DialogueManifest): Promise<void> {
|
||||
const response = await fetch("/api/save-dialogues", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde du manifeste impossible");
|
||||
}
|
||||
}
|
||||
|
||||
function getPatchedDialogue(
|
||||
dialogue: DialogueDefinition,
|
||||
patch: DialoguePatch,
|
||||
): DialogueDefinition {
|
||||
const nextDialogue: DialogueDefinition = {
|
||||
id: patch.id ?? dialogue.id,
|
||||
voice: patch.voice ?? dialogue.voice,
|
||||
audio: patch.audio ?? dialogue.audio,
|
||||
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
|
||||
};
|
||||
|
||||
if ("timecode" in patch) {
|
||||
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||
} else if (dialogue.timecode !== undefined) {
|
||||
nextDialogue.timecode = dialogue.timecode;
|
||||
}
|
||||
|
||||
return nextDialogue;
|
||||
}
|
||||
|
||||
export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||
const [status, setStatus] = useState("Chargement du manifeste...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||
const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false);
|
||||
const errors = getManifestErrors(manifest);
|
||||
const selectedDialogue =
|
||||
manifest?.dialogues.find(
|
||||
(dialogue) => dialogue.id === selectedDialogueId,
|
||||
) ??
|
||||
manifest?.dialogues[0] ??
|
||||
null;
|
||||
const voices = manifest?.voices ?? [];
|
||||
|
||||
async function handleLoad(): Promise<void> {
|
||||
setStatus("Chargement du manifeste...");
|
||||
|
||||
try {
|
||||
const loadedManifest = await loadDialogueManifest();
|
||||
setManifest(loadedManifest);
|
||||
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||
: "Manifeste introuvable ou invalide.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde du manifeste...");
|
||||
|
||||
try {
|
||||
await saveDialogueManifest(manifest);
|
||||
setStatus(
|
||||
"Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDialogue(): Promise<void> {
|
||||
if (!manifest) return;
|
||||
|
||||
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
|
||||
const dialogue = createDialogue(
|
||||
manifest.dialogues.length + 1,
|
||||
manifest,
|
||||
voice,
|
||||
);
|
||||
const nextManifest = {
|
||||
...manifest,
|
||||
dialogues: [...manifest.dialogues, dialogue],
|
||||
};
|
||||
|
||||
setManifest(nextManifest);
|
||||
setSelectedDialogueId(dialogue.id);
|
||||
setIsCreatingSrtCue(true);
|
||||
setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR...");
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(nextManifest, dialogue);
|
||||
setStatus(
|
||||
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(
|
||||
`Dialogue ajoute localement, mais cue FR non creee: ${message}`,
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingSrtCue(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveDialogue(dialogueId: string): void {
|
||||
if (!manifest) return;
|
||||
|
||||
const nextDialogues = manifest.dialogues.filter(
|
||||
(dialogue) => dialogue.id !== dialogueId,
|
||||
);
|
||||
setManifest({ ...manifest, dialogues: nextDialogues });
|
||||
setSelectedDialogueId(nextDialogues[0]?.id ?? "");
|
||||
setStatus("Dialogue supprime localement.");
|
||||
}
|
||||
|
||||
function updateSelectedDialogue(
|
||||
patch: DialoguePatch,
|
||||
nextId = selectedDialogueId,
|
||||
): void {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
|
||||
setManifest({
|
||||
...manifest,
|
||||
dialogues: manifest.dialogues.map((dialogue) =>
|
||||
dialogue.id === selectedDialogue.id
|
||||
? getPatchedDialogue(dialogue, patch)
|
||||
: dialogue,
|
||||
),
|
||||
});
|
||||
setSelectedDialogueId(nextId);
|
||||
}
|
||||
|
||||
async function handlePreviewDialogue(): Promise<void> {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
if (errors.length > 0) {
|
||||
setStatus("Corrige les erreurs avant de lancer la preview.");
|
||||
return;
|
||||
}
|
||||
|
||||
previewAudioRef.current?.pause();
|
||||
previewAudioRef.current = null;
|
||||
setIsPreviewing(true);
|
||||
setStatus(`Preview dialogue: ${selectedDialogue.id}`);
|
||||
|
||||
try {
|
||||
const audio = await playDialogueById(manifest, selectedDialogue.id);
|
||||
previewAudioRef.current = audio;
|
||||
|
||||
if (!audio) {
|
||||
setStatus("Dialogue introuvable pour la preview.");
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFinish = (): void => {
|
||||
audio.removeEventListener("ended", handleFinish);
|
||||
audio.removeEventListener("pause", handleFinish);
|
||||
if (previewAudioRef.current === audio) previewAudioRef.current = null;
|
||||
setIsPreviewing(false);
|
||||
};
|
||||
|
||||
audio.addEventListener("ended", handleFinish);
|
||||
audio.addEventListener("pause", handleFinish);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setIsPreviewing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||
if (!manifest || !selectedDialogue) return;
|
||||
|
||||
setIsCreatingSrtCue(true);
|
||||
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
||||
|
||||
try {
|
||||
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setIsCreatingSrtCue(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (!mounted) return;
|
||||
|
||||
setManifest(loadedManifest);
|
||||
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||
setStatus(
|
||||
loadedManifest
|
||||
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||
: "Manifeste introuvable ou invalide.",
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(message);
|
||||
setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
previewAudioRef.current?.pause();
|
||||
previewAudioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="editor-dialogue-manifest-section"
|
||||
aria-labelledby="dialogue-manifest-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="dialogue-manifest-heading">Dialogues</h3>
|
||||
<span>{manifest?.dialogues.length ?? 0} items</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-dialogue-manifest-actions">
|
||||
<button type="button" onClick={() => void handleLoad()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || isCreatingSrtCue}
|
||||
onClick={() => void handleAddDialogue()}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{isCreatingSrtCue ? "Adding..." : "Add"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!manifest || errors.length > 0 || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{manifest && (
|
||||
<label className="editor-dialogue-manifest-select">
|
||||
Dialogue
|
||||
<select
|
||||
value={selectedDialogue?.id ?? ""}
|
||||
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||
>
|
||||
{manifest.dialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
{dialogue.id || "Dialogue sans id"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedDialogue && (
|
||||
<div className="editor-dialogue-manifest-form">
|
||||
<label>
|
||||
ID
|
||||
<input
|
||||
value={selectedDialogue.id}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue(
|
||||
{ id: event.target.value },
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Voix
|
||||
<select
|
||||
value={selectedDialogue.voice}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({
|
||||
voice: event.target.value as DialogueVoiceId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{voices.map((voice) => (
|
||||
<option key={voice.id} value={voice.id}>
|
||||
{voice.speaker}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Audio
|
||||
<input
|
||||
value={selectedDialogue.audio}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({ audio: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Cue SRT
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={selectedDialogue.subtitleCueIndex}
|
||||
onChange={(event) =>
|
||||
updateSelectedDialogue({
|
||||
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Timecode global optionnel
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
value={selectedDialogue.timecode ?? ""}
|
||||
placeholder="Aucun"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value.trim();
|
||||
updateSelectedDialogue({
|
||||
timecode: value === "" ? undefined : Number(value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-srt-cue"
|
||||
type="button"
|
||||
disabled={isCreatingSrtCue}
|
||||
onClick={() => void handleCreateFrenchSrtCue()}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-preview"
|
||||
type="button"
|
||||
disabled={errors.length > 0 || isPreviewing}
|
||||
onClick={() => void handlePreviewDialogue()}
|
||||
>
|
||||
<Play size={14} aria-hidden="true" />
|
||||
{isPreviewing ? "Playing..." : "Preview dialogue"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="editor-dialogue-manifest-delete"
|
||||
type="button"
|
||||
onClick={() => handleRemoveDialogue(selectedDialogue.id)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete dialogue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="editor-dialogue-manifest-status">{status}</p>
|
||||
<div
|
||||
className={`editor-dialogue-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{errors.length === 0
|
||||
? "Manifeste local valide."
|
||||
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||
</strong>
|
||||
{errors.length > 0 && (
|
||||
<ul>
|
||||
{errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, RefreshCw, Save } from "lucide-react";
|
||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
interface SrtVoiceOption {
|
||||
id: DialogueVoiceId;
|
||||
label: DialogueSpeaker;
|
||||
}
|
||||
|
||||
interface SrtDiagnostic {
|
||||
cueCount: number;
|
||||
expectedCueCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface TextRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface DialogueValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type CueTimeEdge = "start" | "end";
|
||||
const CUE_NUDGE_SECONDS = 0.1;
|
||||
|
||||
const SRT_VOICES: SrtVoiceOption[] = [
|
||||
{ id: "narrateur", label: "Narrateur" },
|
||||
{ id: "fermier", label: "Fermier" },
|
||||
{ id: "electricienne", label: "Electricienne" },
|
||||
];
|
||||
const DEFAULT_SRT_VOICE: SrtVoiceOption = {
|
||||
id: "narrateur",
|
||||
label: "Narrateur",
|
||||
};
|
||||
|
||||
const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"];
|
||||
const SRT_TIME_LINE_PATTERN =
|
||||
/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/;
|
||||
|
||||
function getSrtPath(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
): string {
|
||||
return `/sounds/dialogue/subtitles/${language}/${voice}.srt`;
|
||||
}
|
||||
|
||||
function createSrtTemplate(
|
||||
speaker: DialogueSpeaker,
|
||||
expectedCueIndexes: number[],
|
||||
): string {
|
||||
const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1];
|
||||
|
||||
return `${cueIndexes
|
||||
.map((cueIndex, index) => {
|
||||
const startTime = index * 3;
|
||||
const endTime = startTime + 2;
|
||||
|
||||
return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`;
|
||||
})
|
||||
.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
function formatSrtTime(totalSeconds: number): string {
|
||||
const safeSeconds = Math.max(0, totalSeconds);
|
||||
const totalMilliseconds = Math.round(safeSeconds * 1000);
|
||||
const milliseconds = totalMilliseconds % 1000;
|
||||
const totalWholeSeconds = Math.floor(totalMilliseconds / 1000);
|
||||
const hours = Math.floor(totalWholeSeconds / 3600);
|
||||
const minutes = Math.floor((totalWholeSeconds % 3600) / 60);
|
||||
const seconds = totalWholeSeconds % 60;
|
||||
|
||||
return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`;
|
||||
}
|
||||
|
||||
function formatPreviewTime(totalSeconds: number): string {
|
||||
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function parseSrtTime(value: string): number | null {
|
||||
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, hours, minutes, seconds, milliseconds] = match;
|
||||
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||
|
||||
return (
|
||||
Number(hours) * 3600 +
|
||||
Number(minutes) * 60 +
|
||||
Number(seconds) +
|
||||
Number(milliseconds) / 1000
|
||||
);
|
||||
}
|
||||
|
||||
function padTime(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function padMilliseconds(value: number): string {
|
||||
return value.toString().padStart(3, "0");
|
||||
}
|
||||
|
||||
function getSrtDiagnostic(
|
||||
content: string,
|
||||
expectedCueIndexes: number[],
|
||||
): SrtDiagnostic {
|
||||
const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, "");
|
||||
const blocks = normalizedContent
|
||||
.trim()
|
||||
.split(/\n{2,}/)
|
||||
.filter(Boolean);
|
||||
const cues = parseSrt(content);
|
||||
const errors: string[] = [];
|
||||
const indexes = new Set<number>();
|
||||
|
||||
if (blocks.length === 0) {
|
||||
errors.push("Le fichier SRT est vide.");
|
||||
}
|
||||
|
||||
blocks.forEach((block, blockIndex) => {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const displayIndex = blockIndex + 1;
|
||||
const cueIndex = Number(lines[0]);
|
||||
|
||||
if (lines.length < 3) {
|
||||
errors.push(
|
||||
`Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(cueIndex)) {
|
||||
errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`);
|
||||
} else if (indexes.has(cueIndex)) {
|
||||
errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`);
|
||||
} else {
|
||||
indexes.add(cueIndex);
|
||||
}
|
||||
|
||||
if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) {
|
||||
errors.push(
|
||||
`Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (blocks.length > 0 && cues.length !== blocks.length) {
|
||||
errors.push(
|
||||
"Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.",
|
||||
);
|
||||
}
|
||||
|
||||
const cueIndexes = new Set(cues.map((cue) => cue.index));
|
||||
const missingCueIndexes = expectedCueIndexes.filter(
|
||||
(cueIndex) => !cueIndexes.has(cueIndex),
|
||||
);
|
||||
|
||||
if (missingCueIndexes.length > 0) {
|
||||
errors.push(
|
||||
`Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cueCount: cues.length,
|
||||
expectedCueCount: expectedCueIndexes.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function getExpectedCueIndexes(
|
||||
manifest: DialogueManifest | null,
|
||||
voice: DialogueVoiceId,
|
||||
): number[] {
|
||||
return getExpectedDialogues(manifest, voice)
|
||||
.map((dialogue) => dialogue.subtitleCueIndex)
|
||||
.filter(
|
||||
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function getExpectedDialogues(
|
||||
manifest: DialogueManifest | null,
|
||||
voice: DialogueVoiceId,
|
||||
): DialogueDefinition[] {
|
||||
if (!manifest) return [];
|
||||
|
||||
return [...manifest.dialogues]
|
||||
.filter((dialogue) => dialogue.voice === voice)
|
||||
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
|
||||
}
|
||||
|
||||
function findCueBlockRange(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
): TextRange | null {
|
||||
const normalizedContent = content.replace(/\r/g, "");
|
||||
const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m");
|
||||
const match = normalizedContent.match(cuePattern);
|
||||
|
||||
if (!match || match.index === undefined) return null;
|
||||
|
||||
const start = match.index + (match[1] ? 1 : 0);
|
||||
const nextBlockIndex = normalizedContent.indexOf("\n\n", start);
|
||||
const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function updateCueTimecode(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
edge: CueTimeEdge,
|
||||
time: number,
|
||||
): string | null {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
if (!range) return null;
|
||||
|
||||
const block = content.slice(range.start, range.end);
|
||||
const lines = block.split("\n");
|
||||
const timecodeLine = lines[1];
|
||||
if (!timecodeLine) return null;
|
||||
|
||||
const [start, end] = timecodeLine.split(" --> ");
|
||||
if (!start || !end) return null;
|
||||
|
||||
lines[1] =
|
||||
edge === "start"
|
||||
? `${formatSrtTime(time)} --> ${end}`
|
||||
: `${start} --> ${formatSrtTime(time)}`;
|
||||
|
||||
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||
}
|
||||
|
||||
function nudgeCueTimecode(
|
||||
content: string,
|
||||
cueIndex: number,
|
||||
delta: number,
|
||||
): string | null {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
if (!range) return null;
|
||||
|
||||
const block = content.slice(range.start, range.end);
|
||||
const lines = block.split("\n");
|
||||
const timecodeLine = lines[1];
|
||||
if (!timecodeLine) return null;
|
||||
|
||||
const [start, end] = timecodeLine.split(" --> ");
|
||||
if (!start || !end) return null;
|
||||
|
||||
const startTime = parseSrtTime(start);
|
||||
const endTime = parseSrtTime(end);
|
||||
if (startTime === null || endTime === null) return null;
|
||||
|
||||
const nextStartTime = Math.max(0, startTime + delta);
|
||||
const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta);
|
||||
lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`;
|
||||
|
||||
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||
}
|
||||
|
||||
function downloadSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
content: string,
|
||||
): void {
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${voice}.${language}.srt`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
async function saveSrtFile(
|
||||
voice: DialogueVoiceId,
|
||||
language: SubtitleLanguage,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch("/api/save-srt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice, language, content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
|
||||
const response = await fetch("/api/validate-dialogues");
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| Partial<DialogueValidationResult>
|
||||
| { error?: string }
|
||||
| null;
|
||||
|
||||
if (!body) {
|
||||
throw new Error("Validation dialogues impossible");
|
||||
}
|
||||
|
||||
if (
|
||||
"valid" in body &&
|
||||
typeof body.valid === "boolean" &&
|
||||
Array.isArray(body.errors) &&
|
||||
Array.isArray(body.warnings)
|
||||
) {
|
||||
return {
|
||||
valid: body.valid,
|
||||
errors: body.errors.filter((item) => typeof item === "string"),
|
||||
warnings: body.warnings.filter((item) => typeof item === "string"),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"error" in body && body.error
|
||||
? body.error
|
||||
: "Validation dialogues impossible",
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorSrtPanel(): React.JSX.Element {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [voice, setVoice] = useState<DialogueVoiceId>("narrateur");
|
||||
const [language, setLanguage] = useState<SubtitleLanguage>("fr");
|
||||
const [content, setContent] = useState("");
|
||||
const [status, setStatus] = useState("Chargement du SRT...");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isValidatingDialogues, setIsValidatingDialogues] = useState(false);
|
||||
const [dialogueValidationResult, setDialogueValidationResult] =
|
||||
useState<DialogueValidationResult | null>(null);
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||
const selectedVoice =
|
||||
SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE;
|
||||
const expectedDialogues = getExpectedDialogues(manifest, voice);
|
||||
const expectedCueIndexes = getExpectedCueIndexes(manifest, voice);
|
||||
const parsedCues = parseSrt(content);
|
||||
const activeCue =
|
||||
parsedCues.find(
|
||||
(cue) =>
|
||||
audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime,
|
||||
) ?? null;
|
||||
const diagnostic = getSrtDiagnostic(content, expectedCueIndexes);
|
||||
const isSrtValid = diagnostic.errors.length === 0;
|
||||
const dialogueValidationClass = dialogueValidationResult
|
||||
? dialogueValidationResult.valid
|
||||
? "is-valid"
|
||||
: "is-invalid"
|
||||
: "is-idle";
|
||||
const srtTemplate = createSrtTemplate(
|
||||
selectedVoice.label,
|
||||
expectedCueIndexes,
|
||||
);
|
||||
const selectedDialogue =
|
||||
expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ??
|
||||
expectedDialogues[0] ??
|
||||
null;
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!isSrtValid) {
|
||||
setStatus("Corrige les erreurs SRT avant de sauvegarder.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setStatus("Sauvegarde du SRT...");
|
||||
|
||||
try {
|
||||
await saveSrtFile(voice, language, content);
|
||||
setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidateDialogues(): Promise<void> {
|
||||
setIsValidatingDialogues(true);
|
||||
setDialogueValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await validateDialogueAssets();
|
||||
setDialogueValidationResult(result);
|
||||
setStatus(
|
||||
result.valid
|
||||
? "Validation dialogues terminee."
|
||||
: "Validation dialogues terminee avec erreurs.",
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
setStatus(`${message}. Verifie que le serveur Vite est lance.`);
|
||||
} finally {
|
||||
setIsValidatingDialogues(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpToCue(cueIndex: number): void {
|
||||
const range = findCueBlockRange(content, cueIndex);
|
||||
|
||||
if (!range || !textareaRef.current) {
|
||||
setStatus(`Cue ${cueIndex} introuvable dans le SRT.`);
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(range.start, range.end);
|
||||
setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`);
|
||||
}
|
||||
|
||||
function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void {
|
||||
const updatedContent = updateCueTimecode(
|
||||
content,
|
||||
cueIndex,
|
||||
edge,
|
||||
audioCurrentTime,
|
||||
);
|
||||
|
||||
if (!updatedContent) {
|
||||
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(updatedContent);
|
||||
setStatus(
|
||||
`Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleNudgeCue(cueIndex: number, delta: number): void {
|
||||
const updatedContent = nudgeCueTimecode(content, cueIndex, delta);
|
||||
|
||||
if (!updatedContent) {
|
||||
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(updatedContent);
|
||||
setStatus(
|
||||
`Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setManifest(loadedManifest);
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setManifest(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const srtPath = getSrtPath(voice, language);
|
||||
|
||||
void fetch(srtPath)
|
||||
.then(async (response) => {
|
||||
if (!mounted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setContent(srtTemplate);
|
||||
setStatus("Fichier absent, template local cree");
|
||||
return;
|
||||
}
|
||||
|
||||
setContent(await response.text());
|
||||
setStatus(`Charge depuis ${srtPath}`);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setContent(srtTemplate);
|
||||
setStatus("Erreur de chargement, template local cree");
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [language, selectedVoice.label, srtTemplate, voice]);
|
||||
|
||||
return (
|
||||
<section className="editor-srt-section" aria-labelledby="srt-heading">
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="srt-heading">SRT</h3>
|
||||
<span>{language.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-srt-controls">
|
||||
<label>
|
||||
Voix
|
||||
<select
|
||||
value={voice}
|
||||
onChange={(event) =>
|
||||
setVoice(event.target.value as DialogueVoiceId)
|
||||
}
|
||||
>
|
||||
{SRT_VOICES.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Langue
|
||||
<select
|
||||
value={language}
|
||||
onChange={(event) =>
|
||||
setLanguage(event.target.value as SubtitleLanguage)
|
||||
}
|
||||
>
|
||||
{SRT_LANGUAGES.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="editor-srt-preview">
|
||||
<label>
|
||||
Dialogue audio
|
||||
<select
|
||||
value={selectedDialogue?.id ?? ""}
|
||||
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||
disabled={expectedDialogues.length === 0}
|
||||
>
|
||||
{expectedDialogues.length === 0 && (
|
||||
<option value="">Aucun dialogue</option>
|
||||
)}
|
||||
{expectedDialogues.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedDialogue && (
|
||||
<div className="editor-srt-audio-card">
|
||||
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
||||
<strong>{selectedDialogue.id}</strong>
|
||||
<audio
|
||||
key={selectedDialogue.audio}
|
||||
controls
|
||||
src={selectedDialogue.audio}
|
||||
onLoadedMetadata={() => setAudioCurrentTime(0)}
|
||||
onTimeUpdate={(event) =>
|
||||
setAudioCurrentTime(event.currentTarget.currentTime)
|
||||
}
|
||||
/>
|
||||
<div className="editor-srt-active-cue">
|
||||
<span>Temps audio: {formatPreviewTime(audioCurrentTime)}</span>
|
||||
{activeCue ? (
|
||||
<p>
|
||||
<strong>Cue {activeCue.index}</strong> {activeCue.text}
|
||||
</p>
|
||||
) : (
|
||||
<p>Aucune cue active a ce moment.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="editor-srt-time-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
||||
}
|
||||
>
|
||||
Set start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
||||
}
|
||||
>
|
||||
Set end
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
-CUE_NUDGE_SECONDS,
|
||||
)
|
||||
}
|
||||
>
|
||||
-100ms
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleNudgeCue(
|
||||
selectedDialogue.subtitleCueIndex,
|
||||
CUE_NUDGE_SECONDS,
|
||||
)
|
||||
}
|
||||
>
|
||||
+100ms
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="editor-srt-jump-button"
|
||||
type="button"
|
||||
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
|
||||
>
|
||||
Aller a la cue {selectedDialogue.subtitleCueIndex}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-srt-textarea"
|
||||
value={content}
|
||||
spellCheck={false}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
aria-label="SRT content"
|
||||
/>
|
||||
|
||||
<div className="editor-srt-actions">
|
||||
<button
|
||||
className="editor-action-button"
|
||||
type="button"
|
||||
onClick={() => setContent(srtTemplate)}
|
||||
>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
Template
|
||||
</button>
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
type="button"
|
||||
disabled={isSaving || !isSrtValid}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
<Save size={15} aria-hidden="true" />
|
||||
{isSaving ? "Saving..." : "Save SRT"}
|
||||
</button>
|
||||
<button
|
||||
className="editor-action-button"
|
||||
type="button"
|
||||
onClick={() => downloadSrtFile(voice, language, content)}
|
||||
>
|
||||
<Download size={15} aria-hidden="true" />
|
||||
Export SRT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="editor-srt-status">{status}</p>
|
||||
<div className={`editor-dialogue-validation ${dialogueValidationClass}`}>
|
||||
<div className="editor-dialogue-validation__heading">
|
||||
<div>
|
||||
<strong>Manifeste dialogues</strong>
|
||||
<span>Audio, SRT FR et cues references</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isValidatingDialogues}
|
||||
onClick={() => void handleValidateDialogues()}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
{isValidatingDialogues ? "Validation..." : "Validate"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogueValidationResult && (
|
||||
<div className="editor-dialogue-validation__result">
|
||||
<p>
|
||||
{dialogueValidationResult.valid
|
||||
? "Manifeste valide."
|
||||
: `${dialogueValidationResult.errors.length} erreur${dialogueValidationResult.errors.length > 1 ? "s" : ""} detectee${dialogueValidationResult.errors.length > 1 ? "s" : ""}.`}
|
||||
{dialogueValidationResult.warnings.length > 0 &&
|
||||
` ${dialogueValidationResult.warnings.length} warning${dialogueValidationResult.warnings.length > 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
{dialogueValidationResult.errors.length > 0 && (
|
||||
<ul className="editor-dialogue-validation__errors">
|
||||
{dialogueValidationResult.errors.map((error, index) => (
|
||||
<li key={`${error}-${index}`}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{dialogueValidationResult.warnings.length > 0 && (
|
||||
<ul className="editor-dialogue-validation__warnings">
|
||||
{dialogueValidationResult.warnings.map((warning, index) => (
|
||||
<li key={`${warning}-${index}`}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`editor-srt-diagnostic ${isSrtValid ? "is-valid" : "is-invalid"}`}
|
||||
>
|
||||
<strong>
|
||||
{isSrtValid
|
||||
? `${diagnostic.cueCount} cue${diagnostic.cueCount > 1 ? "s" : ""} valide${diagnostic.cueCount > 1 ? "s" : ""} / ${diagnostic.expectedCueCount} attendue${diagnostic.expectedCueCount > 1 ? "s" : ""}`
|
||||
: `${diagnostic.errors.length} erreur${diagnostic.errors.length > 1 ? "s" : ""} SRT`}
|
||||
</strong>
|
||||
{!isSrtValid && (
|
||||
<ul>
|
||||
{diagnostic.errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
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 { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||
|
||||
export interface EditorCinematicPreviewRequest {
|
||||
id: string;
|
||||
cinematic: CinematicDefinition;
|
||||
}
|
||||
|
||||
interface EditorSceneProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
@@ -18,6 +27,8 @@ interface EditorSceneProps {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
isPlayerMode?: boolean;
|
||||
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function EditorScene({
|
||||
@@ -34,7 +45,11 @@ export function EditorScene({
|
||||
onUndo,
|
||||
onRedo,
|
||||
isPlayerMode = false,
|
||||
cinematicPreviewRequest = null,
|
||||
onCinematicPreviewComplete,
|
||||
}: EditorSceneProps): React.JSX.Element {
|
||||
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -74,10 +89,16 @@ export function EditorScene({
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorCinematicPreviewPlayer
|
||||
request={cinematicPreviewRequest}
|
||||
onComplete={onCinematicPreviewComplete}
|
||||
/>
|
||||
|
||||
{isPlayerMode ? (
|
||||
<FlyController disabled={false} />
|
||||
<FlyController disabled={isCinematicPreviewing} />
|
||||
) : (
|
||||
<OrbitControls
|
||||
enabled={!isCinematicPreviewing}
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ export function TriggerObject({
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
if (soundPath) {
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume);
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume, {
|
||||
category: "sfx",
|
||||
});
|
||||
}
|
||||
|
||||
onTrigger?.();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type {
|
||||
RepairRuntime,
|
||||
SubtitleLanguage,
|
||||
} from "@/managers/stores/useSettingsStore";
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function clearCookies(): void {
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const cookieName = cookie.split("=")[0]?.trim();
|
||||
if (!cookieName) return;
|
||||
|
||||
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
});
|
||||
}
|
||||
|
||||
interface VolumeSliderProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function VolumeSlider({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: VolumeSliderProps): React.JSX.Element {
|
||||
return (
|
||||
<label className="game-settings-menu__slider" htmlFor={id}>
|
||||
<span>
|
||||
{label}
|
||||
<strong>{formatPercent(value)}</strong>
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameSettingsMenu(): React.JSX.Element | null {
|
||||
const {
|
||||
isSettingsMenuOpen,
|
||||
musicVolume,
|
||||
sfxVolume,
|
||||
dialogueVolume,
|
||||
subtitlesEnabled,
|
||||
subtitleLanguage,
|
||||
repairRuntime,
|
||||
setMusicVolume,
|
||||
setSfxVolume,
|
||||
setDialogueVolume,
|
||||
setSettingsMenuOpen,
|
||||
setSubtitlesEnabled,
|
||||
setSubtitleLanguage,
|
||||
setRepairRuntime,
|
||||
} = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isSettingsMenuOpen) document.exitPointerLock();
|
||||
setSettingsMenuOpen(!isSettingsMenuOpen);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
|
||||
|
||||
if (!isSettingsMenuOpen) return null;
|
||||
|
||||
const handleQuit = (): void => {
|
||||
clearCookies();
|
||||
window.location.assign("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="game-settings-menu" role="dialog" aria-modal="true">
|
||||
<div className="game-settings-menu__panel">
|
||||
<header className="game-settings-menu__header">
|
||||
<div>
|
||||
<span>Pause</span>
|
||||
<h2>Options</h2>
|
||||
</div>
|
||||
<button
|
||||
className="game-settings-menu__close"
|
||||
type="button"
|
||||
onClick={() => setSettingsMenuOpen(false)}
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="audio-settings-heading"
|
||||
>
|
||||
<h3 id="audio-settings-heading">Audio</h3>
|
||||
<VolumeSlider
|
||||
id="music-volume"
|
||||
label="Musique"
|
||||
value={musicVolume}
|
||||
onChange={setMusicVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="sfx-volume"
|
||||
label="Sound effects"
|
||||
value={sfxVolume}
|
||||
onChange={setSfxVolume}
|
||||
/>
|
||||
<VolumeSlider
|
||||
id="dialogue-volume"
|
||||
label="Dialogue"
|
||||
value={dialogueVolume}
|
||||
onChange={setDialogueVolume}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="subtitle-settings-heading"
|
||||
>
|
||||
<h3 id="subtitle-settings-heading">Sous-titres</h3>
|
||||
<label className="game-settings-menu__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitlesEnabled}
|
||||
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
|
||||
/>
|
||||
Afficher sous-titres
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="game-settings-menu__choice-group"
|
||||
aria-label="Langue des sous-titres"
|
||||
>
|
||||
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
|
||||
<button
|
||||
key={language}
|
||||
type="button"
|
||||
className={subtitleLanguage === language ? "active" : undefined}
|
||||
onClick={() => setSubtitleLanguage(language)}
|
||||
aria-pressed={subtitleLanguage === language}
|
||||
>
|
||||
{language === "fr" ? "Francais" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="game-settings-menu__section"
|
||||
aria-labelledby="repair-settings-heading"
|
||||
>
|
||||
<h3 id="repair-settings-heading">Repair game</h3>
|
||||
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
|
||||
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
|
||||
<button
|
||||
key={runtime}
|
||||
type="button"
|
||||
className={repairRuntime === runtime ? "active" : undefined}
|
||||
onClick={() => setRepairRuntime(runtime)}
|
||||
aria-pressed={repairRuntime === runtime}
|
||||
>
|
||||
{runtime === "js"
|
||||
? "Repair game en JS (local)"
|
||||
: "Repair game en Python (server)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="game-settings-menu__quit"
|
||||
type="button"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
Quitter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
return (
|
||||
@@ -12,6 +14,8 @@ export function GameUI(): React.JSX.Element {
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
<GameSettingsMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||
|
||||
export type SubtitleSpeaker = DialogueSpeaker;
|
||||
|
||||
interface SubtitlesProps {
|
||||
speaker?: SubtitleSpeaker | null;
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
export function Subtitles({
|
||||
speaker = null,
|
||||
text = null,
|
||||
}: SubtitlesProps): React.JSX.Element | null {
|
||||
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
|
||||
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
|
||||
const content = (text ?? activeSubtitle?.text)?.trim();
|
||||
|
||||
if (!subtitlesEnabled || !content) return null;
|
||||
|
||||
return (
|
||||
<div className="subtitles" aria-live="polite">
|
||||
<p>
|
||||
{subtitleSpeaker ? (
|
||||
<span
|
||||
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
|
||||
>
|
||||
{subtitleSpeaker}:
|
||||
</span>
|
||||
) : null}
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,8 +124,44 @@ Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il
|
||||
|
||||
## Audio
|
||||
|
||||
- \`src/managers/AudioManager.ts\` fournit actuellement une lecture de sons one-shot avec pool.
|
||||
- Les interactions trigger peuvent lancer directement un son via \`AudioManager\`.
|
||||
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
|
||||
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
|
||||
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
|
||||
|
||||
## Menu options
|
||||
|
||||
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
|
||||
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
|
||||
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
|
||||
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
|
||||
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
|
||||
|
||||
## Dialogues et sous-titres
|
||||
|
||||
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
|
||||
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
|
||||
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
|
||||
- Le modèle actuel utilise un fichier SRT par voix et par langue.
|
||||
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
|
||||
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
|
||||
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
|
||||
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
|
||||
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
|
||||
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
|
||||
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
|
||||
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
|
||||
- La lecture de dialogue est mise en file pour éviter les chevauchements.
|
||||
|
||||
## Cinématiques
|
||||
|
||||
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
|
||||
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
|
||||
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
|
||||
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
|
||||
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
|
||||
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
|
||||
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
|
||||
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
|
||||
|
||||
## Système debug
|
||||
|
||||
@@ -154,7 +190,8 @@ Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il
|
||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
||||
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
||||
- L'état de mission existe dans Zustand, mais les zones, cinématiques, dialogues et le flow complet de réparation ne sont pas implémentés.
|
||||
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
|
||||
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
|
||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
||||
`;
|
||||
|
||||
@@ -450,8 +487,37 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
|
||||
## Audio
|
||||
|
||||
- Lecture de sons one-shot pour les interactions trigger
|
||||
- Pool simple par son via \`AudioManager\`
|
||||
- Volumes par catégorie pour la musique, les SFX et les dialogues
|
||||
- Lecture de musique en boucle via \`AudioManager\`
|
||||
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
|
||||
- Pan stéréo optionnel pour les sons one-shot
|
||||
|
||||
## Dialogues et sous-titres
|
||||
|
||||
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
|
||||
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
|
||||
- Un fichier SRT par voix et par langue
|
||||
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
|
||||
- Overlay de sous-titres runtime avec couleurs par speaker
|
||||
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
|
||||
- File d'attente pour éviter les dialogues superposés
|
||||
|
||||
## Cinématiques
|
||||
|
||||
- Manifeste de cinématiques dans \`public/cinematics.json\`
|
||||
- Déclenchement timecodé des cinématiques
|
||||
- Lecture de keyframes caméra via GSAP
|
||||
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
|
||||
- Blocage des inputs joueur pendant une cinématique
|
||||
|
||||
## Menu options
|
||||
|
||||
- \`Esc\` ouvre et ferme le menu options en jeu
|
||||
- Sliders de volume musique, SFX et dialogue
|
||||
- Toggle d'affichage des sous-titres
|
||||
- Choix de langue des sous-titres entre français et anglais
|
||||
- Choix du runtime de réparation entre JavaScript local et serveur Python
|
||||
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
|
||||
|
||||
## Outils debug
|
||||
|
||||
@@ -463,12 +529,28 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
||||
- Caméra libre debug
|
||||
- Overlay \`r3f-perf\`
|
||||
|
||||
## Éditeur de carte
|
||||
|
||||
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
|
||||
- Chargement automatique de \`public/map.json\` quand il existe
|
||||
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
|
||||
- Cubes de fallback pour les nodes dont le modèle manque
|
||||
- Sélection d'objet au clic
|
||||
- Modes de transformation translation, rotation et scale
|
||||
- Export JSON pour télécharger la carte modifiée
|
||||
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
|
||||
- Éditeur SRT pour les sous-titres de dialogue
|
||||
- Preview audio et outils de timing pour les cues SRT
|
||||
- Endpoint de sauvegarde dev-server pour les fichiers SRT
|
||||
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
|
||||
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
|
||||
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
|
||||
|
||||
## Pas encore implémenté
|
||||
|
||||
- système de missions complet
|
||||
- système de zones
|
||||
- système de cinématiques
|
||||
- système de dialogues
|
||||
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
|
||||
- flow de chargement
|
||||
- minimap et HUD de mission
|
||||
- séparation complète production / debug pour les scènes gameplay
|
||||
@@ -527,6 +609,74 @@ Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'édi
|
||||
|
||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
||||
|
||||
## Éditer les dialogues et sous-titres
|
||||
|
||||
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
|
||||
|
||||
### Manifeste dialogues
|
||||
|
||||
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
|
||||
|
||||
- \`Reload\` recharge le manifeste depuis le disque.
|
||||
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
|
||||
- \`Save\` écrit le manifeste via le serveur Vite local.
|
||||
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
|
||||
- \`Create FR SRT cue\` crée la cue française si elle manque.
|
||||
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
|
||||
|
||||
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
|
||||
|
||||
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
|
||||
|
||||
### Éditeur SRT
|
||||
|
||||
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
|
||||
2. Choisir une langue : \`FR\` ou \`EN\`.
|
||||
3. Modifier le texte SRT directement dans la textarea.
|
||||
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
|
||||
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
|
||||
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
|
||||
|
||||
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
|
||||
|
||||
## Valider les assets de dialogue
|
||||
|
||||
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
|
||||
|
||||
La validation vérifie :
|
||||
|
||||
- \`public/sounds/dialogue/dialogues.json\`
|
||||
- les fichiers audio de dialogue référencés
|
||||
- les fichiers SRT français
|
||||
- les indexes de cue référencés par le manifeste
|
||||
|
||||
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
|
||||
|
||||
## Éditer les cinématiques
|
||||
|
||||
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
|
||||
|
||||
Chaque cinématique contient :
|
||||
|
||||
- un \`id\`
|
||||
- un \`timecode\` global optionnel
|
||||
- au moins deux keyframes caméra
|
||||
- des dialogue cues optionnelles synchronisées avec la timeline
|
||||
|
||||
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
|
||||
|
||||
Actions disponibles :
|
||||
|
||||
- \`Reload\` recharge le manifeste.
|
||||
- \`Add\` crée une cinématique locale avec deux keyframes.
|
||||
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
|
||||
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
|
||||
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
|
||||
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
|
||||
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
|
||||
|
||||
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
|
||||
|
||||
## Inspecteur JSON
|
||||
|
||||
Le panneau latéral affiche le JSON brut de la carte :
|
||||
@@ -542,4 +692,6 @@ Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauveg
|
||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
||||
- La sauvegarde production n'est pas implémentée.
|
||||
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
|
||||
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
|
||||
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
|
||||
`;
|
||||
|
||||
@@ -52,7 +52,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.25,
|
||||
modelScale: 0.0055,
|
||||
stageUiPath: "/assets/UI/ebike.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
|
||||
@@ -17,6 +17,8 @@ export function useOctreeGraphNode(
|
||||
}, [rebuildKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const graphNode = graphNodeRef.current;
|
||||
if (!enabled || octreeBuilt.current || !graphNode) return;
|
||||
octreeBuilt.current = true;
|
||||
|
||||
+861
@@ -498,6 +498,194 @@ canvas {
|
||||
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
/* Subtitles */
|
||||
.subtitles {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 7vh;
|
||||
z-index: 15;
|
||||
width: min(780px, calc(100vw - 32px));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subtitles p {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
color: #ffffff;
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.subtitles__speaker {
|
||||
margin-right: 0.35em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.subtitles__speaker--narrateur {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.subtitles__speaker--fermier {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.subtitles__speaker--electricienne {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
/* In-game settings menu */
|
||||
.game-settings-menu {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.game-settings-menu__panel {
|
||||
width: min(460px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 24px;
|
||||
background: rgba(8, 8, 8, 0.94);
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.game-settings-menu__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 4px 16px;
|
||||
}
|
||||
|
||||
.game-settings-menu__header span {
|
||||
color: #8f8f8f;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-settings-menu__header h2 {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.game-settings-menu__close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: #111111;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-settings-menu__section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.game-settings-menu__section h3 {
|
||||
margin: 0;
|
||||
color: #d7d7d7;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider span,
|
||||
.game-settings-menu__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #f2f2f2;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider strong {
|
||||
color: #8f8f8f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.game-settings-menu__slider input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #ffffff;
|
||||
}
|
||||
|
||||
.game-settings-menu__checkbox {
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-settings-menu__checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #ffffff;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button,
|
||||
.game-settings-menu__quit {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.game-settings-menu__choice-group button.active {
|
||||
border-color: #ffffff;
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
}
|
||||
|
||||
.game-settings-menu__quit {
|
||||
margin-top: 8px;
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
/* Debug overlay panels */
|
||||
.debug-overlay-layout {
|
||||
position: fixed;
|
||||
@@ -1105,6 +1293,12 @@ canvas {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.editor-action-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.editor-action-button-primary,
|
||||
.editor-player-button.active {
|
||||
background: #ffffff;
|
||||
@@ -1268,6 +1462,673 @@ canvas {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
/* Editor SRT panel */
|
||||
.editor-srt-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.editor-srt-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-srt-controls label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-srt-controls select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-srt-textarea {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
background: #050505;
|
||||
color: #d7d7d7;
|
||||
font-family: "SFMono-Regular", "Courier New", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.editor-srt-textarea:focus,
|
||||
.editor-srt-controls select:focus,
|
||||
.editor-srt-preview select:focus {
|
||||
border-color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-srt-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
background: #070707;
|
||||
}
|
||||
|
||||
.editor-srt-preview label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-srt-preview select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-srt-audio-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-srt-audio-card span {
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.editor-srt-audio-card strong {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editor-srt-audio-card audio {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.editor-srt-active-cue {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.editor-srt-active-cue span {
|
||||
color: #8d8d8d;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.editor-srt-active-cue p {
|
||||
margin: 0;
|
||||
color: #d7d7d7;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-srt-active-cue strong {
|
||||
margin-right: 4px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.editor-srt-time-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-srt-time-actions button {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.24);
|
||||
border-radius: 12px;
|
||||
background: rgba(125, 211, 252, 0.08);
|
||||
color: #bae6fd;
|
||||
cursor: pointer;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-srt-time-actions button:hover {
|
||||
border-color: #7dd3fc;
|
||||
background: rgba(125, 211, 252, 0.14);
|
||||
}
|
||||
|
||||
.editor-srt-jump-button {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
background: #151515;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editor-srt-jump-button:hover {
|
||||
border-color: #ffffff;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.editor-srt-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-srt-actions .editor-action-button + .editor-action-button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.editor-srt-status {
|
||||
margin: 0;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation.is-valid {
|
||||
border-color: rgba(134, 239, 172, 0.32);
|
||||
}
|
||||
|
||||
.editor-dialogue-validation.is-invalid {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading strong {
|
||||
color: #f2f2f2;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading span {
|
||||
color: #8d8d8d;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 92px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
background: #151515;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading button:hover {
|
||||
border-color: #ffffff;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__heading button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__result {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__result p {
|
||||
margin: 0;
|
||||
color: #d7d7d7;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__errors,
|
||||
.editor-dialogue-validation__warnings {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__errors {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-dialogue-validation__warnings {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.editor-srt-diagnostic {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-srt-diagnostic.is-valid {
|
||||
border-color: rgba(134, 239, 172, 0.32);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.editor-srt-diagnostic.is-invalid {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-srt-diagnostic strong {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.editor-srt-diagnostic ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Editor dialogue manifest panel */
|
||||
.editor-dialogue-manifest-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-actions button,
|
||||
.editor-dialogue-manifest-srt-cue,
|
||||
.editor-dialogue-manifest-preview,
|
||||
.editor-dialogue-manifest-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
background: #151515;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-actions button:hover,
|
||||
.editor-dialogue-manifest-srt-cue:hover,
|
||||
.editor-dialogue-manifest-preview:hover,
|
||||
.editor-dialogue-manifest-delete:hover {
|
||||
border-color: #ffffff;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-srt-cue:disabled,
|
||||
.editor-dialogue-manifest-preview:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-select,
|
||||
.editor-dialogue-manifest-form label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-select select,
|
||||
.editor-dialogue-manifest-form input,
|
||||
.editor-dialogue-manifest-form select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-select select:focus,
|
||||
.editor-dialogue-manifest-form input:focus,
|
||||
.editor-dialogue-manifest-form select:focus {
|
||||
border-color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
background: #070707;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-delete {
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-preview {
|
||||
border-color: rgba(125, 211, 252, 0.24);
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-srt-cue {
|
||||
border-color: rgba(134, 239, 172, 0.24);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-status {
|
||||
margin: 0;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-diagnostic {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-diagnostic.is-valid {
|
||||
border-color: rgba(134, 239, 172, 0.32);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-diagnostic.is-invalid {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-dialogue-manifest-diagnostic ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Editor cinematic manifest panel */
|
||||
.editor-cinematic-manifest-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 12px 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-actions button,
|
||||
.editor-cinematic-manifest-preview,
|
||||
.editor-cinematic-manifest-delete,
|
||||
.editor-cinematic-keyframes-heading button,
|
||||
.editor-cinematic-keyframe-heading button,
|
||||
.editor-cinematic-dialogue-cues-heading button,
|
||||
.editor-cinematic-dialogue-cue-heading button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
background: #151515;
|
||||
color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-actions button:hover,
|
||||
.editor-cinematic-manifest-preview:hover,
|
||||
.editor-cinematic-manifest-delete:hover,
|
||||
.editor-cinematic-keyframes-heading button:hover,
|
||||
.editor-cinematic-keyframe-heading button:hover,
|
||||
.editor-cinematic-dialogue-cues-heading button:hover,
|
||||
.editor-cinematic-dialogue-cue-heading button:hover {
|
||||
border-color: #ffffff;
|
||||
background: #202020;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-actions button:disabled,
|
||||
.editor-cinematic-manifest-preview:disabled,
|
||||
.editor-cinematic-keyframe-heading button:disabled,
|
||||
.editor-cinematic-dialogue-cue-heading button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-select,
|
||||
.editor-cinematic-manifest-form label,
|
||||
.editor-cinematic-vector-inputs label,
|
||||
.editor-cinematic-dialogue-cue label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-select select,
|
||||
.editor-cinematic-manifest-form input,
|
||||
.editor-cinematic-manifest-form select,
|
||||
.editor-cinematic-vector-inputs input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-select select:focus,
|
||||
.editor-cinematic-manifest-form input:focus,
|
||||
.editor-cinematic-manifest-form select:focus,
|
||||
.editor-cinematic-vector-inputs input:focus {
|
||||
border-color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-form,
|
||||
.editor-cinematic-keyframes,
|
||||
.editor-cinematic-keyframe,
|
||||
.editor-cinematic-dialogue-cues,
|
||||
.editor-cinematic-dialogue-cue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-form {
|
||||
padding: 10px;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 16px;
|
||||
background: #070707;
|
||||
}
|
||||
|
||||
.editor-cinematic-keyframes,
|
||||
.editor-cinematic-dialogue-cues {
|
||||
padding: 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 14px;
|
||||
background: #101010;
|
||||
}
|
||||
|
||||
.editor-cinematic-keyframes-heading,
|
||||
.editor-cinematic-keyframe-heading,
|
||||
.editor-cinematic-dialogue-cues-heading,
|
||||
.editor-cinematic-dialogue-cue-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-cinematic-keyframes-heading strong,
|
||||
.editor-cinematic-keyframe-heading strong,
|
||||
.editor-cinematic-dialogue-cues-heading strong,
|
||||
.editor-cinematic-dialogue-cue-heading strong {
|
||||
color: #f2f2f2;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.editor-cinematic-keyframe,
|
||||
.editor-cinematic-dialogue-cue {
|
||||
padding: 9px;
|
||||
border: 1px solid #1f1f1f;
|
||||
border-radius: 12px;
|
||||
background: #070707;
|
||||
}
|
||||
|
||||
.editor-cinematic-vector-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.editor-cinematic-vector-inputs span {
|
||||
grid-column: 1 / -1;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-delete {
|
||||
border-color: rgba(248, 113, 113, 0.32);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-preview {
|
||||
border-color: rgba(125, 211, 252, 0.24);
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.editor-cinematic-keyframe-heading button,
|
||||
.editor-cinematic-dialogue-cue-heading button {
|
||||
padding: 6px 8px;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-cinematic-dialogue-cues p {
|
||||
margin: 0;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-status {
|
||||
margin: 0;
|
||||
color: #8d8d8d;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-diagnostic {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #242424;
|
||||
border-radius: 12px;
|
||||
background: #101010;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-diagnostic.is-valid {
|
||||
border-color: rgba(134, 239, 172, 0.32);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-diagnostic.is-invalid {
|
||||
border-color: rgba(248, 113, 113, 0.38);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.editor-cinematic-manifest-diagnostic ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Editor responsive layout */
|
||||
@media (max-width: 768px) {
|
||||
.editor-error h2 {
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
|
||||
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||
|
||||
interface AudioContextWindow extends Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||
music: 1,
|
||||
sfx: 1,
|
||||
dialogue: 1,
|
||||
};
|
||||
|
||||
interface PlaySoundOptions {
|
||||
category?: OneShotAudioCategory;
|
||||
pan?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
interface StereoNodes {
|
||||
source: MediaElementAudioSourceNode;
|
||||
panner: StereoPannerNode;
|
||||
}
|
||||
|
||||
interface OneShotAudioState {
|
||||
category: OneShotAudioCategory;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export class AudioManager {
|
||||
private static _instance: AudioManager | null = null;
|
||||
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
||||
private readonly _stereoNodes = new WeakMap<HTMLAudioElement, StereoNodes>();
|
||||
private readonly _oneShotStates = new WeakMap<
|
||||
HTMLAudioElement,
|
||||
OneShotAudioState
|
||||
>();
|
||||
private readonly _categoryVolumes: Record<AudioCategory, number> = {
|
||||
...DEFAULT_CATEGORY_VOLUMES,
|
||||
};
|
||||
private _audioContext: AudioContext | null = null;
|
||||
private _music: HTMLAudioElement | null = null;
|
||||
private _musicPath: string | null = null;
|
||||
private _musicVolume = 1;
|
||||
private _musicUnlockHandler: (() => void) | null = null;
|
||||
|
||||
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
||||
private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx";
|
||||
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
||||
"AbortError",
|
||||
"NotAllowedError",
|
||||
@@ -27,11 +63,38 @@ export class AudioManager {
|
||||
|
||||
private constructor() {}
|
||||
|
||||
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
|
||||
setCategoryVolume(category: AudioCategory, volume: number): void {
|
||||
this._categoryVolumes[category] = AudioManager._clampVolume(volume);
|
||||
|
||||
if (category === "music" && this._music) {
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateOneShotVolumes(category);
|
||||
}
|
||||
|
||||
getCategoryVolume(category: AudioCategory): number {
|
||||
return this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
playSound(
|
||||
path: string,
|
||||
volume = 1,
|
||||
options: PlaySoundOptions = {},
|
||||
): HTMLAudioElement {
|
||||
const audio = this._acquireAudio(path);
|
||||
audio.volume = Math.max(0, Math.min(1, volume));
|
||||
const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY;
|
||||
const baseVolume = AudioManager._clampVolume(volume);
|
||||
this._oneShotStates.set(audio, { category, volume: baseVolume });
|
||||
audio.volume = this._getEffectiveVolume(category, baseVolume);
|
||||
audio.playbackRate = options.playbackRate ?? 1;
|
||||
audio.currentTime = 0;
|
||||
this._setStereoPan(audio, options.pan ?? 0);
|
||||
|
||||
if (this._audioContext?.state === "suspended") {
|
||||
void this._audioContext.resume();
|
||||
}
|
||||
|
||||
void audio.play().catch((error: unknown) => {
|
||||
if (
|
||||
@@ -43,14 +106,19 @@ export class AudioManager {
|
||||
|
||||
logger.error("AudioManager", "Failed to play sound", {
|
||||
path,
|
||||
category,
|
||||
error: AudioManager._toLogValue(error),
|
||||
});
|
||||
});
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
playMusic(path: string, volume = 1): void {
|
||||
this._musicVolume = AudioManager._clampVolume(volume);
|
||||
|
||||
if (this._musicPath === path && this._music) {
|
||||
this._music.volume = Math.max(0, Math.min(1, volume));
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
if (!this._music.paused) return;
|
||||
} else {
|
||||
this.stopMusic();
|
||||
@@ -59,7 +127,7 @@ export class AudioManager {
|
||||
this._musicPath = path;
|
||||
}
|
||||
|
||||
this._music.volume = Math.max(0, Math.min(1, volume));
|
||||
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||
|
||||
void this._music.play().catch((error: unknown) => {
|
||||
if (
|
||||
@@ -93,6 +161,8 @@ export class AudioManager {
|
||||
});
|
||||
});
|
||||
this._audioPools.clear();
|
||||
void this._audioContext?.close();
|
||||
this._audioContext = null;
|
||||
AudioManager._instance = null;
|
||||
}
|
||||
|
||||
@@ -159,6 +229,61 @@ export class AudioManager {
|
||||
this._musicUnlockHandler = null;
|
||||
}
|
||||
|
||||
private _setStereoPan(audio: HTMLAudioElement, pan: number): void {
|
||||
const audioContext = this._getAudioContext();
|
||||
if (!audioContext || !("createStereoPanner" in audioContext)) return;
|
||||
|
||||
let nodes = this._stereoNodes.get(audio);
|
||||
if (!nodes) {
|
||||
nodes = {
|
||||
source: audioContext.createMediaElementSource(audio),
|
||||
panner: audioContext.createStereoPanner(),
|
||||
};
|
||||
nodes.source.connect(nodes.panner).connect(audioContext.destination);
|
||||
this._stereoNodes.set(audio, nodes);
|
||||
}
|
||||
|
||||
nodes.panner.pan.value = AudioManager._clampPan(pan);
|
||||
}
|
||||
|
||||
private _getAudioContext(): AudioContext | null {
|
||||
if (this._audioContext) return this._audioContext;
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ??
|
||||
(window as AudioContextWindow).webkitAudioContext ??
|
||||
null;
|
||||
if (!AudioContextConstructor) return null;
|
||||
|
||||
this._audioContext = new AudioContextConstructor();
|
||||
return this._audioContext;
|
||||
}
|
||||
|
||||
private _getEffectiveVolume(category: AudioCategory, volume: number): number {
|
||||
return AudioManager._clampVolume(volume) * this._categoryVolumes[category];
|
||||
}
|
||||
|
||||
private _updateOneShotVolumes(category: AudioCategory): void {
|
||||
if (category === "music") return;
|
||||
|
||||
this._audioPools.forEach((pool) => {
|
||||
pool.forEach((audio) => {
|
||||
const state = this._oneShotStates.get(audio);
|
||||
if (!state || state.category !== category) return;
|
||||
|
||||
audio.volume = this._getEffectiveVolume(category, state.volume);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static _clampPan(pan: number): number {
|
||||
return Math.max(-1, Math.min(1, pan));
|
||||
}
|
||||
|
||||
private static _clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private static _toLogValue(error: unknown): Error | DOMException | string {
|
||||
if (error instanceof Error || error instanceof DOMException) {
|
||||
return error;
|
||||
|
||||
@@ -21,6 +21,7 @@ interface MissionState {
|
||||
|
||||
interface GameState {
|
||||
mainState: MainGameState;
|
||||
isCinematicPlaying: boolean;
|
||||
intro: IntroState;
|
||||
bike: MissionState & {
|
||||
isRepaired: boolean;
|
||||
@@ -39,6 +40,7 @@ interface GameState {
|
||||
|
||||
interface GameActions {
|
||||
setMainState: (mainState: MainGameState) => void;
|
||||
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||
setIntroState: (intro: Partial<IntroState>) => void;
|
||||
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||
@@ -222,6 +224,7 @@ function startOutroState(state: GameState): GameStateUpdate {
|
||||
function createInitialGameState(): GameState {
|
||||
return {
|
||||
mainState: "intro",
|
||||
isCinematicPlaying: false,
|
||||
intro: {
|
||||
dialogueAudio: null,
|
||||
hasCompleted: false,
|
||||
@@ -252,6 +255,7 @@ function createInitialGameState(): GameState {
|
||||
export const useGameStore = create<GameStore>()((set) => ({
|
||||
...createInitialGameState(),
|
||||
setMainState: (mainState) => set({ mainState }),
|
||||
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||
setIntroState: (intro) =>
|
||||
set((state) => ({ intro: { ...state.intro, ...intro } })),
|
||||
setBikeState: (bike) =>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from "zustand";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { AudioCategory } from "@/managers/AudioManager";
|
||||
|
||||
export type SubtitleLanguage = "fr" | "en";
|
||||
export type RepairRuntime = "js" | "python";
|
||||
|
||||
interface SettingsState {
|
||||
isSettingsMenuOpen: boolean;
|
||||
musicVolume: number;
|
||||
sfxVolume: number;
|
||||
dialogueVolume: number;
|
||||
subtitlesEnabled: boolean;
|
||||
subtitleLanguage: SubtitleLanguage;
|
||||
repairRuntime: RepairRuntime;
|
||||
}
|
||||
|
||||
interface SettingsActions {
|
||||
setSettingsMenuOpen: (open: boolean) => void;
|
||||
setMusicVolume: (volume: number) => void;
|
||||
setSfxVolume: (volume: number) => void;
|
||||
setDialogueVolume: (volume: number) => void;
|
||||
setSubtitlesEnabled: (enabled: boolean) => void;
|
||||
setSubtitleLanguage: (language: SubtitleLanguage) => void;
|
||||
setRepairRuntime: (runtime: RepairRuntime) => void;
|
||||
resetSettings: () => void;
|
||||
}
|
||||
|
||||
type SettingsStore = SettingsState & SettingsActions;
|
||||
|
||||
const DEFAULT_SETTINGS: SettingsState = {
|
||||
isSettingsMenuOpen: false,
|
||||
musicVolume: 1,
|
||||
sfxVolume: 1,
|
||||
dialogueVolume: 1,
|
||||
subtitlesEnabled: true,
|
||||
subtitleLanguage: "fr",
|
||||
repairRuntime: "js",
|
||||
};
|
||||
|
||||
function clampVolume(volume: number): number {
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
function setAudioCategoryVolume(
|
||||
category: AudioCategory,
|
||||
volume: number,
|
||||
): number {
|
||||
const nextVolume = clampVolume(volume);
|
||||
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
|
||||
return nextVolume;
|
||||
}
|
||||
|
||||
function applyDefaultAudioSettings(): void {
|
||||
AudioManager.getInstance().setCategoryVolume(
|
||||
"music",
|
||||
DEFAULT_SETTINGS.musicVolume,
|
||||
);
|
||||
AudioManager.getInstance().setCategoryVolume(
|
||||
"sfx",
|
||||
DEFAULT_SETTINGS.sfxVolume,
|
||||
);
|
||||
AudioManager.getInstance().setCategoryVolume(
|
||||
"dialogue",
|
||||
DEFAULT_SETTINGS.dialogueVolume,
|
||||
);
|
||||
}
|
||||
|
||||
applyDefaultAudioSettings();
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>()((set) => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
||||
setMusicVolume: (volume) =>
|
||||
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
||||
setSfxVolume: (volume) =>
|
||||
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
||||
setDialogueVolume: (volume) =>
|
||||
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
||||
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
||||
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
||||
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
|
||||
resetSettings: () => {
|
||||
applyDefaultAudioSettings();
|
||||
set(DEFAULT_SETTINGS);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||
|
||||
interface ActiveSubtitle {
|
||||
speaker: DialogueSpeaker;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface SubtitleState {
|
||||
activeSubtitle: ActiveSubtitle | null;
|
||||
}
|
||||
|
||||
interface SubtitleActions {
|
||||
setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void;
|
||||
clearActiveSubtitle: () => void;
|
||||
}
|
||||
|
||||
type SubtitleStore = SubtitleState & SubtitleActions;
|
||||
|
||||
export const useSubtitleStore = create<SubtitleStore>()((set) => ({
|
||||
activeSubtitle: null,
|
||||
setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }),
|
||||
clearActiveSubtitle: () => set({ activeSubtitle: null }),
|
||||
}));
|
||||
@@ -3,8 +3,11 @@ import { Canvas } from "@react-three/fiber";
|
||||
import { useProgress } from "@react-three/drei";
|
||||
import { EditorControls } from "@/components/editor/EditorControls";
|
||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
||||
import {
|
||||
@@ -93,6 +96,8 @@ export function EditorPage(): React.JSX.Element {
|
||||
status: "loading" as const,
|
||||
}
|
||||
: sceneLoadingState;
|
||||
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
||||
useState<EditorCinematicPreviewRequest | null>(null);
|
||||
|
||||
const {
|
||||
undoCount,
|
||||
@@ -153,6 +158,20 @@ export function EditorPage(): React.JSX.Element {
|
||||
setIsPlayerMode((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handlePreviewCinematic = useCallback(
|
||||
(cinematic: CinematicDefinition) => {
|
||||
setCinematicPreviewRequest({
|
||||
id: window.crypto.randomUUID(),
|
||||
cinematic,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCinematicPreviewComplete = useCallback(() => {
|
||||
setCinematicPreviewRequest(null);
|
||||
}, []);
|
||||
|
||||
const handleNodeTransform = useCallback(
|
||||
(nodeIndex: number, updatedNode: MapNode) => {
|
||||
setSceneData((prev) => {
|
||||
@@ -237,6 +256,8 @@ export function EditorPage(): React.JSX.Element {
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
isPlayerMode={isPlayerMode}
|
||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
@@ -262,9 +283,11 @@ export function EditorPage(): React.JSX.Element {
|
||||
onExportJson={handleExportJson}
|
||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||
onPlayerMode={handlePlayerMode}
|
||||
onPreviewCinematic={handlePreviewCinematic}
|
||||
isPlayerMode={isPlayerMode}
|
||||
/>
|
||||
)}
|
||||
<Subtitles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface CinematicCameraKeyframe {
|
||||
time: number;
|
||||
position: Vector3Tuple;
|
||||
target: Vector3Tuple;
|
||||
}
|
||||
|
||||
export interface CinematicDialogueCue {
|
||||
time: number;
|
||||
dialogueId: string;
|
||||
}
|
||||
|
||||
export interface CinematicDefinition {
|
||||
id: string;
|
||||
timecode?: number;
|
||||
cameraKeyframes: CinematicCameraKeyframe[];
|
||||
dialogueCues?: CinematicDialogueCue[];
|
||||
}
|
||||
|
||||
export interface CinematicManifest {
|
||||
version: 1;
|
||||
cinematics: CinematicDefinition[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||
|
||||
export type DialogueVoiceId = "narrateur" | "fermier" | "electricienne";
|
||||
export type DialogueSpeaker = "Narrateur" | "Fermier" | "Electricienne";
|
||||
|
||||
export interface DialogueVoice {
|
||||
id: DialogueVoiceId;
|
||||
speaker: DialogueSpeaker;
|
||||
subtitles: Partial<Record<SubtitleLanguage, string>>;
|
||||
}
|
||||
|
||||
export interface DialogueDefinition {
|
||||
id: string;
|
||||
voice: DialogueVoiceId;
|
||||
audio: string;
|
||||
subtitleCueIndex: number;
|
||||
timecode?: number;
|
||||
}
|
||||
|
||||
export interface DialogueManifest {
|
||||
version: 1;
|
||||
voices: DialogueVoice[];
|
||||
dialogues: DialogueDefinition[];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CinematicCameraKeyframe,
|
||||
CinematicDefinition,
|
||||
CinematicDialogueCue,
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export function parseCinematicManifest(data: unknown): CinematicManifest {
|
||||
if (!isRecord(data) || data.version !== 1) {
|
||||
throw new Error("Invalid cinematic manifest version");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.cinematics)) {
|
||||
throw new Error("Cinematic manifest requires a cinematics array");
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
cinematics: data.cinematics.map(parseCinematicDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCinematicDefinition(data: unknown): CinematicDefinition {
|
||||
if (!isRecord(data) || typeof data.id !== "string") {
|
||||
throw new Error("Invalid cinematic definition");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.cameraKeyframes)) {
|
||||
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
|
||||
}
|
||||
|
||||
const cameraKeyframes = data.cameraKeyframes.map(parseCameraKeyframe);
|
||||
if (cameraKeyframes.length < 2) {
|
||||
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
|
||||
}
|
||||
|
||||
cameraKeyframes.forEach((keyframe, index) => {
|
||||
const previousKeyframe = cameraKeyframes[index - 1];
|
||||
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
|
||||
}
|
||||
});
|
||||
|
||||
const cinematic: CinematicDefinition = {
|
||||
id: data.id,
|
||||
cameraKeyframes,
|
||||
};
|
||||
|
||||
if (typeof data.timecode === "number") {
|
||||
cinematic.timecode = data.timecode;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.dialogueCues)) {
|
||||
cinematic.dialogueCues = data.dialogueCues.map(parseDialogueCue);
|
||||
}
|
||||
|
||||
return cinematic;
|
||||
}
|
||||
|
||||
function parseDialogueCue(data: unknown): CinematicDialogueCue {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
typeof data.time !== "number" ||
|
||||
typeof data.dialogueId !== "string"
|
||||
) {
|
||||
throw new Error("Invalid cinematic dialogue cue");
|
||||
}
|
||||
|
||||
return {
|
||||
time: data.time,
|
||||
dialogueId: data.dialogueId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe {
|
||||
if (!isRecord(data) || typeof data.time !== "number") {
|
||||
throw new Error("Invalid cinematic camera keyframe");
|
||||
}
|
||||
|
||||
return {
|
||||
time: data.time,
|
||||
position: parseVector3(data.position),
|
||||
target: parseVector3(data.target),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVector3(value: unknown): Vector3Tuple {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 3 ||
|
||||
value.some((item) => typeof item !== "number")
|
||||
) {
|
||||
throw new Error("Invalid cinematic vector");
|
||||
}
|
||||
|
||||
return [value[0], value[1], value[2]];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CinematicManifest } from "@/types/cinematics/cinematics";
|
||||
import { parseCinematicManifest } from "@/utils/cinematics/cinematicManifestValidation";
|
||||
|
||||
const CINEMATIC_MANIFEST_PATH = "/cinematics.json";
|
||||
|
||||
export async function loadCinematicManifest(): Promise<CinematicManifest | null> {
|
||||
const response = await fetch(CINEMATIC_MANIFEST_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseCinematicManifest(await response.json());
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoice,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
|
||||
const VALID_VOICE_IDS = new Set<DialogueVoiceId>([
|
||||
"narrateur",
|
||||
"fermier",
|
||||
"electricienne",
|
||||
]);
|
||||
const VALID_SPEAKERS = new Set<DialogueSpeaker>([
|
||||
"Narrateur",
|
||||
"Fermier",
|
||||
"Electricienne",
|
||||
]);
|
||||
|
||||
export function parseDialogueManifest(data: unknown): DialogueManifest {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue manifest must be an object");
|
||||
}
|
||||
|
||||
if (data.version !== 1) {
|
||||
throw new Error("Unsupported dialogue manifest version");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
|
||||
throw new Error("Dialogue manifest requires voices and dialogues arrays");
|
||||
}
|
||||
|
||||
const voices = data.voices.map(parseDialogueVoice);
|
||||
const voiceIds = new Set(voices.map((voice) => voice.id));
|
||||
const dialogues = data.dialogues.map((dialogue) =>
|
||||
parseDialogueDefinition(dialogue, voiceIds),
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
voices,
|
||||
dialogues,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogueVoice(data: unknown): DialogueVoice {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue voice must be an object");
|
||||
}
|
||||
|
||||
if (!isDialogueVoiceId(data.id)) {
|
||||
throw new Error("Dialogue voice has an invalid id");
|
||||
}
|
||||
|
||||
if (!isDialogueSpeaker(data.speaker)) {
|
||||
throw new Error(`Dialogue voice ${data.id} has an invalid speaker`);
|
||||
}
|
||||
|
||||
if (!isRecord(data.subtitles)) {
|
||||
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
|
||||
}
|
||||
|
||||
const subtitles: DialogueVoice["subtitles"] = {};
|
||||
const frSubtitle = getOptionalPath(data.subtitles.fr);
|
||||
const enSubtitle = getOptionalPath(data.subtitles.en);
|
||||
if (frSubtitle) subtitles.fr = frSubtitle;
|
||||
if (enSubtitle) subtitles.en = enSubtitle;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
speaker: data.speaker,
|
||||
subtitles,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogueDefinition(
|
||||
data: unknown,
|
||||
voiceIds: Set<DialogueVoiceId>,
|
||||
): DialogueDefinition {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue definition must be an object");
|
||||
}
|
||||
|
||||
if (typeof data.id !== "string" || data.id.length === 0) {
|
||||
throw new Error("Dialogue definition has an invalid id");
|
||||
}
|
||||
|
||||
if (!isDialogueVoiceId(data.voice) || !voiceIds.has(data.voice)) {
|
||||
throw new Error(`Dialogue ${data.id} references an unknown voice`);
|
||||
}
|
||||
|
||||
if (typeof data.audio !== "string" || data.audio.length === 0) {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
||||
}
|
||||
|
||||
const subtitleCueIndex = data.subtitleCueIndex;
|
||||
if (
|
||||
typeof subtitleCueIndex !== "number" ||
|
||||
!Number.isInteger(subtitleCueIndex) ||
|
||||
subtitleCueIndex < 1
|
||||
) {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
||||
}
|
||||
|
||||
const timecode = data.timecode;
|
||||
if (timecode !== undefined && typeof timecode !== "number") {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid timecode`);
|
||||
}
|
||||
|
||||
const dialogue: DialogueDefinition = {
|
||||
id: data.id,
|
||||
voice: data.voice,
|
||||
audio: data.audio,
|
||||
subtitleCueIndex,
|
||||
};
|
||||
|
||||
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
function getOptionalPath(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isDialogueVoiceId(value: unknown): value is DialogueVoiceId {
|
||||
return (
|
||||
typeof value === "string" && VALID_VOICE_IDS.has(value as DialogueVoiceId)
|
||||
);
|
||||
}
|
||||
|
||||
function isDialogueSpeaker(value: unknown): value is DialogueSpeaker {
|
||||
return (
|
||||
typeof value === "string" && VALID_SPEAKERS.has(value as DialogueSpeaker)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueVoice,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||
|
||||
export interface DialogueSubtitleCue {
|
||||
voice: DialogueVoice;
|
||||
cue: SubtitleCue;
|
||||
subtitlePath: string;
|
||||
}
|
||||
|
||||
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseDialogueManifest(await response.json());
|
||||
}
|
||||
|
||||
export function resolveDialogueSubtitlePath(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): string | null {
|
||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||
if (!voice) return null;
|
||||
|
||||
return getVoiceSubtitlePath(voice, language);
|
||||
}
|
||||
|
||||
export function getDialogueVoice(
|
||||
manifest: DialogueManifest,
|
||||
voiceId: DialogueDefinition["voice"],
|
||||
): DialogueVoice | null {
|
||||
return manifest.voices.find((voice) => voice.id === voiceId) ?? null;
|
||||
}
|
||||
|
||||
export async function loadDialogueSubtitleCue(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): Promise<DialogueSubtitleCue | null> {
|
||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||
if (!voice) return null;
|
||||
|
||||
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||
if (!subtitles) return null;
|
||||
|
||||
const cue = subtitles.cues.find(
|
||||
(item) => item.index === dialogue.subtitleCueIndex,
|
||||
);
|
||||
|
||||
if (!cue) return null;
|
||||
|
||||
return {
|
||||
voice,
|
||||
cue,
|
||||
subtitlePath: subtitles.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadVoiceSubtitleCues(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): Promise<{ path: string; cues: SubtitleCue[] } | null> {
|
||||
const paths = getVoiceSubtitlePaths(voice, language);
|
||||
|
||||
for (const path of paths) {
|
||||
const srtContent = await loadSrtContent(path);
|
||||
if (srtContent !== null) {
|
||||
return { path, cues: parseSrt(srtContent) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSrtContent(path: string): Promise<string | null> {
|
||||
const response = await fetch(path);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function getVoiceSubtitlePaths(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): string[] {
|
||||
return [voice.subtitles[language], voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE]]
|
||||
.filter((path): path is string => Boolean(path))
|
||||
.filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function getVoiceSubtitlePath(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): string | null {
|
||||
return (
|
||||
voice.subtitles[language] ??
|
||||
voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import {
|
||||
loadDialogueManifest,
|
||||
loadDialogueSubtitleCue,
|
||||
} from "@/utils/dialogues/loadDialogueManifest";
|
||||
|
||||
interface QueuedDialogueRequest {
|
||||
manifest: DialogueManifest;
|
||||
dialogueId: string;
|
||||
resolve: (audio: HTMLAudioElement | null) => void;
|
||||
}
|
||||
|
||||
const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
||||
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||
let gameplayDialogueManifestPromise: Promise<DialogueManifest | null> | null =
|
||||
null;
|
||||
let isDialogueQueuePlaying = false;
|
||||
|
||||
export function queueDialogueById(
|
||||
manifest: DialogueManifest,
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogueQueue.push({ manifest, dialogueId, resolve });
|
||||
void playNextQueuedDialogue();
|
||||
});
|
||||
}
|
||||
|
||||
export function clearQueuedDialogues(): void {
|
||||
while (dialogueQueue.length > 0) {
|
||||
dialogueQueue.shift()?.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function playGameplayDialogueById(
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
gameplayDialogueManifestPromise ??= loadDialogueManifest();
|
||||
const manifest = await gameplayDialogueManifestPromise;
|
||||
if (!manifest) return null;
|
||||
|
||||
return queueDialogueById(manifest, dialogueId);
|
||||
}
|
||||
|
||||
export async function playDialogueById(
|
||||
manifest: DialogueManifest,
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||
if (!dialogue) return null;
|
||||
|
||||
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||
const subtitle = await loadDialogueSubtitleCue(
|
||||
manifest,
|
||||
dialogue,
|
||||
subtitleLanguage,
|
||||
);
|
||||
const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, {
|
||||
category: "dialogue",
|
||||
});
|
||||
|
||||
if (!subtitle) return audio;
|
||||
|
||||
const clearSubtitle = (): void => {
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
audio.removeEventListener("play", syncSubtitle);
|
||||
audio.removeEventListener("timeupdate", syncSubtitle);
|
||||
audio.removeEventListener("ended", cleanup);
|
||||
audio.removeEventListener("pause", cleanup);
|
||||
clearSubtitle();
|
||||
};
|
||||
|
||||
const syncSubtitle = (): void => {
|
||||
const currentTime = audio.currentTime;
|
||||
const shouldShowSubtitle =
|
||||
currentTime >= subtitle.cue.startTime &&
|
||||
currentTime <= subtitle.cue.endTime;
|
||||
|
||||
if (shouldShowSubtitle) {
|
||||
useSubtitleStore.getState().setActiveSubtitle({
|
||||
speaker: subtitle.voice.speaker,
|
||||
text: subtitle.cue.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearSubtitle();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", syncSubtitle);
|
||||
audio.addEventListener("timeupdate", syncSubtitle);
|
||||
audio.addEventListener("ended", cleanup);
|
||||
audio.addEventListener("pause", cleanup);
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
async function playNextQueuedDialogue(): Promise<void> {
|
||||
if (isDialogueQueuePlaying) return;
|
||||
|
||||
isDialogueQueuePlaying = true;
|
||||
|
||||
while (dialogueQueue.length > 0) {
|
||||
const request = dialogueQueue.shift();
|
||||
if (!request) continue;
|
||||
|
||||
try {
|
||||
const audio = await playDialogueById(
|
||||
request.manifest,
|
||||
request.dialogueId,
|
||||
);
|
||||
request.resolve(audio);
|
||||
if (audio) await waitForDialogueToFinish(audio);
|
||||
} catch {
|
||||
request.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
isDialogueQueuePlaying = false;
|
||||
}
|
||||
|
||||
function waitForDialogueToFinish(audio: HTMLAudioElement): Promise<void> {
|
||||
if (audio.ended) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let hasStarted = !audio.paused;
|
||||
let startTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cleanup(): void {
|
||||
if (startTimeout) clearTimeout(startTimeout);
|
||||
audio.removeEventListener("play", handlePlay);
|
||||
audio.removeEventListener("ended", finish);
|
||||
audio.removeEventListener("pause", finish);
|
||||
audio.removeEventListener("error", finish);
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
|
||||
function handlePlay(): void {
|
||||
hasStarted = true;
|
||||
if (startTimeout) clearTimeout(startTimeout);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", handlePlay);
|
||||
audio.addEventListener("ended", finish);
|
||||
audio.addEventListener("pause", finish);
|
||||
audio.addEventListener("error", finish);
|
||||
|
||||
startTimeout = setTimeout(() => {
|
||||
if (!hasStarted && audio.paused) finish();
|
||||
}, DIALOGUE_PLAY_START_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface SubtitleCue {
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SRT_TIME_SEPARATOR = " --> ";
|
||||
const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/;
|
||||
|
||||
export function parseSrt(srtContent: string): SubtitleCue[] {
|
||||
return srtContent
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim()
|
||||
.split(/\n{2,}/)
|
||||
.map(parseSrtBlock)
|
||||
.filter((cue): cue is SubtitleCue => cue !== null);
|
||||
}
|
||||
|
||||
function parseSrtBlock(block: string): SubtitleCue | null {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length < 3) return null;
|
||||
|
||||
const index = Number(lines[0]);
|
||||
if (!Number.isInteger(index)) return null;
|
||||
|
||||
const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? [];
|
||||
if (!start || !end) return null;
|
||||
|
||||
const startTime = parseSrtTime(start);
|
||||
const endTime = parseSrtTime(end);
|
||||
if (startTime === null || endTime === null || endTime <= startTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
startTime,
|
||||
endTime,
|
||||
text: lines.slice(2).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSrtTime(value: string): number | null {
|
||||
const match = value.match(SRT_TIME_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const [, hours, minutes, seconds, milliseconds] = match;
|
||||
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||
|
||||
return (
|
||||
Number(hours) * 3600 +
|
||||
Number(minutes) * 60 +
|
||||
Number(seconds) +
|
||||
Number(milliseconds) / 1000
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type {
|
||||
CinematicDefinition,
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
export function GameCinematics(): null {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||
const [dialogueManifest, setDialogueManifest] =
|
||||
useState<DialogueManifest | null>(null);
|
||||
const playedCinematicsRef = useRef(new Set<string>());
|
||||
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const activeAudios = activeAudiosRef.current;
|
||||
|
||||
void loadCinematicManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setManifest(loadedManifest);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error("GameCinematics", "Failed to load cinematic manifest", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setDialogueManifest(loadedManifest);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error("GameCinematics", "Failed to load dialogue manifest", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
stopActiveCinematic(timelineRef);
|
||||
activeAudios.forEach((audio) => audio.pause());
|
||||
activeAudios.clear();
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!manifest) return;
|
||||
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
|
||||
manifest.cinematics.forEach((cinematic) => {
|
||||
if (cinematic.timecode === undefined) return;
|
||||
if (cinematic.timecode > elapsedTime) return;
|
||||
if (cinematic.dialogueCues && !dialogueManifest) return;
|
||||
if (playedCinematicsRef.current.has(cinematic.id)) return;
|
||||
|
||||
playedCinematicsRef.current.add(cinematic.id);
|
||||
playCinematic(camera, cinematic, timelineRef, {
|
||||
dialogueManifest,
|
||||
activeAudiosRef,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stopActiveCinematic(
|
||||
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
|
||||
): void {
|
||||
timelineRef.current?.kill();
|
||||
timelineRef.current = null;
|
||||
}
|
||||
|
||||
function playCinematic(
|
||||
camera: THREE.Camera,
|
||||
cinematic: CinematicDefinition,
|
||||
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
|
||||
dialogueOptions: {
|
||||
dialogueManifest: DialogueManifest | null;
|
||||
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
||||
},
|
||||
): void {
|
||||
const firstKeyframe = cinematic.cameraKeyframes[0];
|
||||
if (!firstKeyframe) return;
|
||||
|
||||
document.exitPointerLock();
|
||||
timelineRef.current?.kill();
|
||||
useGameStore.getState().setCinematicPlaying(true);
|
||||
|
||||
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;
|
||||
useGameStore.getState().setCinematicPlaying(false);
|
||||
},
|
||||
});
|
||||
|
||||
cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
|
||||
const previousKeyframe = 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,
|
||||
);
|
||||
});
|
||||
|
||||
cinematic.dialogueCues?.forEach((cue) => {
|
||||
timeline.call(
|
||||
() => {
|
||||
if (!dialogueOptions.dialogueManifest) return;
|
||||
|
||||
void queueDialogueById(
|
||||
dialogueOptions.dialogueManifest,
|
||||
cue.dialogueId,
|
||||
).then((audio) => {
|
||||
if (!audio) return;
|
||||
|
||||
dialogueOptions.activeAudiosRef.current.add(audio);
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => dialogueOptions.activeAudiosRef.current.delete(audio),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
cue.time,
|
||||
);
|
||||
});
|
||||
|
||||
timelineRef.current = timeline;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
clearQueuedDialogues,
|
||||
queueDialogueById,
|
||||
} from "@/utils/dialogues/playDialogue";
|
||||
import { logger } from "@/utils/core/logger";
|
||||
|
||||
export function GameDialogues(): null {
|
||||
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||
const playedDialoguesRef = useRef(new Set<string>());
|
||||
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const activeAudios = activeAudiosRef.current;
|
||||
|
||||
void loadDialogueManifest()
|
||||
.then((loadedManifest) => {
|
||||
if (mounted) setManifest(loadedManifest);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error("GameDialogues", "Failed to load dialogue manifest", {
|
||||
error: error instanceof Error ? error : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearQueuedDialogues();
|
||||
activeAudios.forEach((audio) => audio.pause());
|
||||
activeAudios.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!manifest) return;
|
||||
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
|
||||
manifest.dialogues.forEach((dialogue) => {
|
||||
if (dialogue.timecode === undefined) return;
|
||||
if (dialogue.timecode > elapsedTime) return;
|
||||
if (playedDialoguesRef.current.has(dialogue.id)) return;
|
||||
|
||||
playedDialoguesRef.current.add(dialogue.id);
|
||||
|
||||
void queueDialogueById(manifest, dialogue.id).then((audio) => {
|
||||
if (!audio) return;
|
||||
activeAudiosRef.current.add(audio);
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => activeAudiosRef.current.delete(audio),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -65,11 +65,13 @@ interface GameMapProps {
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
buildOctree?: boolean;
|
||||
}
|
||||
|
||||
const MAP_RENDER_BATCH_SIZE = 12;
|
||||
|
||||
export function GameMap({
|
||||
buildOctree = true,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
@@ -197,6 +199,7 @@ export function GameMap({
|
||||
))}
|
||||
</group>
|
||||
<GameMapCollision
|
||||
buildOctree={buildOctree}
|
||||
mapReady={mapReady}
|
||||
nodes={mapNodes}
|
||||
onLoaded={onLoaded}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ResolvedGameMapCollisionNode {
|
||||
}
|
||||
|
||||
interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
nodes: readonly GameMapCollisionNode[];
|
||||
onLoaded?: (() => void) | undefined;
|
||||
@@ -92,6 +93,7 @@ function isCollisionNode(
|
||||
}
|
||||
|
||||
export function GameMapCollision({
|
||||
buildOctree = true,
|
||||
mapReady,
|
||||
nodes,
|
||||
onLoaded,
|
||||
@@ -129,7 +131,7 @@ export function GameMapCollision({
|
||||
groupRef,
|
||||
handleOctreeReady,
|
||||
collisionReady ? collisionNodes.length : 0,
|
||||
collisionReady && collisionNodes.length > 0,
|
||||
buildOctree && collisionReady && collisionNodes.length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+27
-8
@@ -12,6 +12,8 @@ import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControl
|
||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||
import { Environment } from "@/world/Environment";
|
||||
import { GameCinematics } from "@/world/GameCinematics";
|
||||
import { GameDialogues } from "@/world/GameDialogues";
|
||||
import { GameMusic } from "@/world/GameMusic";
|
||||
import { Lighting } from "@/world/Lighting";
|
||||
import { GameMap } from "@/world/GameMap";
|
||||
@@ -24,12 +26,23 @@ 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 { 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 playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
@@ -52,13 +65,18 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
{sceneMode === "game" ? (
|
||||
<>
|
||||
<GameMusic />
|
||||
<GameMap
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={handleOctreeReady}
|
||||
/>
|
||||
{showGameStage ? (
|
||||
{noMusic ? null : <GameMusic />}
|
||||
{noCinematics ? null : <GameCinematics />}
|
||||
{noDialogues ? null : <GameDialogues />}
|
||||
{noMap ? null : (
|
||||
<GameMap
|
||||
buildOctree={!noOctree}
|
||||
onLoaded={handleGameMapLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={handleOctreeReady}
|
||||
/>
|
||||
)}
|
||||
{noMap || showGameStage ? (
|
||||
<Physics>
|
||||
<GameStageContent />
|
||||
</Physics>
|
||||
@@ -67,7 +85,8 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
) : (
|
||||
<TestMap onOctreeReady={handleOctreeReady} />
|
||||
)}
|
||||
{cameraMode !== "debug" ? (
|
||||
|
||||
{cameraMode !== "debug" && !noPlayer ? (
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
type Keys = {
|
||||
@@ -55,6 +57,13 @@ const _up = new THREE.Vector3(0, 1, 0);
|
||||
const _translateVec = new THREE.Vector3();
|
||||
const _collisionCorrection = new THREE.Vector3();
|
||||
|
||||
function isPlayerInputLocked(): boolean {
|
||||
return (
|
||||
useSettingsStore.getState().isSettingsMenuOpen ||
|
||||
useGameStore.getState().isCinematicPlaying
|
||||
);
|
||||
}
|
||||
|
||||
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case MOVE_FORWARD_KEY:
|
||||
@@ -122,6 +131,8 @@ export function PlayerController({
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, true)) {
|
||||
if (movementLockedRef.current) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
@@ -151,12 +162,15 @@ export function PlayerController({
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
|
||||
if (setMovementKey(keys.current, event.key, false)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||
if (interaction.getState().focused?.kind === "grab") {
|
||||
interaction.pressInteract();
|
||||
@@ -164,6 +178,7 @@ export function PlayerController({
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent): void => {
|
||||
if (isPlayerInputLocked()) return;
|
||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||
if (interaction.getState().holding) {
|
||||
interaction.releaseInteract();
|
||||
@@ -185,6 +200,13 @@ export function PlayerController({
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (isPlayerInputLocked()) {
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
velocity.current.set(0, 0, 0);
|
||||
wantsJump.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||
|
||||
camera.getWorldDirection(_forward);
|
||||
|
||||
Reference in New Issue
Block a user