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>
235 lines
6.0 KiB
Vue
235 lines
6.0 KiB
Vue
<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>
|