✨ 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:
174
frontend/app/composables/useEasterEggDetection.ts
Normal file
174
frontend/app/composables/useEasterEggDetection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
83
frontend/app/composables/useFetchEasterEggs.ts
Normal file
83
frontend/app/composables/useFetchEasterEggs.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export type NarratorContext =
|
||||
| 'intro'
|
||||
| 'intro_sequence_1'
|
||||
| 'intro_sequence_2'
|
||||
| 'intro_sequence_3'
|
||||
| 'transition_projects'
|
||||
| 'transition_skills'
|
||||
| 'transition_testimonials'
|
||||
|
||||
153
frontend/app/composables/useNarrativePath.ts
Normal file
153
frontend/app/composables/useNarrativePath.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user