Add DialoguePNJ component with typewriter effect (Story 2.7)

- Create useReducedMotion composable for motion preferences
- Create useTypewriter composable with accelerate/skip support
- Add DialoguePNJ component with Zelda-style dialogue system
- Add personality-based styling (sage, sarcastique, enthousiaste, professionnel)
- Implement keyboard navigation (arrows, space)
- Add toggle between dialogue and list view modes
- Add i18n translations for dialogue UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 11:07:40 +01:00
parent 1cba01595b
commit cfc9cca34f
8 changed files with 522 additions and 60 deletions

View File

@@ -0,0 +1,20 @@
export function useReducedMotion() {
const reducedMotion = ref(false)
if (import.meta.client) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
reducedMotion.value = mediaQuery.matches
const handler = (e: MediaQueryListEvent) => {
reducedMotion.value = e.matches
}
mediaQuery.addEventListener('change', handler)
onUnmounted(() => {
mediaQuery.removeEventListener('change', handler)
})
}
return readonly(reducedMotion)
}

View File

@@ -0,0 +1,84 @@
export interface UseTypewriterOptions {
speed?: number
speedMultiplier?: number
}
export function useTypewriter(text: Ref<string> | string, options: UseTypewriterOptions = {}) {
const { speed = 40, speedMultiplier = 5 } = options
const textValue = computed(() => toValue(text))
const displayedText = ref('')
const isTyping = ref(false)
const isAccelerated = ref(false)
let timeoutId: ReturnType<typeof setTimeout> | null = null
let currentIndex = 0
const reducedMotion = useReducedMotion()
function typeNextChar() {
if (currentIndex < textValue.value.length) {
displayedText.value = textValue.value.slice(0, currentIndex + 1)
currentIndex++
const currentSpeed = isAccelerated.value ? speed / speedMultiplier : speed
timeoutId = setTimeout(typeNextChar, currentSpeed)
} else {
isTyping.value = false
}
}
function start() {
stop()
if (reducedMotion.value) {
displayedText.value = textValue.value
isTyping.value = false
return
}
displayedText.value = ''
currentIndex = 0
isTyping.value = true
isAccelerated.value = false
typeNextChar()
}
function accelerate() {
isAccelerated.value = true
}
function skip() {
if (timeoutId) clearTimeout(timeoutId)
displayedText.value = textValue.value
isTyping.value = false
}
function stop() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
function reset() {
stop()
displayedText.value = ''
currentIndex = 0
isTyping.value = false
isAccelerated.value = false
}
onUnmounted(() => {
stop()
})
return {
displayedText: readonly(displayedText),
isTyping: readonly(isTyping),
accelerate,
skip,
reset,
start,
}
}