Files
Portfolio-Game/frontend/app/components/feature/CodePuzzle.vue
skycel 7e87a341a2 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>
2026-02-08 13:35:12 +01:00

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>