✨ feat(epic-4): chemins narratifs, easter eggs, challenge et contact
Epic 4: Chemins Narratifs, Challenge & Contact Stories implementees: - 4.1: Composant ChoiceCards pour choix narratifs binaires - 4.2: Sequence d'intro narrative avec Le Bug - 4.3: Chemins narratifs differencies avec useNarrativePath - 4.4: Table easter_eggs et systeme de detection (API + composable) - 4.5: Easter eggs UI (popup, notification, collection) - 4.6: Page challenge avec puzzle de code - 4.7: Page revelation "Monde de Code" - 4.8: Page contact avec formulaire et stats Fichiers crees: - Frontend: ChoiceCards, IntroSequence, ZoneEndChoice, EasterEggPopup, CodePuzzle, ChallengeSuccess, CodeWorld, et pages intro/challenge/revelation - API: EasterEggController, Model, Migration, Seeder Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
frontend/app/pages/intro.vue
Normal file
154
frontend/app/pages/intro.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="intro-page min-h-screen bg-sky-dark relative overflow-hidden">
|
||||
<!-- Fond d'ambiance -->
|
||||
<FeatureIntroBackground />
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="relative z-10 flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<!-- Séquence narrative -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="!isChoiceStep"
|
||||
:key="currentStep"
|
||||
class="max-w-2xl mx-auto text-center"
|
||||
>
|
||||
<FeatureIntroSequence
|
||||
:text="currentText"
|
||||
@complete="handleTextComplete"
|
||||
@skip="handleTextComplete"
|
||||
/>
|
||||
|
||||
<!-- Bouton continuer -->
|
||||
<Transition name="fade">
|
||||
<button
|
||||
v-if="isTextComplete"
|
||||
type="button"
|
||||
class="mt-8 px-8 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity focus-visible:outline-2 focus-visible:outline-sky-accent focus-visible:outline-offset-2"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ isLastTextStep ? $t('intro.startExploring') : $t('intro.continue') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Choix après l'intro -->
|
||||
<div v-else key="choice" class="w-full max-w-3xl mx-auto">
|
||||
<FeatureChoiceCards :choice-point="introChoicePoint" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bouton skip (toujours visible sauf sur le choix) -->
|
||||
<button
|
||||
v-if="!isChoiceStep"
|
||||
type="button"
|
||||
class="absolute bottom-8 right-8 text-sky-text/50 hover:text-sky-text text-sm font-ui underline transition-colors focus-visible:outline-2 focus-visible:outline-sky-accent"
|
||||
@click="skipIntro"
|
||||
>
|
||||
{{ $t('intro.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CHOICE_POINTS } from '~/types/choice'
|
||||
import type { NarratorContext } from '~/composables/useFetchNarratorText'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'adventure',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const progressionStore = useProgressionStore()
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
|
||||
// Rediriger si pas de héros sélectionné
|
||||
onMounted(() => {
|
||||
if (!progressionStore.hero) {
|
||||
navigateTo(localePath('/'))
|
||||
}
|
||||
})
|
||||
|
||||
// Étapes de la séquence
|
||||
const steps: (NarratorContext | 'choice')[] = ['intro_sequence_1', 'intro_sequence_2', 'intro_sequence_3', 'choice']
|
||||
const currentStepIndex = ref(0)
|
||||
|
||||
const currentStep = computed(() => steps[currentStepIndex.value])
|
||||
const isLastTextStep = computed(() => currentStepIndex.value === steps.length - 2)
|
||||
const isChoiceStep = computed(() => currentStep.value === 'choice')
|
||||
|
||||
// Texte actuel
|
||||
const currentText = ref('')
|
||||
const isTextComplete = ref(false)
|
||||
|
||||
// Point de choix pour l'intro
|
||||
const introChoicePoint = CHOICE_POINTS.intro_first_choice
|
||||
|
||||
// Fallback texts
|
||||
const fallbackTexts: Record<string, string> = {
|
||||
intro_sequence_1: t('intro.fallback.seq1'),
|
||||
intro_sequence_2: t('intro.fallback.seq2'),
|
||||
intro_sequence_3: t('intro.fallback.seq3'),
|
||||
}
|
||||
|
||||
async function loadCurrentText() {
|
||||
if (isChoiceStep.value) return
|
||||
|
||||
const context = currentStep.value as NarratorContext
|
||||
const response = await fetchText(context, progressionStore.hero || undefined)
|
||||
|
||||
if (response?.text) {
|
||||
currentText.value = response.text
|
||||
} else {
|
||||
// Fallback si l'API n'est pas disponible
|
||||
currentText.value = fallbackTexts[context] || ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextComplete() {
|
||||
isTextComplete.value = true
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStepIndex.value < steps.length - 1) {
|
||||
currentStepIndex.value++
|
||||
isTextComplete.value = false
|
||||
loadCurrentText()
|
||||
}
|
||||
}
|
||||
|
||||
function skipIntro() {
|
||||
currentStepIndex.value = steps.length - 1 // Aller directement au choix
|
||||
progressionStore.setIntroSeen(true)
|
||||
}
|
||||
|
||||
// Charger le premier texte
|
||||
onMounted(() => {
|
||||
loadCurrentText()
|
||||
})
|
||||
|
||||
// Marquer l'intro comme vue quand on quitte
|
||||
onBeforeUnmount(() => {
|
||||
progressionStore.setIntroSeen(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user