diff --git a/assets/react/controllers/GameGrid.jsx b/assets/react/controllers/GameGrid.jsx index e5976fa..ce8b881 100644 --- a/assets/react/controllers/GameGrid.jsx +++ b/assets/react/controllers/GameGrid.jsx @@ -1,7 +1,7 @@ import React from 'react'; import GameRow from './GameRow'; -export default function GameGrid({ grid, width, middle, revealed = false }) { +export default function GameGrid({ grid, width, middle, revealed = false, attemptedLetters = {} }) { return (
@@ -33,6 +33,8 @@ export default function GameGrid({ grid, width, middle, revealed = false }) { totalWidth={width} hintType={row.hintType} hintText={row.hintText} + rowIndex={rowIndex} + attemptedLetters={attemptedLetters[rowIndex] ?? null} revealed={revealed} /> ); diff --git a/assets/react/controllers/GameRow.jsx b/assets/react/controllers/GameRow.jsx index 256bf41..45d1986 100644 --- a/assets/react/controllers/GameRow.jsx +++ b/assets/react/controllers/GameRow.jsx @@ -6,7 +6,7 @@ function isLetter(ch) { return /[a-zA-Z]/.test(ch); } -export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText, revealed = false }) { +export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText, rowIndex, attemptedLetters = null, revealed = false }) { const inputRefs = useRef([]); const letters = actorName.split(''); @@ -50,14 +50,29 @@ export default function GameRow({ actorName, pos, colStart, totalWidth, hintType ); } + const attemptedLetter = typeof attemptedLetters?.[charIndex] === 'string' + ? attemptedLetters[charIndex].toUpperCase() + : ''; + const correctLetter = ch.toUpperCase(); + const revealState = !revealed + ? null + : attemptedLetter === '' + ? null + : attemptedLetter === correctLetter + ? 'correct' + : 'wrong'; + return ( focusNextInput(charIndex, 1)} onPrev={() => focusNextInput(charIndex, -1)} - value={revealed ? ch.toUpperCase() : undefined} + value={revealed ? (revealState === 'correct' ? attemptedLetter : correctLetter) : undefined} + revealState={revealState} disabled={revealed} /> ); diff --git a/assets/react/controllers/LetterInput.jsx b/assets/react/controllers/LetterInput.jsx index e99463f..481ed63 100644 --- a/assets/react/controllers/LetterInput.jsx +++ b/assets/react/controllers/LetterInput.jsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, disabled = false }) { +export default function LetterInput({ highlighted, onNext, onPrev, inputRef, value, rowIndex, charIndex, revealState = null, disabled = false }) { const handleKeyUp = useCallback((e) => { if (disabled || value !== undefined) { return; @@ -21,11 +21,13 @@ export default function LetterInput({ highlighted, onNext, onPrev, inputRef, val ref={inputRef} type="text" maxLength={1} - className={`letter-input${highlighted ? ' letter-highlighted' : ''}`} + className={`letter-input${highlighted ? ' letter-highlighted' : ''}${revealState ? ` letter-${revealState}` : ''}`} value={value} disabled={disabled} readOnly={value !== undefined} onKeyUp={handleKeyUp} + data-row-index={rowIndex} + data-char-index={charIndex} autoComplete="off" /> diff --git a/assets/styles/app.css b/assets/styles/app.css index f90dd5f..34416c3 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -91,6 +91,18 @@ body { color: var(--orange); } +.letter-correct { + background: #f0fdf4; + border-color: #16a34a; + color: #166534; +} + +.letter-wrong { + background: #fef2f2; + border-color: #dc2626; + color: #b91c1c; +} + .letter-static { width: var(--cell); height: var(--cell); diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index 86fa3b0..3b1e2fe 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -29,6 +29,7 @@ class GameController extends AbstractController ): Response { $this->validateCsrfToken('game_start', $request); $request->getSession()->remove('revealed_game_id'); + $request->getSession()->remove('revealed_attempted_letters'); /** @var User|null $user */ $user = $this->getUser(); @@ -115,6 +116,7 @@ class GameController extends AbstractController } } + $request->getSession()->set('revealed_attempted_letters', $this->extractAttemptedLetters($request)); $game->abandon(); $em->flush(); $request->getSession()->set('revealed_game_id', $game->getId()); @@ -165,4 +167,60 @@ class GameController extends AbstractController return max(self::MIN_POPULARITY_BUCKET, min(self::MAX_POPULARITY_BUCKET, $bucket)); } + + /** + * @return array> + */ + private function extractAttemptedLetters(Request $request): array + { + $rawAttemptedLetters = $request->request->get('attempted_letters'); + if (!is_string($rawAttemptedLetters) || $rawAttemptedLetters === '') { + return []; + } + + $decodedAttemptedLetters = json_decode($rawAttemptedLetters, true); + if (!is_array($decodedAttemptedLetters)) { + return []; + } + + $normalizedAttemptedLetters = []; + + foreach ($decodedAttemptedLetters as $rowIndex => $rowAttempts) { + if ( + (!is_int($rowIndex) && !ctype_digit((string) $rowIndex)) + || !is_array($rowAttempts) + ) { + continue; + } + + $normalizedRowAttempts = []; + + foreach ($rowAttempts as $charIndex => $attemptedLetter) { + if ( + (!is_int($charIndex) && !ctype_digit((string) $charIndex)) + || !is_scalar($attemptedLetter) + ) { + continue; + } + + $normalizedLetter = strtoupper(substr(trim((string) $attemptedLetter), 0, 1)); + if (!preg_match('/^[A-Z]$/', $normalizedLetter)) { + continue; + } + + $normalizedRowAttempts[(int) $charIndex] = $normalizedLetter; + } + + if ($normalizedRowAttempts === []) { + continue; + } + + ksort($normalizedRowAttempts); + $normalizedAttemptedLetters[(int) $rowIndex] = $normalizedRowAttempts; + } + + ksort($normalizedAttemptedLetters); + + return $normalizedAttemptedLetters; + } } diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 699b74e..074b77f 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -56,9 +56,11 @@ class HomepageController extends AbstractController $game = $revealedGame; } else { $request->getSession()->remove('revealed_game_id'); + $request->getSession()->remove('revealed_attempted_letters'); } } else { $request->getSession()->remove('revealed_game_id'); + $request->getSession()->remove('revealed_attempted_letters'); } } @@ -76,6 +78,7 @@ class HomepageController extends AbstractController 'grid' => $gridData['grid'], 'width' => $gridData['width'], 'middle' => $gridData['middle'], + 'attemptedLetters' => $request->getSession()->get('revealed_attempted_letters', []), ]); } } diff --git a/templates/homepage/index.html.twig b/templates/homepage/index.html.twig index a0d2316..ea52b89 100644 --- a/templates/homepage/index.html.twig +++ b/templates/homepage/index.html.twig @@ -10,8 +10,9 @@

Êtes-vous sûr de vouloir abandonner ?

-
+ + @@ -23,6 +24,34 @@ var trigger = document.getElementById('abandon-trigger'); var popover = document.getElementById('abandon-popover'); var cancel = document.getElementById('abandon-cancel'); + var form = document.getElementById('abandon-form'); + var attemptedLettersInput = document.getElementById('abandon-attempted-letters'); + + function collectAttemptedLetters() { + if (!attemptedLettersInput) { + return; + } + + var attemptedLetters = {}; + document.querySelectorAll('#actors input[data-row-index][data-char-index]').forEach(function(input) { + var value = (input.value || '').trim().toUpperCase(); + if (!/^[A-Z]$/.test(value)) { + return; + } + + var rowIndex = input.getAttribute('data-row-index'); + var charIndex = input.getAttribute('data-char-index'); + + if (!attemptedLetters[rowIndex]) { + attemptedLetters[rowIndex] = {}; + } + + attemptedLetters[rowIndex][charIndex] = value; + }); + + attemptedLettersInput.value = JSON.stringify(attemptedLetters); + } + trigger.addEventListener('click', function() { popover.classList.toggle('open'); }); @@ -34,6 +63,7 @@ popover.classList.remove('open'); } }); + form.addEventListener('submit', collectAttemptedLetters); })(); {% else %} @@ -49,6 +79,7 @@ width: width, middle: middle, revealed: game.status == constant('App\\Entity\\Game::STATUS_ABANDONED'), + attemptedLetters: attemptedLetters, }) }}>