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 { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { SiteButton } from "@/components/site/SiteButton";
|
||||
import { SITE_CONFIG } from "@/data/site/siteConfig";
|
||||
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
loadDialogueManifest,
|
||||
loadDialogueSubtitleCues,
|
||||
} from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
playDialogueById,
|
||||
stopCurrentDialogue,
|
||||
} 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
|
||||
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
|
||||
* field reveals one letter per keystroke until the preset name is complete.
|
||||
* Screen 3: Name reveal
|
||||
* The player's preset name is revealed letter-by-letter inside the input
|
||||
* 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 {
|
||||
const setStep = useSiteStore((state) => state.setStep);
|
||||
const setPlayerName = useGameStore((state) => state.setPlayerName);
|
||||
const [charIndex, setCharIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [revealedChars, setRevealedChars] = useState(0);
|
||||
const [typewriterStarted, setTypewriterStarted] = useState(false);
|
||||
|
||||
const presetPlayerName = SITE_CONFIG.presetPlayerName;
|
||||
const displayValue = presetPlayerName.slice(0, charIndex);
|
||||
const isComplete = charIndex >= presetPlayerName.length;
|
||||
const displayValue = presetPlayerName.slice(0, revealedChars);
|
||||
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(() => {
|
||||
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 () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (cancelled || !manifest) return;
|
||||
await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
|
||||
if (cancelled) return;
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
||||
if (audioElement) {
|
||||
if (onTimeUpdate) {
|
||||
audioElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
}
|
||||
audioElement.removeEventListener("ended", start);
|
||||
}
|
||||
stopCurrentDialogue();
|
||||
};
|
||||
}, []);
|
||||
}, [presetPlayerName.length]);
|
||||
|
||||
// Reveal the preset name one character at a time once the typewriter
|
||||
// has been triggered.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const nextLength = Math.min(
|
||||
event.target.value.length,
|
||||
presetPlayerName.length,
|
||||
);
|
||||
setCharIndex(nextLength);
|
||||
},
|
||||
[presetPlayerName.length],
|
||||
);
|
||||
if (!typewriterStarted) return;
|
||||
const interval = setInterval(() => {
|
||||
setRevealedChars((current) => {
|
||||
if (current >= presetPlayerName.length) {
|
||||
clearInterval(interval);
|
||||
return current;
|
||||
}
|
||||
return current + 1;
|
||||
});
|
||||
}, TYPEWRITER_CHAR_DELAY_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [typewriterStarted, presetPlayerName.length]);
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (isComplete) {
|
||||
@@ -98,17 +172,16 @@ export function SiteNamingScreen(): React.JSX.Element {
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Quel est votre prénom ?
|
||||
Je suis…
|
||||
</h2>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Écrivez votre prénom ici"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-labelledby="player-name-label"
|
||||
aria-describedby="player-name-hint"
|
||||
aria-live="polite"
|
||||
autoComplete="off"
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -122,30 +195,12 @@ export function SiteNamingScreen(): React.JSX.Element {
|
||||
background: "#D9D9D9",
|
||||
outline: "none",
|
||||
color: "#333",
|
||||
caretColor: "#333",
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
fontSize: "clamp(16px, 2.5vw, 20px)",
|
||||
textAlign: "left",
|
||||
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>
|
||||
|
||||
<SiteButton
|
||||
|
||||
Reference in New Issue
Block a user