feat(site): sync naming typewriter to last subtitle cue
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Replace the audio "ended"-based trigger with an SRT-driven one: the typewriter now starts (lastCue.endTime - typewriterDuration) seconds into the dialogue so the final letter lands at the moment the narrator finishes speaking. Char delay shortened from 110ms to 70ms for a snappier reveal. Fallbacks: audio "ended" when no SRT, 8s absolute timer otherwise.
This commit is contained in:
@@ -1,59 +1,133 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import { SiteButton } from "@/components/site/SiteButton";
|
import { SiteButton } from "@/components/site/SiteButton";
|
||||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||||
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import {
|
||||||
|
loadDialogueManifest,
|
||||||
|
loadDialogueSubtitleCues,
|
||||||
|
} from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import {
|
import {
|
||||||
playDialogueById,
|
playDialogueById,
|
||||||
stopCurrentDialogue,
|
stopCurrentDialogue,
|
||||||
} from "@/utils/dialogues/playDialogue";
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
const TYPEWRITER_CHAR_DELAY_MS = 70;
|
||||||
|
// Fallback in case nothing else triggers the typewriter (audio failed to
|
||||||
|
// load, no subtitles, "ended" never fires). Long enough not to fire
|
||||||
|
// before the narration on a slow load.
|
||||||
|
const AUDIO_END_FALLBACK_MS = 8000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen 3: Name input
|
* Screen 3: Name reveal
|
||||||
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
|
* The player's preset name is revealed letter-by-letter inside the input
|
||||||
* field reveals one letter per keystroke until the preset name is complete.
|
* once the naming dialogue finishes playing. The confirm button stays
|
||||||
|
* locked until the reveal completes. No user typing — the input is
|
||||||
|
* read-only and just acts as a typewriter target.
|
||||||
*/
|
*/
|
||||||
export function SiteNamingScreen(): React.JSX.Element {
|
export function SiteNamingScreen(): React.JSX.Element {
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||||
const [charIndex, setCharIndex] = useState(0);
|
const [revealedChars, setRevealedChars] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [typewriterStarted, setTypewriterStarted] = useState(false);
|
||||||
|
|
||||||
const presetPlayerName = SITE_CONFIG.presetPlayerName;
|
const presetPlayerName = SITE_CONFIG.presetPlayerName;
|
||||||
const displayValue = presetPlayerName.slice(0, charIndex);
|
const displayValue = presetPlayerName.slice(0, revealedChars);
|
||||||
const isComplete = charIndex >= presetPlayerName.length;
|
const isComplete = revealedChars >= presetPlayerName.length;
|
||||||
|
|
||||||
|
// Play the dialogue, then trigger the typewriter so it FINISHES at the
|
||||||
|
// same moment the narration ends. We compute that moment from the SRT
|
||||||
|
// cues: the last cue's endTime is where the narrator stops speaking,
|
||||||
|
// so we start typing `typewriterDuration` before that.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let audioElement: HTMLAudioElement | null = null;
|
||||||
|
let onTimeUpdate: (() => void) | null = null;
|
||||||
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const start = (): void => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTypewriterStarted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const typewriterDurationSec =
|
||||||
|
(TYPEWRITER_CHAR_DELAY_MS * presetPlayerName.length) / 1000;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const manifest = await loadDialogueManifest();
|
const manifest = await loadDialogueManifest();
|
||||||
if (cancelled || !manifest) return;
|
if (cancelled) return;
|
||||||
await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
|
if (!manifest) {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the dialogue + its SRT cues for the active subtitle language.
|
||||||
|
const dialogue = manifest.dialogues.find(
|
||||||
|
(item) => item.id === SITE_DIALOGUE_IDS.naming,
|
||||||
|
);
|
||||||
|
const language = useSettingsStore.getState().subtitleLanguage;
|
||||||
|
const subtitleData = dialogue
|
||||||
|
? await loadDialogueSubtitleCues(manifest, dialogue, language)
|
||||||
|
: null;
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
audioElement = await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!audioElement) {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCue = subtitleData?.cues[subtitleData.cues.length - 1];
|
||||||
|
if (lastCue) {
|
||||||
|
// Trigger so the typewriter ends at the narration's end.
|
||||||
|
const audio = audioElement;
|
||||||
|
const triggerAt = Math.max(0, lastCue.endTime - typewriterDurationSec);
|
||||||
|
onTimeUpdate = (): void => {
|
||||||
|
if (audio.currentTime >= triggerAt) {
|
||||||
|
audio.removeEventListener("timeupdate", onTimeUpdate!);
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||||
|
} else {
|
||||||
|
// No SRT data — fall back to the audio "ended" event.
|
||||||
|
audioElement.addEventListener("ended", start, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackTimer = setTimeout(start, AUDIO_END_FALLBACK_MS);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
||||||
|
if (audioElement) {
|
||||||
|
if (onTimeUpdate) {
|
||||||
|
audioElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||||
|
}
|
||||||
|
audioElement.removeEventListener("ended", start);
|
||||||
|
}
|
||||||
stopCurrentDialogue();
|
stopCurrentDialogue();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [presetPlayerName.length]);
|
||||||
|
|
||||||
|
// Reveal the preset name one character at a time once the typewriter
|
||||||
|
// has been triggered.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
if (!typewriterStarted) return;
|
||||||
}, []);
|
const interval = setInterval(() => {
|
||||||
|
setRevealedChars((current) => {
|
||||||
const handleNameChange = useCallback(
|
if (current >= presetPlayerName.length) {
|
||||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
clearInterval(interval);
|
||||||
const nextLength = Math.min(
|
return current;
|
||||||
event.target.value.length,
|
}
|
||||||
presetPlayerName.length,
|
return current + 1;
|
||||||
);
|
});
|
||||||
setCharIndex(nextLength);
|
}, TYPEWRITER_CHAR_DELAY_MS);
|
||||||
},
|
return () => clearInterval(interval);
|
||||||
[presetPlayerName.length],
|
}, [typewriterStarted, presetPlayerName.length]);
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirm = (): void => {
|
const handleConfirm = (): void => {
|
||||||
if (isComplete) {
|
if (isComplete) {
|
||||||
@@ -98,17 +172,16 @@ export function SiteNamingScreen(): React.JSX.Element {
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Quel est votre prénom ?
|
Je suis…
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={handleNameChange}
|
readOnly
|
||||||
placeholder="Écrivez votre prénom ici"
|
tabIndex={-1}
|
||||||
aria-labelledby="player-name-label"
|
aria-labelledby="player-name-label"
|
||||||
aria-describedby="player-name-hint"
|
aria-live="polite"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -122,30 +195,12 @@ export function SiteNamingScreen(): React.JSX.Element {
|
|||||||
background: "#D9D9D9",
|
background: "#D9D9D9",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
color: "#333",
|
color: "#333",
|
||||||
caretColor: "#333",
|
|
||||||
fontFamily: "Inter, system-ui, sans-serif",
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
fontSize: "clamp(16px, 2.5vw, 20px)",
|
fontSize: "clamp(16px, 2.5vw, 20px)",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
id="player-name-hint"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
padding: 0,
|
|
||||||
margin: -1,
|
|
||||||
overflow: "hidden",
|
|
||||||
clip: "rect(0, 0, 0, 0)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
border: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Votre personnage s'appelle {presetPlayerName}. Tapez{" "}
|
|
||||||
{presetPlayerName.length} caractères pour révéler son nom.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteButton
|
<SiteButton
|
||||||
|
|||||||
Reference in New Issue
Block a user