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:
2026-02-08 13:35:12 +01:00
parent 64b1a33d10
commit 7e87a341a2
38 changed files with 3037 additions and 96 deletions

View File

@@ -0,0 +1,174 @@
import type { EasterEggMeta } from './useFetchEasterEggs'
interface UseEasterEggDetectionOptions {
onFound: (slug: string) => void
}
// Konami Code
const KONAMI_CODE = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'KeyB',
'KeyA',
]
export function useEasterEggDetection(options: UseEasterEggDetectionOptions) {
const { fetchList } = useFetchEasterEggs()
const progressionStore = useProgressionStore()
// State
const konamiIndex = ref(0)
const clickSequence = ref<string[]>([])
let konamiListenerAdded = false
// Load easter eggs on mount
onMounted(() => {
fetchList()
initKonamiListener()
})
// === Konami Code ===
function initKonamiListener() {
if (import.meta.client && !konamiListenerAdded) {
window.addEventListener('keydown', handleKonamiKey)
konamiListenerAdded = true
}
}
function handleKonamiKey(e: KeyboardEvent) {
if (e.code === KONAMI_CODE[konamiIndex.value]) {
konamiIndex.value++
if (konamiIndex.value === KONAMI_CODE.length) {
triggerEasterEgg('konami-master')
konamiIndex.value = 0
}
} else {
konamiIndex.value = 0
}
}
// === Click Detection ===
function detectClick(targetSlug: string, requiredClicks: number = 1) {
const clicks = ref(0)
function handleClick() {
clicks.value++
if (clicks.value >= requiredClicks) {
triggerEasterEgg(targetSlug)
clicks.value = 0
}
}
return { handleClick, clicks }
}
// === Hover Detection ===
function detectHover(targetSlug: string, hoverTime: number = 2000) {
let timeoutId: ReturnType<typeof setTimeout> | null = null
function handleMouseEnter() {
timeoutId = setTimeout(() => {
triggerEasterEgg(targetSlug)
}, hoverTime)
}
function handleMouseLeave() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
}
return { handleMouseEnter, handleMouseLeave }
}
// === Scroll Detection ===
function detectScrollBottom(targetSlug: string) {
let triggered = false
function checkScroll() {
if (triggered) return
const scrollTop = window.scrollY
const windowHeight = window.innerHeight
const docHeight = document.documentElement.scrollHeight
if (scrollTop + windowHeight >= docHeight - 50) {
triggerEasterEgg(targetSlug)
triggered = true
}
}
onMounted(() => {
if (import.meta.client) {
window.addEventListener('scroll', checkScroll, { passive: true })
}
})
onUnmounted(() => {
if (import.meta.client) {
window.removeEventListener('scroll', checkScroll)
}
})
}
// === Sequence Detection ===
function detectSequence(expectedSequence: string[], targetSlug: string) {
function addToSequence(item: string) {
clickSequence.value.push(item)
// Keep only the last N items
if (clickSequence.value.length > expectedSequence.length) {
clickSequence.value.shift()
}
// Check if sequence matches
if (clickSequence.value.length === expectedSequence.length) {
const match = clickSequence.value.every(
(val, idx) => val === expectedSequence[idx]
)
if (match) {
triggerEasterEgg(targetSlug)
clickSequence.value = []
}
}
}
return { addToSequence }
}
// === Trigger Easter Egg ===
function triggerEasterEgg(slug: string) {
// Check if already found
if (progressionStore.easterEggsFound.includes(slug)) {
return
}
// Mark as found
progressionStore.markEasterEggFound(slug)
// Notify
options.onFound(slug)
}
onUnmounted(() => {
if (import.meta.client && konamiListenerAdded) {
window.removeEventListener('keydown', handleKonamiKey)
konamiListenerAdded = false
}
})
return {
detectClick,
detectHover,
detectScrollBottom,
detectSequence,
triggerEasterEgg,
}
}

View File

@@ -0,0 +1,83 @@
export type TriggerType = 'click' | 'hover' | 'konami' | 'scroll' | 'sequence'
export type RewardType = 'snippet' | 'anecdote' | 'image' | 'badge'
export interface EasterEggMeta {
slug: string
location: string
trigger_type: TriggerType
difficulty: number
}
export interface EasterEggReward {
slug: string
reward_type: RewardType
reward: string
difficulty: number
}
interface EasterEggListResponse {
data: EasterEggMeta[]
meta: { total: number }
}
interface EasterEggValidateResponse {
data: EasterEggReward
}
export function useFetchEasterEggs() {
const config = useRuntimeConfig()
const { locale } = useI18n()
// Cache des easter eggs disponibles
const availableEasterEggs = ref<EasterEggMeta[]>([])
const isLoaded = ref(false)
async function fetchList(): Promise<EasterEggMeta[]> {
if (isLoaded.value) return availableEasterEggs.value
try {
const response = await $fetch<EasterEggListResponse>('/easter-eggs', {
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
},
})
availableEasterEggs.value = response.data
isLoaded.value = true
return response.data
} catch {
return []
}
}
async function validate(slug: string): Promise<EasterEggReward | null> {
try {
const response = await $fetch<EasterEggValidateResponse>(`/easter-eggs/${slug}/validate`, {
method: 'POST',
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
'Accept-Language': locale.value,
},
})
return response.data
} catch {
return null
}
}
function getByLocation(location: string): EasterEggMeta[] {
return availableEasterEggs.value.filter(
e => e.location === location || e.location === 'global'
)
}
return {
availableEasterEggs: readonly(availableEasterEggs),
isLoaded: readonly(isLoaded),
fetchList,
validate,
getByLocation,
}
}

View File

@@ -1,5 +1,8 @@
export type NarratorContext =
| 'intro'
| 'intro_sequence_1'
| 'intro_sequence_2'
| 'intro_sequence_3'
| 'transition_projects'
| 'transition_skills'
| 'transition_testimonials'

View File

@@ -0,0 +1,153 @@
import { ZONES, NARRATIVE_PATHS, choiceIdToZone, type ZoneKey } from '~/data/narrativePaths'
import type { Choice, ChoicePoint } from '~/types/choice'
export function useNarrativePath() {
const progressionStore = useProgressionStore()
const { locale } = useI18n()
const route = useRoute()
// Déterminer le chemin actuel basé sur les choix
const currentPath = computed<ZoneKey[] | null>(() => {
const choices = progressionStore.choices
// Premier choix (intro)
const introChoice = choices['intro_first_choice']
if (!introChoice) return null
const startZone = choiceIdToZone(introChoice)
if (!startZone) return null
// Filtrer les chemins qui commencent par cette zone
let possiblePaths = NARRATIVE_PATHS.filter(path => path[0] === startZone)
// Affiner avec les choix suivants si disponibles
const afterFirstZoneChoice = choices[`after_${startZone}`]
if (afterFirstZoneChoice && possiblePaths.length > 1) {
const secondZone = choiceIdToZone(afterFirstZoneChoice)
if (secondZone) {
possiblePaths = possiblePaths.filter(path => path[1] === secondZone)
}
}
return possiblePaths[0] || null
})
// Zone actuelle basée sur la route
const currentZone = computed<ZoneKey | null>(() => {
const path = route.path.toLowerCase()
if (path.includes('projets') || path.includes('projects')) return 'projects'
if (path.includes('competences') || path.includes('skills')) return 'skills'
if (path.includes('temoignages') || path.includes('testimonials')) return 'testimonials'
if (path.includes('parcours') || path.includes('journey')) return 'journey'
if (path.includes('contact')) return 'contact'
return null
})
// Index de la zone actuelle dans le chemin
const currentZoneIndex = computed(() => {
if (!currentPath.value || !currentZone.value) return -1
return currentPath.value.indexOf(currentZone.value)
})
// Prochaine zone suggérée
const suggestedNextZone = computed<ZoneKey | null>(() => {
if (!currentPath.value || currentZoneIndex.value === -1) return null
const nextIndex = currentZoneIndex.value + 1
if (nextIndex >= currentPath.value.length) return null
return currentPath.value[nextIndex]
})
// Zones restantes à visiter (excluant contact)
const remainingZones = computed<ZoneKey[]>(() => {
const mainZones: ZoneKey[] = ['projects', 'skills', 'testimonials', 'journey']
const visited = progressionStore.visitedSections
return mainZones.filter(zone => !visited.includes(zone))
})
// Obtenir la route pour une zone
function getZoneRoute(zone: ZoneKey): string {
const zoneInfo = ZONES[zone]
if (!zoneInfo) return '/'
return locale.value === 'fr' ? zoneInfo.routeFr : zoneInfo.routeEn
}
// Générer le ChoicePoint pour après la zone actuelle
function getNextChoicePoint(): ChoicePoint | null {
if (remainingZones.value.length === 0) {
// Plus de zones, proposer le contact uniquement
return {
id: 'go_to_contact',
questionFr: 'Tu as tout exploré ! Prêt à me rencontrer ?',
questionEn: 'You explored everything! Ready to meet me?',
choices: [
createChoice('contact'),
createChoice('contact'), // Dupliqué car ChoicePoint attend 2 choices
],
context: 'contact_ready',
}
}
// Proposer les 2 prochaines zones non visitées
const nextTwo = remainingZones.value.slice(0, 2)
if (nextTwo.length === 1) {
// Une seule zone restante, proposer zone + contact
return {
id: `after_${currentZone.value}`,
questionFr: 'Où vas-tu ensuite ?',
questionEn: 'Where to next?',
choices: [
createChoice(nextTwo[0]),
createChoice('contact'),
],
context: 'one_zone_left',
}
}
return {
id: `after_${currentZone.value}`,
questionFr: 'Où vas-tu ensuite ?',
questionEn: 'Where to next?',
choices: [
createChoice(nextTwo[0]),
createChoice(nextTwo[1]),
],
context: 'zone_choice',
}
}
// Créer un objet Choice pour une zone
function createChoice(zone: ZoneKey): Choice {
const zoneInfo = ZONES[zone]
return {
id: `choice_${zone}`,
textFr: zoneInfo.labelFr,
textEn: zoneInfo.labelEn,
icon: zoneInfo.icon,
destination: getZoneRoute(zone),
zoneColor: zoneInfo.color,
}
}
// Vérifier si le contact est débloqué
const isContactReady = computed(() => {
return remainingZones.value.length === 0 || progressionStore.isContactUnlocked
})
return {
currentPath,
currentZone,
currentZoneIndex,
suggestedNextZone,
remainingZones,
isContactReady,
getZoneRoute,
getNextChoicePoint,
createChoice,
}
}