✨ 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:
234
frontend/app/components/feature/CodePuzzle.vue
Normal file
234
frontend/app/components/feature/CodePuzzle.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="code-puzzle">
|
||||
<!-- Code zone -->
|
||||
<div class="bg-sky-dark rounded-lg border border-sky-dark-100 p-4 font-mono text-sm mb-6">
|
||||
<div
|
||||
v-for="(line, index) in shuffledLines"
|
||||
:key="index"
|
||||
class="code-line flex items-center gap-2 p-2 rounded cursor-grab transition-all"
|
||||
:class="[
|
||||
validationResult === true && 'bg-green-500/20 border-green-500/50',
|
||||
validationResult === false && line !== solution[index] && 'bg-red-500/20 border-red-500/50',
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, index)"
|
||||
@drop="onDrop($event, index)"
|
||||
@dragover="onDragOver"
|
||||
>
|
||||
<!-- Line number -->
|
||||
<span class="text-sky-text/60 select-none w-6 text-right">{{ index + 1 }}</span>
|
||||
|
||||
<!-- Drag handle -->
|
||||
<span class="text-sky-text/60 cursor-grab select-none">::</span>
|
||||
|
||||
<!-- Code -->
|
||||
<code class="flex-1 text-sky-accent">{{ line }}</code>
|
||||
|
||||
<!-- Keyboard buttons (accessibility) -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-sky-text/60 hover:text-sky-text disabled:opacity-30"
|
||||
:disabled="index === 0"
|
||||
:aria-label="$t('challenge.moveUp')"
|
||||
@click="moveLineUp(index)"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-sky-text/60 hover:text-sky-text disabled:opacity-30"
|
||||
:disabled="index === shuffledLines.length - 1"
|
||||
:aria-label="$t('challenge.moveDown')"
|
||||
@click="moveLineDown(index)"
|
||||
>
|
||||
v
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current hint -->
|
||||
<div
|
||||
v-if="currentHint"
|
||||
class="bg-sky-accent/10 border border-sky-accent/30 rounded-lg p-4 mb-6"
|
||||
>
|
||||
<p class="text-sm text-sky-accent">
|
||||
<span class="font-semibold">{{ $t('challenge.hintLabel') }}:</span>
|
||||
{{ currentHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-4 justify-center">
|
||||
<!-- Validate button -->
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors disabled:opacity-50"
|
||||
:disabled="isValidating"
|
||||
@click="validateSolution"
|
||||
>
|
||||
{{ isValidating ? $t('challenge.validating') : $t('challenge.validate') }}
|
||||
</button>
|
||||
|
||||
<!-- Hint button -->
|
||||
<button
|
||||
v-if="props.hintsUsed < 3"
|
||||
type="button"
|
||||
class="px-6 py-3 border border-sky-dark-100 text-sky-text font-ui rounded-lg hover:bg-sky-dark-50 transition-colors"
|
||||
@click="requestHint"
|
||||
>
|
||||
{{ $t('challenge.needHint') }} ({{ props.hintsUsed }}/3)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<Transition name="fade">
|
||||
<p
|
||||
v-if="validationResult === false"
|
||||
class="text-red-400 text-center mt-4 font-ui"
|
||||
>
|
||||
{{ $t('challenge.wrongOrder') }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
hintsUsed: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
solved: []
|
||||
hintUsed: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Code lines (solution)
|
||||
const solution = [
|
||||
'function unlockDeveloper() {',
|
||||
' const secret = "SKYCEL";',
|
||||
' const key = decode(secret);',
|
||||
' if (key === "ACCESS_GRANTED") {',
|
||||
' return showDeveloper();',
|
||||
' }',
|
||||
' return "Keep exploring...";',
|
||||
'}',
|
||||
]
|
||||
|
||||
// Shuffled lines at start
|
||||
const shuffledLines = ref<string[]>([])
|
||||
const isValidating = ref(false)
|
||||
const validationResult = ref<boolean | null>(null)
|
||||
|
||||
// Shuffle on mount
|
||||
onMounted(() => {
|
||||
shuffledLines.value = [...solution].sort(() => Math.random() - 0.5)
|
||||
})
|
||||
|
||||
// Progressive hints
|
||||
const hints = computed(() => [
|
||||
t('challenge.hint1'),
|
||||
t('challenge.hint2'),
|
||||
t('challenge.hint3'),
|
||||
])
|
||||
|
||||
const currentHint = computed(() => {
|
||||
if (props.hintsUsed === 0) return null
|
||||
return hints.value[Math.min(props.hintsUsed - 1, hints.value.length - 1)]
|
||||
})
|
||||
|
||||
// Drag & Drop
|
||||
function onDragStart(e: DragEvent, index: number) {
|
||||
e.dataTransfer?.setData('text/plain', index.toString())
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, targetIndex: number) {
|
||||
e.preventDefault()
|
||||
const sourceIndex = parseInt(e.dataTransfer?.getData('text/plain') || '-1')
|
||||
if (sourceIndex === -1) return
|
||||
|
||||
// Swap lines
|
||||
const newLines = [...shuffledLines.value]
|
||||
const temp = newLines[sourceIndex]
|
||||
newLines[sourceIndex] = newLines[targetIndex]
|
||||
newLines[targetIndex] = temp
|
||||
shuffledLines.value = newLines
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Validation
|
||||
function validateSolution() {
|
||||
isValidating.value = true
|
||||
validationResult.value = null
|
||||
|
||||
setTimeout(() => {
|
||||
const isCorrect = shuffledLines.value.every((line, i) => line === solution[i])
|
||||
validationResult.value = isCorrect
|
||||
|
||||
if (isCorrect) {
|
||||
emit('solved')
|
||||
} else {
|
||||
// Reset after 2s
|
||||
setTimeout(() => {
|
||||
validationResult.value = null
|
||||
isValidating.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function requestHint() {
|
||||
if (props.hintsUsed < 3) {
|
||||
emit('hintUsed')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function moveLineUp(index: number) {
|
||||
if (index === 0) return
|
||||
const newLines = [...shuffledLines.value]
|
||||
const temp = newLines[index - 1]
|
||||
newLines[index - 1] = newLines[index]
|
||||
newLines[index] = temp
|
||||
shuffledLines.value = newLines
|
||||
}
|
||||
|
||||
function moveLineDown(index: number) {
|
||||
if (index === shuffledLines.value.length - 1) return
|
||||
const newLines = [...shuffledLines.value]
|
||||
const temp = newLines[index + 1]
|
||||
newLines[index + 1] = newLines[index]
|
||||
newLines[index] = temp
|
||||
shuffledLines.value = newLines
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-line {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.code-line:hover {
|
||||
background-color: rgba(250, 120, 79, 0.1);
|
||||
}
|
||||
|
||||
.code-line:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user