Files
Portfolio-Game/docs/implementation-artifacts/4-6-page-challenge-structure-puzzle.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 02:08:56 +01:00

17 KiB

Story 4.6: Page Challenge - Structure et puzzle

Status: ready-for-dev

Story

As a visiteur, I want relever un défi optionnel avant d'accéder au contact, so that l'accès au développeur est une récompense méritée (mais pas bloquante).

Acceptance Criteria

  1. Given le visiteur accède à /challenge (après avoir débloqué le contact) When la page se charge Then une introduction narrative "Une dernière épreuve..." s'affiche
  2. And un puzzle logique/code simple est présenté (réordonner, compléter, décoder)
  3. And la difficulté est calibrée : 1-3 minutes pour résoudre
  4. And le thème est lié au développement/code
  5. And un système d'indices est disponible (bouton "Besoin d'aide ?")
  6. And 3 niveaux d'indices progressifs sont proposés
  7. And après 3 indices, une option "Passer" apparaît
  8. And le challenge est TOUJOURS skippable (bouton discret "Passer directement au contact")
  9. And une validation avec feedback clair indique succès/échec
  10. And une animation de succès célèbre la réussite
  11. And challengeCompleted est mis à true dans le store si réussi

Tasks / Subtasks

  • Task 1: Créer la page challenge (AC: #1, #8)

    • Créer frontend/app/pages/challenge.vue
    • Vérifier que le contact est débloqué
    • Introduction narrative avec Le Bug
    • Bouton discret "Passer" visible en permanence
  • Task 2: Concevoir le puzzle (AC: #2, #3, #4)

    • Puzzle type "réordonner les lignes de code"
    • Code simple : une fonction qui affiche un message
    • 5-7 lignes à réordonner dans le bon ordre
    • Thème : débloquer l'accès au développeur
  • Task 3: Créer le composant CodePuzzle (AC: #2, #9)

    • Créer frontend/app/components/feature/CodePuzzle.vue
    • Drag & drop des lignes de code
    • Support tactile (mobile)
    • Validation visuelle (vert/rouge)
  • Task 4: Implémenter le système d'indices (AC: #5, #6, #7)

    • Bouton "Besoin d'aide ?"
    • 3 indices progressifs (révèlent de plus en plus)
    • Après 3 indices : bouton "Passer" plus visible
    • Indices traduits FR/EN
  • Task 5: Implémenter l'animation de succès (AC: #10, #11)

    • Confettis ou effet visuel de célébration
    • Message du narrateur
    • Mettre challengeCompleted = true dans le store
    • Navigation vers la révélation
  • Task 6: Gérer le skip (AC: #8)

    • Skip visible en permanence (discret mais accessible)
    • Skip après indices (plus visible)
    • Dans les deux cas : navigation vers révélation
  • Task 7: Accessibilité

    • Navigation clavier pour le drag & drop
    • aria-labels descriptifs
    • Instructions claires
  • Task 8: Tests et validation

    • Tester le puzzle complet
    • Tester les 3 indices
    • Vérifier le skip
    • Tester sur mobile (drag & drop tactile)
    • Valider l'animation de succès

Dev Notes

Puzzle : Réordonner le code

Le puzzle consiste à remettre dans l'ordre les lignes d'une fonction JavaScript qui "débloque" l'accès au développeur.

// Solution correcte
function unlockDeveloper() {
  const secret = "SKYCEL";
  const key = decode(secret);
  if (key === "ACCESS_GRANTED") {
    return showDeveloper();
  }
  return "Keep exploring...";
}

Les lignes sont mélangées et le visiteur doit les réordonner.

Page challenge.vue

<!-- frontend/app/pages/challenge.vue -->
<script setup lang="ts">
const { t } = useI18n()
const router = useRouter()
const progressionStore = useProgressionStore()
const narrator = useNarrator()

// Vérifier que le contact est débloqué
if (!progressionStore.contactUnlocked) {
  navigateTo('/')
}

// États
const showIntro = ref(true)
const puzzleCompleted = ref(false)
const hintsUsed = ref(0)

// Introduction narrative
onMounted(async () => {
  await narrator.showMessage('challenge_intro')
})

function startPuzzle() {
  showIntro.value = false
}

function handlePuzzleSolved() {
  puzzleCompleted.value = true
  progressionStore.setChallengeCompleted(true)

  // Attendre l'animation puis naviguer
  setTimeout(() => {
    router.push('/revelation')
  }, 3000)
}

function skipChallenge() {
  // Skip ne marque pas comme complété
  router.push('/revelation')
}

function useHint() {
  hintsUsed.value++
}
</script>

<template>
  <div class="challenge-page min-h-screen bg-sky-dark relative">
    <!-- Bouton skip (toujours visible) -->
    <button
      v-if="!puzzleCompleted"
      type="button"
      class="absolute top-4 right-4 text-sky-text-muted hover:text-sky-text text-sm font-ui underline z-10"
      :class="{ 'text-sky-accent': hintsUsed >= 3 }"
      @click="skipChallenge"
    >
      {{ t('challenge.skip') }}
    </button>

    <!-- Introduction -->
    <Transition name="fade" mode="out-in">
      <div
        v-if="showIntro"
        class="flex flex-col items-center justify-center min-h-screen p-8"
      >
        <div class="max-w-lg text-center">
          <img
            src="/images/bug/bug-stage-4.svg"
            alt="Le Bug"
            class="w-24 h-24 mx-auto mb-6"
          />

          <h1 class="text-3xl font-ui font-bold text-sky-text mb-4">
            {{ t('challenge.title') }}
          </h1>

          <p class="font-narrative text-xl text-sky-text-muted mb-8">
            {{ t('challenge.intro') }}
          </p>

          <button
            type="button"
            class="px-8 py-3 bg-sky-accent text-white font-ui font-semibold rounded-lg hover:bg-sky-accent/90 transition-colors"
            @click="startPuzzle"
          >
            {{ t('challenge.accept') }}
          </button>
        </div>
      </div>

      <!-- Puzzle -->
      <div
        v-else-if="!puzzleCompleted"
        class="flex flex-col items-center justify-center min-h-screen p-8"
      >
        <div class="max-w-2xl w-full">
          <h2 class="text-xl font-ui font-bold text-sky-text mb-2 text-center">
            {{ t('challenge.puzzleTitle') }}
          </h2>

          <p class="text-sky-text-muted text-center mb-8">
            {{ t('challenge.puzzleInstruction') }}
          </p>

          <CodePuzzle
            @solved="handlePuzzleSolved"
            @hint-used="useHint"
            :hints-used="hintsUsed"
          />
        </div>
      </div>

      <!-- Succès -->
      <div
        v-else
        class="flex flex-col items-center justify-center min-h-screen p-8"
      >
        <ChallengeSuccess />
      </div>
    </Transition>
  </div>
</template>

Composant CodePuzzle

<!-- frontend/app/components/feature/CodePuzzle.vue -->
<script setup lang="ts">
import { useDraggable } from '@vueuse/core'

const props = defineProps<{
  hintsUsed: number
}>()

const emit = defineEmits<{
  solved: []
  hintUsed: []
}>()

const { t } = useI18n()

// Lignes de code (solution)
const solution = [
  'function unlockDeveloper() {',
  '  const secret = "SKYCEL";',
  '  const key = decode(secret);',
  '  if (key === "ACCESS_GRANTED") {',
  '    return showDeveloper();',
  '  }',
  '  return "Keep exploring...";',
  '}',
]

// Lignes mélangées au départ
const shuffledLines = ref<string[]>([])
const isValidating = ref(false)
const validationResult = ref<boolean | null>(null)

// Mélanger au montage
onMounted(() => {
  shuffledLines.value = [...solution].sort(() => Math.random() - 0.5)
})

// Indices progressifs
const hints = [
  () => t('challenge.hint1'), // "La fonction commence par 'function'"
  () => t('challenge.hint2'), // "La variable 'secret' est définie en premier"
  () => t('challenge.hint3'), // "Le return final est 'Keep exploring...'"
]

const currentHint = computed(() => {
  if (props.hintsUsed === 0) return null
  return hints[Math.min(props.hintsUsed - 1, hints.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 les lignes
  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 après 2s
      setTimeout(() => {
        validationResult.value = null
        isValidating.value = false
      }, 2000)
    }
  }, 500)
}

function requestHint() {
  if (props.hintsUsed < 3) {
    emit('hintUsed')
  }
}

// Navigation clavier
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>

<template>
  <div class="code-puzzle">
    <!-- Zone de code -->
    <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"
      >
        <!-- Numéro de ligne -->
        <span class="text-sky-text-muted select-none w-6 text-right">{{ index + 1 }}</span>

        <!-- Poignée de drag -->
        <span class="text-sky-text-muted cursor-grab">⋮⋮</span>

        <!-- Code -->
        <code class="flex-1 text-sky-accent">{{ line }}</code>

        <!-- Boutons clavier (accessibilité) -->
        <div class="flex gap-1">
          <button
            type="button"
            class="p-1 text-sky-text-muted hover:text-sky-text"
            :disabled="index === 0"
            @click="moveLineUp(index)"
            :aria-label="t('challenge.moveUp')"
          >
            
          </button>
          <button
            type="button"
            class="p-1 text-sky-text-muted hover:text-sky-text"
            :disabled="index === shuffledLines.length - 1"
            @click="moveLineDown(index)"
            :aria-label="t('challenge.moveDown')"
          >
            
          </button>
        </div>
      </div>
    </div>

    <!-- Indice actuel -->
    <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">
      <!-- Bouton valider -->
      <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>

      <!-- Bouton indice -->
      <button
        v-if="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') }} ({{ hintsUsed }}/3)
      </button>
    </div>

    <!-- Message d'erreur -->
    <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>

<style scoped>
.code-line {
  border: 1px solid transparent;
}

.code-line:hover {
  background-color: rgba(250, 120, 79, 0.1);
}

.code-line:active {
  cursor: grabbing;
}
</style>

Composant ChallengeSuccess

<!-- frontend/app/components/feature/ChallengeSuccess.vue -->
<script setup lang="ts">
import confetti from 'canvas-confetti'

const { t } = useI18n()

onMounted(() => {
  // Lancer les confettis
  confetti({
    particleCount: 100,
    spread: 70,
    origin: { y: 0.6 },
    colors: ['#fa784f', '#3b82f6', '#10b981', '#f59e0b'],
  })
})
</script>

<template>
  <div class="challenge-success text-center">
    <div class="text-6xl mb-4 animate-bounce">🎉</div>

    <h2 class="text-3xl font-ui font-bold text-sky-accent mb-4">
      {{ t('challenge.success') }}
    </h2>

    <p class="font-narrative text-xl text-sky-text mb-8">
      {{ t('challenge.successMessage') }}
    </p>

    <p class="text-sky-text-muted">
      {{ t('challenge.redirecting') }}
    </p>
  </div>
</template>

Clés i18n

fr.json :

{
  "challenge": {
    "title": "Une dernière épreuve...",
    "intro": "Avant de rencontrer le développeur, prouve que tu maîtrises les bases du code. Rien de bien méchant, promis.",
    "accept": "Relever le défi",
    "skip": "Passer directement au contact",
    "puzzleTitle": "Remets le code dans l'ordre",
    "puzzleInstruction": "Glisse les lignes pour reconstituer la fonction qui débloque l'accès au développeur.",
    "hint1": "La fonction commence par 'function unlockDeveloper() {'",
    "hint2": "La variable 'secret' est définie juste après l'accolade ouvrante",
    "hint3": "La dernière ligne avant l'accolade fermante est 'return \"Keep exploring...\";'",
    "hintLabel": "Indice",
    "needHint": "Besoin d'aide ?",
    "validate": "Vérifier",
    "validating": "Vérification...",
    "wrongOrder": "Ce n'est pas le bon ordre... Essaie encore !",
    "moveUp": "Monter",
    "moveDown": "Descendre",
    "success": "Bravo !",
    "successMessage": "Tu as prouvé ta valeur. Le chemin vers le développeur est maintenant ouvert...",
    "redirecting": "Redirection en cours..."
  }
}

en.json :

{
  "challenge": {
    "title": "One last challenge...",
    "intro": "Before meeting the developer, prove you understand the basics of code. Nothing too hard, I promise.",
    "accept": "Accept the challenge",
    "skip": "Skip to contact",
    "puzzleTitle": "Put the code in order",
    "puzzleInstruction": "Drag the lines to reconstruct the function that unlocks access to the developer.",
    "hint1": "The function starts with 'function unlockDeveloper() {'",
    "hint2": "The 'secret' variable is defined right after the opening brace",
    "hint3": "The last line before the closing brace is 'return \"Keep exploring...\";'",
    "hintLabel": "Hint",
    "needHint": "Need help?",
    "validate": "Check",
    "validating": "Checking...",
    "wrongOrder": "That's not the right order... Try again!",
    "moveUp": "Move up",
    "moveDown": "Move down",
    "success": "Well done!",
    "successMessage": "You've proven your worth. The path to the developer is now open...",
    "redirecting": "Redirecting..."
  }
}

Dépendances

Cette story nécessite :

  • Story 3.5 : Store de progression (contactUnlocked, challengeCompleted)
  • Story 3.3 : useNarrator

Cette story prépare pour :

  • Story 4.7 : Révélation (destination après le challenge)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── pages/
│   └── challenge.vue                    # CRÉER
└── components/feature/
    ├── CodePuzzle.vue                   # CRÉER
    └── ChallengeSuccess.vue             # CRÉER

Fichiers à modifier :

frontend/app/stores/progression.ts       # AJOUTER challengeCompleted
frontend/package.json                    # AJOUTER canvas-confetti
frontend/i18n/fr.json                    # AJOUTER challenge.*
frontend/i18n/en.json                    # AJOUTER challenge.*

References

  • [Source: docs/planning-artifacts/epics.md#Story-4.6]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Challenge]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Challenge]

Technical Requirements

Requirement Value Source
Durée puzzle 1-3 minutes Epics
Indices 3 niveaux progressifs Epics
Skip Toujours disponible Epics
Thème Code/développement Epics

Dev Agent Record

Agent Model Used

{{agent_model_name_version}}

Debug Log References

Completion Notes List

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent

File List