🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet : - Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS) - Backend Laravel 12 API-only avec middleware X-API-Key et CORS - Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter) - Documentation planning et implementation artifacts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# Story 4.2: Intro narrative et premier choix
|
||||
|
||||
Status: ready-for-dev
|
||||
|
||||
## Story
|
||||
|
||||
As a visiteur aventurier,
|
||||
I want une introduction narrative captivante suivie d'un premier choix,
|
||||
so that je suis immergé dès le début de l'aventure.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **Given** le visiteur a sélectionné son héros sur la landing page **When** il commence l'aventure **Then** une séquence d'intro narrative s'affiche avec le narrateur (Le Bug)
|
||||
2. **And** le texte présente le "héros mystérieux" (le développeur) à découvrir
|
||||
3. **And** l'effet typewriter anime le texte (skippable par clic/Espace)
|
||||
4. **And** l'ambiance visuelle est immersive (fond sombre, illustrations)
|
||||
5. **And** un bouton "Continuer" permet d'avancer
|
||||
6. **And** à la fin de l'intro, le premier choix binaire s'affiche via `ChoiceCards`
|
||||
7. **And** le choix propose deux zones à explorer en premier (ex: Projets vs Compétences)
|
||||
8. **And** le contenu est bilingue (FR/EN) et adapté au héros (vouvoiement/tutoiement)
|
||||
9. **And** la durée de l'intro est courte (15-30s max, skippable)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] **Task 1: Créer les textes d'intro dans l'API** (AC: #2, #8)
|
||||
- [ ] Ajouter les contextes `intro_sequence_1`, `intro_sequence_2`, `intro_sequence_3` dans narrator_texts
|
||||
- [ ] Variantes pour chaque type de héros (vouvoiement/tutoiement)
|
||||
- [ ] Textes mystérieux présentant le développeur
|
||||
|
||||
- [ ] **Task 2: Créer la page intro** (AC: #1, #4, #9)
|
||||
- [ ] Créer `frontend/app/pages/intro.vue`
|
||||
- [ ] Rediriger automatiquement depuis landing après choix du héros
|
||||
- [ ] Fond sombre avec ambiance mystérieuse
|
||||
- [ ] Structure en étapes (séquences de texte)
|
||||
|
||||
- [ ] **Task 3: Implémenter la séquence narrative** (AC: #2, #3, #5)
|
||||
- [ ] Créer composant `IntroSequence.vue`
|
||||
- [ ] Afficher le Bug avec le texte en typewriter
|
||||
- [ ] Bouton "Continuer" pour passer à l'étape suivante
|
||||
- [ ] Clic/Espace pour skip le typewriter
|
||||
- [ ] 3-4 séquences de texte courtes
|
||||
|
||||
- [ ] **Task 4: Ajouter les illustrations d'ambiance** (AC: #4)
|
||||
- [ ] Illustrations de fond (toiles d'araignée, ombres, code flottant)
|
||||
- [ ] Animation subtile sur les éléments de fond
|
||||
- [ ] Cohérence avec l'univers de Le Bug
|
||||
|
||||
- [ ] **Task 5: Intégrer le premier choix** (AC: #6, #7)
|
||||
- [ ] Après la dernière séquence, afficher ChoiceCards
|
||||
- [ ] Choix : Projets vs Compétences
|
||||
- [ ] La sélection navigue vers la zone choisie
|
||||
|
||||
- [ ] **Task 6: Gérer le skip global** (AC: #9)
|
||||
- [ ] Bouton discret "Passer l'intro" visible en permanence
|
||||
- [ ] Navigation directe vers le choix si skip
|
||||
- [ ] Enregistrer dans le store que l'intro a été vue/skip
|
||||
|
||||
- [ ] **Task 7: Tests et validation**
|
||||
- [ ] Tester le flow complet
|
||||
- [ ] Vérifier les 3 types de héros (textes adaptés)
|
||||
- [ ] Tester FR et EN
|
||||
- [ ] Valider la durée (< 30s)
|
||||
- [ ] Tester le skip intro
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Textes d'intro (exemples)
|
||||
|
||||
```php
|
||||
// À ajouter dans NarratorTextSeeder.php
|
||||
|
||||
// Intro séquence 1 - Recruteur (vouvoiement)
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.recruteur', 'variant' => 1, 'hero_type' => 'recruteur'],
|
||||
|
||||
// Intro séquence 1 - Client/Dev (tutoiement)
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'client'],
|
||||
['context' => 'intro_sequence_1', 'text_key' => 'narrator.intro_seq.1.casual', 'variant' => 1, 'hero_type' => 'dev'],
|
||||
|
||||
// Traductions
|
||||
['key' => 'narrator.intro_seq.1.recruteur', 'fr' => "Bienvenue dans mon domaine, voyageur... Je suis Le Bug, et je vais vous guider dans cette aventure.", 'en' => "Welcome to my domain, traveler... I am The Bug, and I will guide you through this adventure."],
|
||||
['key' => 'narrator.intro_seq.1.casual', 'fr' => "Hey ! Bienvenue chez moi. Je suis Le Bug, ton guide pour cette aventure.", 'en' => "Hey! Welcome to my place. I'm The Bug, your guide for this adventure."],
|
||||
|
||||
['key' => 'narrator.intro_seq.2', 'fr' => "Il y a quelqu'un ici que tu cherches... Un développeur mystérieux qui a créé tout ce que tu vois autour de toi.", 'en' => "There's someone here you're looking for... A mysterious developer who created everything you see around you."],
|
||||
|
||||
['key' => 'narrator.intro_seq.3', 'fr' => "Pour le trouver, tu devras explorer ce monde. Chaque zone cache une partie de son histoire. Es-tu prêt ?", 'en' => "To find them, you'll have to explore this world. Each zone hides a part of their story. Are you ready?"],
|
||||
```
|
||||
|
||||
### Page intro.vue
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/pages/intro.vue -->
|
||||
<script setup lang="ts">
|
||||
import { CHOICE_POINTS } from '~/types/choice'
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Rediriger si pas de héros sélectionné
|
||||
if (!progressionStore.heroType) {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
// Étapes de la séquence
|
||||
const steps = ['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)
|
||||
|
||||
const { fetchText } = useFetchNarratorText()
|
||||
|
||||
async function loadCurrentText() {
|
||||
if (isChoiceStep.value) return
|
||||
|
||||
const response = await fetchText(currentStep.value, progressionStore.heroType || undefined)
|
||||
currentText.value = response.data.text
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Charger le premier texte
|
||||
onMounted(() => {
|
||||
loadCurrentText()
|
||||
})
|
||||
|
||||
// Marquer l'intro comme vue
|
||||
onUnmounted(() => {
|
||||
progressionStore.setIntroSeen(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="intro-page min-h-screen bg-sky-dark relative overflow-hidden">
|
||||
<!-- Fond d'ambiance -->
|
||||
<IntroBackground />
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<IntroSequence
|
||||
: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-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ isLastTextStep ? t('intro.startExploring') : t('intro.continue') }}
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Choix après l'intro -->
|
||||
<div v-else class="w-full max-w-3xl mx-auto">
|
||||
<ChoiceCards :choice-point="CHOICE_POINTS.intro_first_choice" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Bouton skip (toujours visible) -->
|
||||
<button
|
||||
v-if="!isChoiceStep"
|
||||
type="button"
|
||||
class="absolute bottom-8 right-8 text-sky-text-muted hover:text-sky-text text-sm font-ui underline transition-colors"
|
||||
@click="skipIntro"
|
||||
>
|
||||
{{ t('intro.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Composant IntroSequence
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/IntroSequence.vue -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const progressionStore = useProgressionStore()
|
||||
const { displayedText, isTyping, isComplete, start, skip } = useTypewriter({
|
||||
speed: 35,
|
||||
onComplete: () => emit('complete'),
|
||||
})
|
||||
|
||||
// Bug image selon le stage (toujours stage 1 au début)
|
||||
const bugImage = '/images/bug/bug-stage-1.svg'
|
||||
|
||||
// Démarrer le typewriter quand le texte change
|
||||
watch(() => props.text, (newText) => {
|
||||
if (newText) {
|
||||
start(newText)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function handleInteraction() {
|
||||
if (isTyping.value) {
|
||||
skip()
|
||||
emit('skip')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInteraction()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="intro-sequence"
|
||||
@click="handleInteraction"
|
||||
@keydown="handleKeydown"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar du Bug -->
|
||||
<div class="mb-8">
|
||||
<img
|
||||
:src="bugImage"
|
||||
alt="Le Bug"
|
||||
class="w-32 h-32 mx-auto animate-float"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Texte avec typewriter -->
|
||||
<div class="bg-sky-dark-50/80 backdrop-blur rounded-xl p-8 border border-sky-dark-100">
|
||||
<p class="font-narrative text-xl md:text-2xl text-sky-text leading-relaxed">
|
||||
{{ displayedText }}
|
||||
<span
|
||||
v-if="isTyping"
|
||||
class="inline-block w-0.5 h-6 bg-sky-accent animate-blink ml-1"
|
||||
></span>
|
||||
</p>
|
||||
|
||||
<!-- Indication pour skip -->
|
||||
<p
|
||||
v-if="isTyping"
|
||||
class="text-sm text-sky-text-muted mt-4 font-ui"
|
||||
>
|
||||
{{ $t('narrator.clickToSkip') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.intro-sequence:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-float {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Composant IntroBackground
|
||||
|
||||
```vue
|
||||
<!-- frontend/app/components/feature/IntroBackground.vue -->
|
||||
<script setup lang="ts">
|
||||
const reducedMotion = useReducedMotion()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="intro-background absolute inset-0 overflow-hidden">
|
||||
<!-- Gradient de fond -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-sky-dark via-sky-dark-50 to-sky-dark"></div>
|
||||
|
||||
<!-- Particules flottantes (code fragments) -->
|
||||
<div
|
||||
v-if="!reducedMotion"
|
||||
class="particles absolute inset-0"
|
||||
>
|
||||
<div
|
||||
v-for="i in 20"
|
||||
:key="i"
|
||||
class="particle absolute text-sky-accent/10 font-mono text-xs"
|
||||
:style="{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
animationDuration: `${10 + Math.random() * 10}s`,
|
||||
}"
|
||||
>
|
||||
{{ ['</', '/>', '{}', '[]', '()', '=>', '&&', '||'][i % 8] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toile d'araignée stylisée (SVG) -->
|
||||
<svg
|
||||
class="absolute top-0 right-0 w-64 h-64 text-sky-dark-100/30"
|
||||
viewBox="0 0 200 200"
|
||||
>
|
||||
<path
|
||||
d="M100,100 L100,0 M100,100 L200,100 M100,100 L100,200 M100,100 L0,100 M100,100 L170,30 M100,100 L170,170 M100,100 L30,170 M100,100 L30,30"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="100" cy="100" r="30" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
<circle cx="100" cy="100" r="60" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
<circle cx="100" cy="100" r="90" stroke="currentColor" stroke-width="1" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes float-up {
|
||||
from {
|
||||
transform: translateY(100vh) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-100vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.particle {
|
||||
animation: float-up linear infinite;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Clés i18n
|
||||
|
||||
**fr.json :**
|
||||
```json
|
||||
{
|
||||
"intro": {
|
||||
"continue": "Continuer",
|
||||
"startExploring": "Commencer l'exploration",
|
||||
"skip": "Passer l'intro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**en.json :**
|
||||
```json
|
||||
{
|
||||
"intro": {
|
||||
"continue": "Continue",
|
||||
"startExploring": "Start exploring",
|
||||
"skip": "Skip intro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dépendances
|
||||
|
||||
**Cette story nécessite :**
|
||||
- Story 3.1 : API narrateur (contextes intro_sequence_*)
|
||||
- Story 3.2 : NarratorBubble et useTypewriter
|
||||
- Story 4.1 : ChoiceCards pour le premier choix
|
||||
- Story 1.5 : Landing page (choix du héros)
|
||||
|
||||
**Cette story prépare pour :**
|
||||
- Story 4.3 : Chemins narratifs (suite de l'aventure)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**Fichiers à créer :**
|
||||
```
|
||||
frontend/app/
|
||||
├── pages/
|
||||
│ └── intro.vue # CRÉER
|
||||
└── components/feature/
|
||||
├── IntroSequence.vue # CRÉER
|
||||
└── IntroBackground.vue # CRÉER
|
||||
```
|
||||
|
||||
**Fichiers à modifier :**
|
||||
```
|
||||
api/database/seeders/NarratorTextSeeder.php # AJOUTER intro_sequence_*
|
||||
frontend/i18n/fr.json # AJOUTER intro.*
|
||||
frontend/i18n/en.json # AJOUTER intro.*
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- [Source: docs/planning-artifacts/epics.md#Story-4.2]
|
||||
- [Source: docs/planning-artifacts/ux-design-specification.md#Intro-Sequence]
|
||||
- [Source: docs/brainstorming-gamification-2026-01-26.md#Onboarding]
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| Requirement | Value | Source |
|
||||
|-------------|-------|--------|
|
||||
| Durée intro | 15-30s max (skippable) | Epics |
|
||||
| Séquences | 3-4 textes courts | Décision technique |
|
||||
| Premier choix | Projets vs Compétences | Epics |
|
||||
| Adaptation héros | Vouvoiement/tutoiement | UX Spec |
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
{{agent_model_name_version}}
|
||||
|
||||
### Debug Log References
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
### Change Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
||||
|
||||
### File List
|
||||
|
||||
Reference in New Issue
Block a user