✨ 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:
20
frontend/app/composables/useReducedMotion.ts
Normal file
20
frontend/app/composables/useReducedMotion.ts
Normal 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)
|
||||
}
|
||||
84
frontend/app/composables/useTypewriter.ts
Normal file
84
frontend/app/composables/useTypewriter.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user