From b7992642fd47a3f4146d87d23e1f6ee74e1bd71b Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 13:52:24 +0000 Subject: [PATCH 1/8] Add Letter Triangles word puzzle game --- src/App.jsx | 1 + src/assets/icons/letter-triangles.svg | 14 ++ src/i18n/locales/en.json | 8 + src/i18n/locales/es.json | 8 + src/packs/word-games/manifest.js | 12 ++ src/pages/LetterTriangles/LetterTriangles.jsx | 183 ++++++++++++++++++ .../LetterTriangles.module.css | 117 +++++++++++ .../LetterTriangles/LetterTriangles.test.js | 43 ++++ src/pages/LetterTriangles/README.md | 19 ++ src/pages/LetterTriangles/index.js | 1 + .../LetterTriangles/letterTrianglesLogic.js | 147 ++++++++++++++ src/tests/gameModuleImports.test.js | 1 + src/tests/gameReadmeCheck.test.js | 1 + 13 files changed, 555 insertions(+) create mode 100644 src/assets/icons/letter-triangles.svg create mode 100644 src/pages/LetterTriangles/LetterTriangles.jsx create mode 100644 src/pages/LetterTriangles/LetterTriangles.module.css create mode 100644 src/pages/LetterTriangles/LetterTriangles.test.js create mode 100644 src/pages/LetterTriangles/README.md create mode 100644 src/pages/LetterTriangles/index.js create mode 100644 src/pages/LetterTriangles/letterTrianglesLogic.js diff --git a/src/App.jsx b/src/App.jsx index 5e86718f..14858740 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -80,6 +80,7 @@ const slugToFolder = { 'language-quiz': 'LanguageQuiz', 'letter-web': 'LetterWeb', 'letter-orbit': 'LetterOrbit', + 'letter-triangles': 'LetterTriangles', 'lights-out': 'LightsOut', 'lightup': 'Lightup', 'lits': 'LITS', diff --git a/src/assets/icons/letter-triangles.svg b/src/assets/icons/letter-triangles.svg new file mode 100644 index 00000000..23a1bf41 --- /dev/null +++ b/src/assets/icons/letter-triangles.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + A + B + C + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 994a004f..54069389 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -752,6 +752,14 @@ "gaveUp": "Solution Revealed", "gaveUpMessage": "Review the route and try a new seed." }, + "letterTriangles": { + "title": "Letter Triangles", + "instructions": "Place all triangle tiles so each line spells the target word.", + "selectedHint": "Tile selected: click a board cell to place it.", + "boardCell": "Board cell {{index}}", + "tileLabel": "Tile {{id}}", + "lineN": "Line {{num}}" + }, "kana": { "rules": "Draw exactly one single closed loop by connecting centers of orthogonally adjacent cells.\n\nHow to play:\n1) Click between two neighboring cell centers to add/remove a segment.\n2) The final loop cannot branch or cross itself: every loop cell has exactly two connected segments.\n3) The loop is one continuous cycle (not multiple smaller loops).\n4) Every kana clue cell (あ, え, う, い, お) must be on the loop.\n5) If two clues show the same kana, the loop must pass straight through both in the same orientation (horizontal/vertical) and with the same distances to the next turn on each side.\n6) Different kana must have different orientation-and-distance patterns.\n\nTip: Use the Hint button to view each kana’s required orientation and turn distances for this puzzle.", "won": "Perfect loop! You solved Kana.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index afb65c6a..ea50ccb7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -752,6 +752,14 @@ "gaveUp": "Solución revelada", "gaveUpMessage": "Revisa la ruta e intenta con una nueva semilla." }, + "letterTriangles": { + "title": "Triángulos de Letras", + "instructions": "Coloca todas las fichas triangulares para que cada línea forme la palabra objetivo.", + "selectedHint": "Ficha seleccionada: haz clic en una celda para colocarla.", + "boardCell": "Celda {{index}}", + "tileLabel": "Ficha {{id}}", + "lineN": "Línea {{num}}" + }, "kana": { "rules": "Dibuja exactamente un único bucle cerrado conectando centros de celdas ortogonalmente adyacentes.\n\nCómo jugar:\n1) Haz clic entre dos centros vecinos para añadir o quitar un segmento.\n2) El bucle final no puede ramificarse ni cruzarse: cada celda del bucle tiene exactamente dos segmentos conectados.\n3) El bucle debe ser un solo ciclo continuo (no varios bucles pequeños).\n4) Todas las celdas con pistas kana (あ, え, う, い, お) deben pertenecer al bucle.\n5) Si dos pistas muestran el mismo kana, el bucle debe pasar recto por ambas con la misma orientación (horizontal/vertical) y con las mismas distancias hasta el siguiente giro a cada lado.\n6) Kana diferentes deben tener patrones distintos de orientación y distancias.\n\nConsejo: usa el botón de Pista para ver la orientación y las distancias de giro requeridas para cada kana en este rompecabezas.", "won": "¡Bucle perfecto! Resolviste Kana.", diff --git a/src/packs/word-games/manifest.js b/src/packs/word-games/manifest.js index 019c8ed2..becc8daa 100644 --- a/src/packs/word-games/manifest.js +++ b/src/packs/word-games/manifest.js @@ -22,6 +22,7 @@ import pyramidIcon from '../../assets/icons/pyramid.svg'; import wordtilesIcon from '../../assets/icons/wordtiles.svg'; import letterwebIcon from '../../assets/icons/letterweb.svg'; import letterorbitIcon from '../../assets/icons/letter-orbit.svg'; +import letterTrianglesIcon from '../../assets/icons/letter-triangles.svg'; import flipquotesIcon from '../../assets/icons/flipquotes.svg'; import cryptogramIcon from '../../assets/icons/cryptogram.svg'; import phraseguessIcon from '../../assets/icons/phraseguess.svg'; @@ -230,6 +231,17 @@ export const categories = [ component: () => import('../../pages/LetterOrbit'), lastModified: 1769540742000 }, + { + title: 'Letter Triangles', + slug: 'letter-triangles', + description: 'Arrange triangle tiles so every line spells its target word.', + icon: letterTrianglesIcon, + emojiIcon: '🔺', + colors: { primary: '#0ea5e9', secondary: '#6366f1' }, + gradient: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)', + component: () => import('../../pages/LetterTriangles'), + lastModified: 1772400000000 + }, { title: 'Word Search', slug: 'word-search', diff --git a/src/pages/LetterTriangles/LetterTriangles.jsx b/src/pages/LetterTriangles/LetterTriangles.jsx new file mode 100644 index 00000000..5af4d3e7 --- /dev/null +++ b/src/pages/LetterTriangles/LetterTriangles.jsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import GameHeader from '../../components/GameHeader'; +import SeedDisplay, { useSeed } from '../../components/SeedDisplay/SeedDisplay'; +import GiveUpButton from '../../components/GiveUpButton'; +import GameResult from '../../components/GameResult'; +import { + CELL_COUNT, + generateLetterTrianglesPuzzle, + buildLineWordsFromPlacement, + isSolvedPlacement, +} from './letterTrianglesLogic'; +import styles from './LetterTriangles.module.css'; + +const BOARD_ROWS = [1, 3, 5]; + +export default function LetterTriangles() { + const { t } = useTranslation(); + const { seed, setSeed, newSeed } = useSeed('letter-triangles', () => Math.floor(Math.random() * 1000000)); + + const [puzzle, setPuzzle] = useState(() => generateLetterTrianglesPuzzle(seed)); + const [placement, setPlacement] = useState(() => Array(CELL_COUNT).fill(null)); + const [selectedTileId, setSelectedTileId] = useState(null); + const [gaveUp, setGaveUp] = useState(false); + + useEffect(() => { + setPuzzle(generateLetterTrianglesPuzzle(seed)); + setPlacement(Array(CELL_COUNT).fill(null)); + setSelectedTileId(null); + setGaveUp(false); + }, [seed]); + + const tileById = useMemo(() => new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile])), [puzzle]); + + const trayTileIds = useMemo(() => { + const placed = new Set(placement.filter((id) => id !== null)); + return puzzle.shuffledTiles.map((tile) => tile.id).filter((id) => !placed.has(id)); + }, [placement, puzzle]); + + const lineWords = useMemo(() => buildLineWordsFromPlacement(placement, tileById), [placement, tileById]); + const solved = isSolvedPlacement(placement); + + const handleCellClick = useCallback((cellIndex) => { + if (gaveUp || solved) return; + + setPlacement((prev) => { + const next = [...prev]; + const occupant = next[cellIndex]; + + if (selectedTileId === null) { + if (occupant !== null) { + next[cellIndex] = null; + setSelectedTileId(occupant); + } + return next; + } + + next[cellIndex] = selectedTileId; + setSelectedTileId(occupant); + return next; + }); + }, [selectedTileId, gaveUp, solved]); + + const handleTileClick = useCallback((tileId) => { + if (gaveUp || solved) return; + + setSelectedTileId((current) => (current === tileId ? null : tileId)); + }, [gaveUp, solved]); + + const handleGiveUp = useCallback(() => { + setPlacement(Array.from({ length: CELL_COUNT }, (_, index) => index)); + setSelectedTileId(null); + setGaveUp(true); + }, []); + + const renderTile = (tileId) => { + if (tileId === null || tileId === undefined) return null; + const tile = tileById.get(tileId); + const [a, b, c] = tile.letters; + + return ( +
+ {a} + {b} + {c} +
+ ); + }; + + let cellIndex = 0; + + return ( +
+ + +
+ + +
+ + + + {(solved || gaveUp) && ( + + )} + +
+ {BOARD_ROWS.map((rowSize, rowIdx) => ( +
+ {Array.from({ length: rowSize }).map((_, i) => { + const current = cellIndex++; + const orientation = i % 2 === 1 ? styles.down : styles.up; + return ( + + ); + })} +
+ ))} +
+ + {selectedTileId !== null && ( +

+ {t('letterTriangles.selectedHint', { defaultValue: 'Tile selected: click a board cell to place it.' })} +

+ )} + +
+ {trayTileIds.map((tileId) => ( + + ))} +
+ +
+ {puzzle.targetWords.map((targetWord, idx) => { + const currentWord = lineWords[idx]; + const complete = !currentWord.includes('_'); + const correct = currentWord === targetWord; + + return ( +
+ {t('letterTriangles.lineN', { defaultValue: 'Line {{num}}', num: idx + 1 })} + {currentWord} + + {targetWord} + +
+ ); + })} +
+
+ ); +} diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css new file mode 100644 index 00000000..cee6e4ea --- /dev/null +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -0,0 +1,117 @@ +.container { + max-width: 980px; + margin: 0 auto; + padding: 1rem; +} + +.topBar { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.actionButton { + background: #334155; + color: white; + border: 1px solid #475569; + border-radius: 8px; + padding: 0.45rem 0.8rem; + cursor: pointer; +} + +.board { + margin: 1rem 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.boardRow { + display: flex; + gap: 0.5rem; +} + +.cell, +.trayTile { + width: 88px; + height: 78px; + border: none; + background: transparent; + cursor: pointer; + padding: 0; +} + +.tileFace { + position: relative; + width: 100%; + height: 100%; + background: rgba(30, 41, 59, 0.85); + clip-path: polygon(50% 4%, 4% 96%, 96% 96%); + border: 2px solid rgba(148, 163, 184, 0.75); +} + +.down .tileFace { + transform: rotate(180deg); +} + +.letter { + position: absolute; + color: #e2e8f0; + font-weight: 700; + font-size: 1rem; +} + +.top { + top: 10%; + left: 50%; + transform: translateX(-50%); +} + +.left { + bottom: 14%; + left: 25%; +} + +.right { + bottom: 14%; + right: 25%; +} + +.tray { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); + gap: 0.4rem; + margin-bottom: 1rem; +} + +.selected .tileFace { + border-color: #fbbf24; + box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3); +} + +.selectionHint { + margin-bottom: 0.75rem; + color: #93c5fd; +} + +.linesPanel { + border: 1px solid #334155; + border-radius: 10px; + padding: 0.75rem; + background: rgba(15, 23, 42, 0.6); +} + +.lineRow { + display: grid; + grid-template-columns: 84px 1fr 1fr; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.3rem; +} + +.lineLabel { color: #94a3b8; } +.currentWord { color: #e2e8f0; } +.correct { color: #22c55e; font-weight: 700; } +.incorrect { color: #f97316; font-weight: 700; } +.pending { color: #64748b; } diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js new file mode 100644 index 00000000..afe08123 --- /dev/null +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { + CELL_COUNT, + generateLetterTrianglesPuzzle, + buildLineWordsFromPlacement, + isSolvedPlacement, +} from './letterTrianglesLogic'; + +describe('Letter Triangles generator', () => { + it('creates deterministic puzzles for the same seed', () => { + const a = generateLetterTrianglesPuzzle(123456); + const b = generateLetterTrianglesPuzzle(123456); + + expect(a.targetWords).toEqual(b.targetWords); + expect(a.solvedTiles).toEqual(b.solvedTiles); + expect(a.shuffledTiles).toEqual(b.shuffledTiles); + }); + + it('generates words with strict 1..8 lengths', () => { + const puzzle = generateLetterTrianglesPuzzle(99); + + expect(puzzle.targetWords).toHaveLength(8); + puzzle.targetWords.forEach((word, index) => { + expect(word).toMatch(/^[A-Z]+$/); + expect(word.length).toBe(index + 1); + }); + }); + + it('solved placement reconstructs exactly the target line words', () => { + const puzzle = generateLetterTrianglesPuzzle(42); + const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index); + const tileById = new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile])); + + const lines = buildLineWordsFromPlacement(solvedPlacement, tileById); + expect(lines).toEqual(puzzle.targetWords); + expect(isSolvedPlacement(solvedPlacement)).toBe(true); + }); + + it('unsolved placements are never falsely considered solved', () => { + const placement = [0, 1, 2, 3, 4, 5, 6, 8, 7]; + expect(isSolvedPlacement(placement)).toBe(false); + }); +}); diff --git a/src/pages/LetterTriangles/README.md b/src/pages/LetterTriangles/README.md new file mode 100644 index 00000000..1dbf27bf --- /dev/null +++ b/src/pages/LetterTriangles/README.md @@ -0,0 +1,19 @@ +# Letter Triangles + +Arrange 9 triangular letter tiles into a large triangle. + +Each tile has three letters. Place the tiles so that every line shown under the board spells the target word. + +## Rules + +- Place all tiles from the tray into the 9 board cells. +- A puzzle is solved when every tile is in the correct position. +- You can click a placed tile to pick it back up. +- Use **Give Up** to reveal the solution. + +## Puzzle generation + +- Uses a seeded generator for reproducibility. +- Chooses English words with lengths 1 through 8. +- Writes those words into overlapping line definitions. +- Builds tile letters from the solved layout and shuffles the tiles. diff --git a/src/pages/LetterTriangles/index.js b/src/pages/LetterTriangles/index.js new file mode 100644 index 00000000..44774133 --- /dev/null +++ b/src/pages/LetterTriangles/index.js @@ -0,0 +1 @@ +export { default } from './LetterTriangles'; diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js new file mode 100644 index 00000000..abb8d5f8 --- /dev/null +++ b/src/pages/LetterTriangles/letterTrianglesLogic.js @@ -0,0 +1,147 @@ +import { createSeededRandom, seededShuffleArray, getWordsByLength } from '../../data/wordUtils'; + +export const CELL_COUNT = 9; +export const CORNERS = ['A', 'B', 'C']; + +export const LINE_DEFINITIONS = [ + [{ cell: 0, corner: 'A' }], + [{ cell: 0, corner: 'A' }, { cell: 0, corner: 'B' }], + [{ cell: 0, corner: 'B' }, { cell: 0, corner: 'C' }, { cell: 1, corner: 'A' }], + [{ cell: 1, corner: 'A' }, { cell: 1, corner: 'B' }, { cell: 1, corner: 'C' }, { cell: 2, corner: 'A' }], + [{ cell: 2, corner: 'A' }, { cell: 2, corner: 'B' }, { cell: 2, corner: 'C' }, { cell: 3, corner: 'A' }, { cell: 3, corner: 'B' }], + [{ cell: 3, corner: 'B' }, { cell: 3, corner: 'C' }, { cell: 4, corner: 'A' }, { cell: 4, corner: 'B' }, { cell: 4, corner: 'C' }, { cell: 5, corner: 'A' }], + [{ cell: 5, corner: 'A' }, { cell: 5, corner: 'B' }, { cell: 5, corner: 'C' }, { cell: 6, corner: 'A' }, { cell: 6, corner: 'B' }, { cell: 6, corner: 'C' }, { cell: 7, corner: 'A' }], + [{ cell: 7, corner: 'A' }, { cell: 7, corner: 'B' }, { cell: 7, corner: 'C' }, { cell: 8, corner: 'A' }, { cell: 8, corner: 'B' }, { cell: 8, corner: 'C' }, { cell: 0, corner: 'C' }, { cell: 1, corner: 'B' }], +]; + +const ONE_LETTER_WORDS = ['A', 'I']; +const LETTERS = 'ETAOINSHRDLUCMWFGYPBVKJXQZ'; + +function slotKey(cell, corner) { + return `${cell}:${corner}`; +} + +function getWordPool(length) { + if (length === 1) return ONE_LETTER_WORDS; + return getWordsByLength(length) + .map((word) => word.toUpperCase()) + .filter((word) => /^[A-Z]+$/.test(word)); +} + +function compatibleWithPattern(word, pattern) { + for (let i = 0; i < pattern.length; i++) { + if (pattern[i] && pattern[i] !== word[i]) return false; + } + return true; +} + +export function generateLetterTrianglesPuzzle(seed) { + const baseRandom = createSeededRandom(seed); + + for (let attempt = 0; attempt < 8; attempt++) { + const attemptSeed = seed + attempt * 104729; + const random = createSeededRandom(attemptSeed); + + const pools = LINE_DEFINITIONS.map((_, idx) => seededShuffleArray([...getWordPool(idx + 1)], random)); + + const assignedSlots = new Map(); + const usedWords = new Set(); + const chosenWords = []; + + function search(lineIndex) { + if (lineIndex === LINE_DEFINITIONS.length) return true; + + const line = LINE_DEFINITIONS[lineIndex]; + const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, corner)) || null); + + const candidates = pools[lineIndex] + .filter((word) => !usedWords.has(word) && compatibleWithPattern(word, pattern)) + .slice(0, 900); + + for (const word of candidates) { + const changed = []; + + for (let i = 0; i < line.length; i++) { + const key = slotKey(line[i].cell, line[i].corner); + const existing = assignedSlots.get(key); + if (!existing) { + assignedSlots.set(key, word[i]); + changed.push(key); + } + } + + usedWords.add(word); + chosenWords.push(word); + + if (search(lineIndex + 1)) return true; + + chosenWords.pop(); + usedWords.delete(word); + changed.forEach((key) => assignedSlots.delete(key)); + } + + return false; + } + + if (!search(0)) continue; + + for (let cell = 0; cell < CELL_COUNT; cell++) { + for (const corner of CORNERS) { + const key = slotKey(cell, corner); + if (!assignedSlots.has(key)) { + assignedSlots.set(key, LETTERS[Math.floor(random() * LETTERS.length)]); + } + } + } + + const solvedTiles = Array.from({ length: CELL_COUNT }, (_, cell) => ({ + id: cell, + letters: CORNERS.map((corner) => assignedSlots.get(slotKey(cell, corner))), + })); + + return { + seed, + targetWords: chosenWords, + solvedTiles, + shuffledTiles: seededShuffleArray([...solvedTiles], baseRandom), + }; + } + + // Fallback: generate a guaranteed solvable letter layout even if dictionary matching fails + const fallbackSlots = new Map(); + for (let cell = 0; cell < CELL_COUNT; cell++) { + for (const corner of CORNERS) { + fallbackSlots.set(slotKey(cell, corner), LETTERS[Math.floor(baseRandom() * LETTERS.length)]); + } + } + + const solvedTiles = Array.from({ length: CELL_COUNT }, (_, cell) => ({ + id: cell, + letters: CORNERS.map((corner) => fallbackSlots.get(slotKey(cell, corner))), + })); + + const targetWords = LINE_DEFINITIONS.map((line) => + line.map(({ cell, corner }) => fallbackSlots.get(slotKey(cell, corner))).join('') + ); + + return { + seed, + targetWords, + solvedTiles, + shuffledTiles: seededShuffleArray([...solvedTiles], baseRandom), + }; +} + +export function buildLineWordsFromPlacement(placement, tileById) { + return LINE_DEFINITIONS.map((line) => line.map(({ cell, corner }) => { + const tileId = placement[cell]; + if (tileId === null || tileId === undefined) return '_'; + const tile = tileById.get(tileId); + const cornerIndex = CORNERS.indexOf(corner); + return tile?.letters[cornerIndex] ?? '_'; + }).join('')); +} + +export function isSolvedPlacement(placement) { + return placement.every((tileId, cellIndex) => tileId === cellIndex); +} diff --git a/src/tests/gameModuleImports.test.js b/src/tests/gameModuleImports.test.js index ba98900f..5aefb4fe 100644 --- a/src/tests/gameModuleImports.test.js +++ b/src/tests/gameModuleImports.test.js @@ -74,6 +74,7 @@ const GAME_MODULES = { 'language-quiz': 'LanguageQuiz', 'letter-web': 'LetterWeb', 'letter-orbit': 'LetterOrbit', + 'letter-triangles': 'LetterTriangles', 'lights-out': 'LightsOut', 'lightup': 'Lightup', 'lits': 'LITS', diff --git a/src/tests/gameReadmeCheck.test.js b/src/tests/gameReadmeCheck.test.js index b528cad2..9ddfd106 100644 --- a/src/tests/gameReadmeCheck.test.js +++ b/src/tests/gameReadmeCheck.test.js @@ -80,6 +80,7 @@ describe('Game README Files', () => { 'language-quiz': 'LanguageQuiz', 'letter-web': 'LetterWeb', 'letter-orbit': 'LetterOrbit', + 'letter-triangles': 'LetterTriangles', 'lights-out': 'LightsOut', 'lightup': 'Lightup', 'lits': 'LITS', From d35f826d588ab30188d482415075f9269826cab3 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 13:55:52 +0000 Subject: [PATCH 2/8] Lighten Letter Triangles board styling --- .../LetterTriangles.module.css | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css index cee6e4ea..3011d2a4 100644 --- a/src/pages/LetterTriangles/LetterTriangles.module.css +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -2,6 +2,9 @@ max-width: 980px; margin: 0 auto; padding: 1rem; + background: #ffffff; + border-radius: 14px; + border: 1px solid #dbe4ee; } .topBar { @@ -11,9 +14,9 @@ } .actionButton { - background: #334155; - color: white; - border: 1px solid #475569; + background: #f8fafc; + color: #0f172a; + border: 1px solid #cbd5e1; border-radius: 8px; padding: 0.45rem 0.8rem; cursor: pointer; @@ -46,9 +49,9 @@ position: relative; width: 100%; height: 100%; - background: rgba(30, 41, 59, 0.85); + background: #ffffff; clip-path: polygon(50% 4%, 4% 96%, 96% 96%); - border: 2px solid rgba(148, 163, 184, 0.75); + border: 2px solid #94a3b8; } .down .tileFace { @@ -57,7 +60,7 @@ .letter { position: absolute; - color: #e2e8f0; + color: #0f172a; font-weight: 700; font-size: 1rem; } @@ -92,14 +95,14 @@ .selectionHint { margin-bottom: 0.75rem; - color: #93c5fd; + color: #1d4ed8; } .linesPanel { - border: 1px solid #334155; + border: 1px solid #cbd5e1; border-radius: 10px; padding: 0.75rem; - background: rgba(15, 23, 42, 0.6); + background: #f8fafc; } .lineRow { @@ -110,8 +113,14 @@ margin-bottom: 0.3rem; } -.lineLabel { color: #94a3b8; } -.currentWord { color: #e2e8f0; } +.lineLabel { color: #475569; } +.currentWord { color: #0f172a; } .correct { color: #22c55e; font-weight: 700; } .incorrect { color: #f97316; font-weight: 700; } -.pending { color: #64748b; } +.pending { color: #334155; } + + +.cell:hover .tileFace, +.trayTile:hover .tileFace { + border-color: #3b82f6; +} From e7ca9be12671a883d42a83d37f399d53d11e6b1b Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 13:59:02 +0000 Subject: [PATCH 3/8] Add visible board and cell outlines in Letter Triangles --- src/pages/LetterTriangles/LetterTriangles.module.css | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css index 3011d2a4..6281ed9a 100644 --- a/src/pages/LetterTriangles/LetterTriangles.module.css +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -28,6 +28,10 @@ flex-direction: column; align-items: center; gap: 0.5rem; + border: 2px solid #475569; + border-radius: 12px; + padding: 0.75rem; + background: #0f172a; } .boardRow { @@ -39,12 +43,17 @@ .trayTile { width: 88px; height: 78px; - border: none; + border: 2px solid #64748b; + border-radius: 8px; background: transparent; cursor: pointer; padding: 0; } +.cell { + background: rgba(148, 163, 184, 0.12); +} + .tileFace { position: relative; width: 100%; From d0476ab43ef0c7d33e075ae1e3a41c1b53dbbbb9 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 14:12:49 +0000 Subject: [PATCH 4/8] Use valid common words in Letter Triangles generation --- .../LetterTriangles.module.css | 5 +- .../LetterTriangles/LetterTriangles.test.js | 15 +- .../LetterTriangles/letterTrianglesLogic.js | 146 ++++++++---------- 3 files changed, 79 insertions(+), 87 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css index 6281ed9a..e6f72a62 100644 --- a/src/pages/LetterTriangles/LetterTriangles.module.css +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -45,14 +45,11 @@ height: 78px; border: 2px solid #64748b; border-radius: 8px; - background: transparent; + background: #0f172a; cursor: pointer; padding: 0; } -.cell { - background: rgba(148, 163, 184, 0.12); -} .tileFace { position: relative; diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js index afe08123..6c5aa841 100644 --- a/src/pages/LetterTriangles/LetterTriangles.test.js +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { isValidWord, isCommonWord } from '../../data/wordUtils'; import { CELL_COUNT, generateLetterTrianglesPuzzle, @@ -16,16 +17,28 @@ describe('Letter Triangles generator', () => { expect(a.shuffledTiles).toEqual(b.shuffledTiles); }); - it('generates words with strict 1..8 lengths', () => { + it('generates valid English words with strict 1..8 lengths', () => { const puzzle = generateLetterTrianglesPuzzle(99); expect(puzzle.targetWords).toHaveLength(8); puzzle.targetWords.forEach((word, index) => { expect(word).toMatch(/^[A-Z]+$/); expect(word.length).toBe(index + 1); + + if (word.length >= 3) { + expect(isValidWord(word)).toBe(true); + } }); }); + it('prefers common words for most rows', () => { + const puzzle = generateLetterTrianglesPuzzle(314159); + const longRows = puzzle.targetWords.filter((word) => word.length >= 3); + const commonCount = longRows.filter((word) => isCommonWord(word)).length; + + expect(commonCount).toBeGreaterThanOrEqual(2); + }); + it('solved placement reconstructs exactly the target line words', () => { const puzzle = generateLetterTrianglesPuzzle(42); const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index); diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js index abb8d5f8..94eddb2f 100644 --- a/src/pages/LetterTriangles/letterTrianglesLogic.js +++ b/src/pages/LetterTriangles/letterTrianglesLogic.js @@ -1,4 +1,4 @@ -import { createSeededRandom, seededShuffleArray, getWordsByLength } from '../../data/wordUtils'; +import { createSeededRandom, seededShuffleArray, getWordsByLength, isCommonWord } from '../../data/wordUtils'; export const CELL_COUNT = 9; export const CORNERS = ['A', 'B', 'C']; @@ -15,17 +15,33 @@ export const LINE_DEFINITIONS = [ ]; const ONE_LETTER_WORDS = ['A', 'I']; -const LETTERS = 'ETAOINSHRDLUCMWFGYPBVKJXQZ'; +const COMMON_TWO_LETTER_WORDS = [ + 'AM', 'AN', 'AS', 'AT', 'BE', 'BY', 'DO', 'GO', 'HE', 'IF', 'IN', 'IS', 'IT', 'ME', 'MY', + 'NO', 'OF', 'ON', 'OR', 'OX', 'SO', 'TO', 'UP', 'US', 'WE' +]; + +const WORD_POOL_CACHE = new Map(); function slotKey(cell, corner) { return `${cell}:${corner}`; } function getWordPool(length) { - if (length === 1) return ONE_LETTER_WORDS; - return getWordsByLength(length) - .map((word) => word.toUpperCase()) - .filter((word) => /^[A-Z]+$/.test(word)); + if (WORD_POOL_CACHE.has(length)) return WORD_POOL_CACHE.get(length); + + let words; + if (length === 1) { + words = ONE_LETTER_WORDS; + } else if (length === 2) { + words = COMMON_TWO_LETTER_WORDS; + } else { + words = getWordsByLength(length) + .map((word) => word.toUpperCase()) + .filter((word) => /^[A-Z]+$/.test(word)); + } + + WORD_POOL_CACHE.set(length, words); + return words; } function compatibleWithPattern(word, pattern) { @@ -35,103 +51,69 @@ function compatibleWithPattern(word, pattern) { return true; } -export function generateLetterTrianglesPuzzle(seed) { - const baseRandom = createSeededRandom(seed); - - for (let attempt = 0; attempt < 8; attempt++) { - const attemptSeed = seed + attempt * 104729; - const random = createSeededRandom(attemptSeed); - - const pools = LINE_DEFINITIONS.map((_, idx) => seededShuffleArray([...getWordPool(idx + 1)], random)); +function generateAttempt(seed, preferCommon) { + const random = createSeededRandom(seed); + const pools = LINE_DEFINITIONS.map((_, idx) => seededShuffleArray([...getWordPool(idx + 1)], random)); - const assignedSlots = new Map(); - const usedWords = new Set(); - const chosenWords = []; + const assignedSlots = new Map(); + const usedWords = new Set(); + const chosenWords = []; - function search(lineIndex) { - if (lineIndex === LINE_DEFINITIONS.length) return true; + for (let lineIndex = 0; lineIndex < LINE_DEFINITIONS.length; lineIndex++) { + const line = LINE_DEFINITIONS[lineIndex]; + const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, corner)) || null); - const line = LINE_DEFINITIONS[lineIndex]; - const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, corner)) || null); + const matching = []; + const matchingCommon = []; - const candidates = pools[lineIndex] - .filter((word) => !usedWords.has(word) && compatibleWithPattern(word, pattern)) - .slice(0, 900); - - for (const word of candidates) { - const changed = []; - - for (let i = 0; i < line.length; i++) { - const key = slotKey(line[i].cell, line[i].corner); - const existing = assignedSlots.get(key); - if (!existing) { - assignedSlots.set(key, word[i]); - changed.push(key); - } - } - - usedWords.add(word); - chosenWords.push(word); - - if (search(lineIndex + 1)) return true; - - chosenWords.pop(); - usedWords.delete(word); - changed.forEach((key) => assignedSlots.delete(key)); + for (const word of pools[lineIndex]) { + if (usedWords.has(word) || !compatibleWithPattern(word, pattern)) continue; + matching.push(word); + if (word.length <= 2 || isCommonWord(word)) { + matchingCommon.push(word); } - - return false; + if (matching.length >= 1200) break; } - if (!search(0)) continue; + const candidatePool = preferCommon && matchingCommon.length > 0 ? matchingCommon : matching; + if (candidatePool.length === 0) return null; - for (let cell = 0; cell < CELL_COUNT; cell++) { - for (const corner of CORNERS) { - const key = slotKey(cell, corner); - if (!assignedSlots.has(key)) { - assignedSlots.set(key, LETTERS[Math.floor(random() * LETTERS.length)]); - } - } - } - - const solvedTiles = Array.from({ length: CELL_COUNT }, (_, cell) => ({ - id: cell, - letters: CORNERS.map((corner) => assignedSlots.get(slotKey(cell, corner))), - })); - - return { - seed, - targetWords: chosenWords, - solvedTiles, - shuffledTiles: seededShuffleArray([...solvedTiles], baseRandom), - }; - } + const chosen = candidatePool[Math.floor(random() * candidatePool.length)]; + chosenWords.push(chosen); + usedWords.add(chosen); - // Fallback: generate a guaranteed solvable letter layout even if dictionary matching fails - const fallbackSlots = new Map(); - for (let cell = 0; cell < CELL_COUNT; cell++) { - for (const corner of CORNERS) { - fallbackSlots.set(slotKey(cell, corner), LETTERS[Math.floor(baseRandom() * LETTERS.length)]); + for (let i = 0; i < line.length; i++) { + const key = slotKey(line[i].cell, line[i].corner); + if (!assignedSlots.has(key)) assignedSlots.set(key, chosen[i]); } } const solvedTiles = Array.from({ length: CELL_COUNT }, (_, cell) => ({ id: cell, - letters: CORNERS.map((corner) => fallbackSlots.get(slotKey(cell, corner))), + letters: CORNERS.map((corner) => assignedSlots.get(slotKey(cell, corner))), })); - const targetWords = LINE_DEFINITIONS.map((line) => - line.map(({ cell, corner }) => fallbackSlots.get(slotKey(cell, corner))).join('') - ); - return { - seed, - targetWords, + targetWords: chosenWords, solvedTiles, - shuffledTiles: seededShuffleArray([...solvedTiles], baseRandom), + shuffledTiles: seededShuffleArray([...solvedTiles], random), }; } +export function generateLetterTrianglesPuzzle(seed) { + for (let attempt = 0; attempt < 80; attempt++) { + const generated = generateAttempt(seed + attempt * 7919, true); + if (generated) return { seed, ...generated }; + } + + for (let attempt = 0; attempt < 120; attempt++) { + const generated = generateAttempt(seed + 500000 + attempt * 1543, false); + if (generated) return { seed, ...generated }; + } + + throw new Error('Failed to generate Letter Triangles puzzle using valid words'); +} + export function buildLineWordsFromPlacement(placement, tileById) { return LINE_DEFINITIONS.map((line) => line.map(({ cell, corner }) => { const tileId = placement[cell]; From bf101b8088e7af8b0267c8b066d07521daeefdda Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 14:30:25 +0000 Subject: [PATCH 5/8] Fix Letter Triangles line model and upside-down labels --- .../LetterTriangles.module.css | 9 +++++++++ .../LetterTriangles/LetterTriangles.test.js | 7 ++++--- src/pages/LetterTriangles/README.md | 2 +- .../LetterTriangles/letterTrianglesLogic.js | 20 +++++++++++-------- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css index e6f72a62..078acd6a 100644 --- a/src/pages/LetterTriangles/LetterTriangles.module.css +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -87,6 +87,15 @@ right: 25%; } +.down .top { + transform: translateX(-50%) rotate(180deg); +} + +.down .left, +.down .right { + transform: rotate(180deg); +} + .tray { display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js index 6c5aa841..f84628ae 100644 --- a/src/pages/LetterTriangles/LetterTriangles.test.js +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -17,13 +17,14 @@ describe('Letter Triangles generator', () => { expect(a.shuffledTiles).toEqual(b.shuffledTiles); }); - it('generates valid English words with strict 1..8 lengths', () => { + it('generates valid English words with configured line lengths', () => { const puzzle = generateLetterTrianglesPuzzle(99); - expect(puzzle.targetWords).toHaveLength(8); + const expectedLengths = [1, 2, 4, 5, 7, 8]; + expect(puzzle.targetWords).toHaveLength(expectedLengths.length); puzzle.targetWords.forEach((word, index) => { expect(word).toMatch(/^[A-Z]+$/); - expect(word.length).toBe(index + 1); + expect(word.length).toBe(expectedLengths[index]); if (word.length >= 3) { expect(isValidWord(word)).toBe(true); diff --git a/src/pages/LetterTriangles/README.md b/src/pages/LetterTriangles/README.md index 1dbf27bf..947320c6 100644 --- a/src/pages/LetterTriangles/README.md +++ b/src/pages/LetterTriangles/README.md @@ -14,6 +14,6 @@ Each tile has three letters. Place the tiles so that every line shown under the ## Puzzle generation - Uses a seeded generator for reproducibility. -- Chooses English words with lengths 1 through 8. +- Chooses English words with lengths 1, 2, 4, 5, 7, and 8. - Writes those words into overlapping line definitions. - Builds tile letters from the solved layout and shuffles the tiles. diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js index 94eddb2f..01c40b03 100644 --- a/src/pages/LetterTriangles/letterTrianglesLogic.js +++ b/src/pages/LetterTriangles/letterTrianglesLogic.js @@ -4,14 +4,18 @@ export const CELL_COUNT = 9; export const CORNERS = ['A', 'B', 'C']; export const LINE_DEFINITIONS = [ + // Row 1 top (1) [{ cell: 0, corner: 'A' }], - [{ cell: 0, corner: 'A' }, { cell: 0, corner: 'B' }], - [{ cell: 0, corner: 'B' }, { cell: 0, corner: 'C' }, { cell: 1, corner: 'A' }], - [{ cell: 1, corner: 'A' }, { cell: 1, corner: 'B' }, { cell: 1, corner: 'C' }, { cell: 2, corner: 'A' }], - [{ cell: 2, corner: 'A' }, { cell: 2, corner: 'B' }, { cell: 2, corner: 'C' }, { cell: 3, corner: 'A' }, { cell: 3, corner: 'B' }], - [{ cell: 3, corner: 'B' }, { cell: 3, corner: 'C' }, { cell: 4, corner: 'A' }, { cell: 4, corner: 'B' }, { cell: 4, corner: 'C' }, { cell: 5, corner: 'A' }], - [{ cell: 5, corner: 'A' }, { cell: 5, corner: 'B' }, { cell: 5, corner: 'C' }, { cell: 6, corner: 'A' }, { cell: 6, corner: 'B' }, { cell: 6, corner: 'C' }, { cell: 7, corner: 'A' }], - [{ cell: 7, corner: 'A' }, { cell: 7, corner: 'B' }, { cell: 7, corner: 'C' }, { cell: 8, corner: 'A' }, { cell: 8, corner: 'B' }, { cell: 8, corner: 'C' }, { cell: 0, corner: 'C' }, { cell: 1, corner: 'B' }], + // Row 1 bottom edge (2) + [{ cell: 0, corner: 'B' }, { cell: 0, corner: 'C' }], + // Row 2 upper pattern (4) + [{ cell: 1, corner: 'A' }, { cell: 2, corner: 'B' }, { cell: 2, corner: 'C' }, { cell: 3, corner: 'A' }], + // Row 2 lower pattern (5) + [{ cell: 1, corner: 'B' }, { cell: 1, corner: 'C' }, { cell: 2, corner: 'A' }, { cell: 3, corner: 'B' }, { cell: 3, corner: 'C' }], + // Row 3 upper pattern (7) + [{ cell: 4, corner: 'A' }, { cell: 5, corner: 'B' }, { cell: 5, corner: 'C' }, { cell: 6, corner: 'A' }, { cell: 7, corner: 'B' }, { cell: 7, corner: 'C' }, { cell: 8, corner: 'A' }], + // Row 3 lower pattern (8) + [{ cell: 4, corner: 'B' }, { cell: 4, corner: 'C' }, { cell: 5, corner: 'A' }, { cell: 6, corner: 'B' }, { cell: 6, corner: 'C' }, { cell: 7, corner: 'A' }, { cell: 8, corner: 'B' }, { cell: 8, corner: 'C' }], ]; const ONE_LETTER_WORDS = ['A', 'I']; @@ -53,7 +57,7 @@ function compatibleWithPattern(word, pattern) { function generateAttempt(seed, preferCommon) { const random = createSeededRandom(seed); - const pools = LINE_DEFINITIONS.map((_, idx) => seededShuffleArray([...getWordPool(idx + 1)], random)); + const pools = LINE_DEFINITIONS.map((line) => seededShuffleArray([...getWordPool(line.length)], random)); const assignedSlots = new Map(); const usedWords = new Set(); From eaecc87043a98436235139c84c2549619783249c Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 15:35:08 +0000 Subject: [PATCH 6/8] Handle upside-down triangle letter reversal in line logic --- src/pages/LetterTriangles/LetterTriangles.test.js | 13 +++++++++++++ src/pages/LetterTriangles/letterTrianglesLogic.js | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js index f84628ae..42ee3423 100644 --- a/src/pages/LetterTriangles/LetterTriangles.test.js +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -50,6 +50,19 @@ describe('Letter Triangles generator', () => { expect(isSolvedPlacement(solvedPlacement)).toBe(true); }); + + + it('applies upside-down cell corner reversal when building lines', () => { + const placement = Array.from({ length: CELL_COUNT }, (_, index) => index); + const tiles = Array.from({ length: CELL_COUNT }, (_, id) => ({ id, letters: ['A', 'B', 'C'] })); + const tileById = new Map(tiles.map((tile) => [tile.id, tile])); + + const lines = buildLineWordsFromPlacement(placement, tileById); + + // Line 3 includes cell 2 B then C; for upside-down cells this should read C then B + expect(lines[2]).toBe('ACBA'); + }); + it('unsolved placements are never falsely considered solved', () => { const placement = [0, 1, 2, 3, 4, 5, 6, 8, 7]; expect(isSolvedPlacement(placement)).toBe(false); diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js index 01c40b03..b657cc4d 100644 --- a/src/pages/LetterTriangles/letterTrianglesLogic.js +++ b/src/pages/LetterTriangles/letterTrianglesLogic.js @@ -26,6 +26,15 @@ const COMMON_TWO_LETTER_WORDS = [ const WORD_POOL_CACHE = new Map(); +const DOWN_CELLS = new Set([2, 5, 7]); + +function getPhysicalCorner(cell, displayCorner) { + if (!DOWN_CELLS.has(cell)) return displayCorner; + if (displayCorner === 'B') return 'C'; + if (displayCorner === 'C') return 'B'; + return displayCorner; +} + function slotKey(cell, corner) { return `${cell}:${corner}`; } @@ -65,7 +74,7 @@ function generateAttempt(seed, preferCommon) { for (let lineIndex = 0; lineIndex < LINE_DEFINITIONS.length; lineIndex++) { const line = LINE_DEFINITIONS[lineIndex]; - const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, corner)) || null); + const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, getPhysicalCorner(cell, corner))) || null); const matching = []; const matchingCommon = []; @@ -87,7 +96,7 @@ function generateAttempt(seed, preferCommon) { usedWords.add(chosen); for (let i = 0; i < line.length; i++) { - const key = slotKey(line[i].cell, line[i].corner); + const key = slotKey(line[i].cell, getPhysicalCorner(line[i].cell, line[i].corner)); if (!assignedSlots.has(key)) assignedSlots.set(key, chosen[i]); } } @@ -123,7 +132,7 @@ export function buildLineWordsFromPlacement(placement, tileById) { const tileId = placement[cell]; if (tileId === null || tileId === undefined) return '_'; const tile = tileById.get(tileId); - const cornerIndex = CORNERS.indexOf(corner); + const cornerIndex = CORNERS.indexOf(getPhysicalCorner(cell, corner)); return tile?.letters[cornerIndex] ?? '_'; }).join('')); } From aa40c204c3ae81e67da1004a7fbcf23186f12802 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 15:49:54 +0000 Subject: [PATCH 7/8] Add drag-and-drop and tile rotation to Letter Triangles --- src/pages/LetterTriangles/LetterTriangles.jsx | 161 +++++++++++++++--- .../LetterTriangles.module.css | 51 +++++- .../LetterTriangles/LetterTriangles.test.js | 38 +++-- .../LetterTriangles/letterTrianglesLogic.js | 65 ++++--- 4 files changed, 250 insertions(+), 65 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.jsx b/src/pages/LetterTriangles/LetterTriangles.jsx index 5af4d3e7..bdf8031f 100644 --- a/src/pages/LetterTriangles/LetterTriangles.jsx +++ b/src/pages/LetterTriangles/LetterTriangles.jsx @@ -9,6 +9,7 @@ import { generateLetterTrianglesPuzzle, buildLineWordsFromPlacement, isSolvedPlacement, + isDownCell, } from './letterTrianglesLogic'; import styles from './LetterTriangles.module.css'; @@ -22,12 +23,16 @@ export default function LetterTriangles() { const [placement, setPlacement] = useState(() => Array(CELL_COUNT).fill(null)); const [selectedTileId, setSelectedTileId] = useState(null); const [gaveUp, setGaveUp] = useState(false); + const [rotations, setRotations] = useState(() => ({})); + const [draggingTileId, setDraggingTileId] = useState(null); useEffect(() => { - setPuzzle(generateLetterTrianglesPuzzle(seed)); + const next = generateLetterTrianglesPuzzle(seed); + setPuzzle(next); setPlacement(Array(CELL_COUNT).fill(null)); setSelectedTileId(null); setGaveUp(false); + setRotations(next.initialRotations || {}); }, [seed]); const tileById = useMemo(() => new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile])), [puzzle]); @@ -37,8 +42,40 @@ export default function LetterTriangles() { return puzzle.shuffledTiles.map((tile) => tile.id).filter((id) => !placed.has(id)); }, [placement, puzzle]); - const lineWords = useMemo(() => buildLineWordsFromPlacement(placement, tileById), [placement, tileById]); - const solved = isSolvedPlacement(placement); + const lineWords = useMemo(() => buildLineWordsFromPlacement(placement, tileById, rotations), [placement, tileById, rotations]); + const solved = isSolvedPlacement(placement, rotations); + + const rotateTile = useCallback((tileId) => { + if (gaveUp || solved) return; + setRotations((prev) => ({ + ...prev, + [tileId]: ((prev[tileId] ?? 0) + 1) % 3, + })); + }, [gaveUp, solved]); + + const moveTileToCell = useCallback((tileId, targetCellIndex) => { + setPlacement((prev) => { + const next = [...prev]; + const fromIndex = next.findIndex((id) => id === tileId); + const targetOccupant = next[targetCellIndex]; + + if (fromIndex !== -1) { + next[fromIndex] = targetOccupant; + } + + next[targetCellIndex] = tileId; + return next; + }); + }, []); + + const returnTileToTray = useCallback((tileId) => { + setPlacement((prev) => { + const next = [...prev]; + const idx = next.findIndex((id) => id === tileId); + if (idx !== -1) next[idx] = null; + return next; + }); + }, []); const handleCellClick = useCallback((cellIndex) => { if (gaveUp || solved) return; @@ -55,6 +92,8 @@ export default function LetterTriangles() { return next; } + const fromIndex = next.findIndex((id) => id === selectedTileId); + if (fromIndex !== -1) next[fromIndex] = occupant; next[cellIndex] = selectedTileId; setSelectedTileId(occupant); return next; @@ -63,7 +102,6 @@ export default function LetterTriangles() { const handleTileClick = useCallback((tileId) => { if (gaveUp || solved) return; - setSelectedTileId((current) => (current === tileId ? null : tileId)); }, [gaveUp, solved]); @@ -71,15 +109,54 @@ export default function LetterTriangles() { setPlacement(Array.from({ length: CELL_COUNT }, (_, index) => index)); setSelectedTileId(null); setGaveUp(true); + setRotations(() => { + const reset = {}; + for (let i = 0; i < CELL_COUNT; i++) reset[i] = 0; + return reset; + }); + }, []); + + const handleDragStart = useCallback((tileId, event) => { + if (gaveUp || solved) return; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(tileId)); + setDraggingTileId(tileId); + }, [gaveUp, solved]); + + const handleDragEnd = useCallback(() => { + setDraggingTileId(null); }, []); - const renderTile = (tileId) => { + const handleDropOnCell = useCallback((cellIndex, event) => { + event.preventDefault(); + if (gaveUp || solved) return; + const tileId = Number(event.dataTransfer.getData('text/plain')); + if (Number.isNaN(tileId)) return; + moveTileToCell(tileId, cellIndex); + setSelectedTileId(null); + setDraggingTileId(null); + }, [gaveUp, solved, moveTileToCell]); + + const handleDropOnTray = useCallback((event) => { + event.preventDefault(); + if (gaveUp || solved) return; + const tileId = Number(event.dataTransfer.getData('text/plain')); + if (Number.isNaN(tileId)) return; + returnTileToTray(tileId); + setSelectedTileId(null); + setDraggingTileId(null); + }, [gaveUp, solved, returnTileToTray]); + + const renderTile = (tileId, cellIndex = null) => { if (tileId === null || tileId === undefined) return null; const tile = tileById.get(tileId); const [a, b, c] = tile.letters; + const rotation = rotations[tileId] ?? 0; + const isDown = cellIndex !== null && isDownCell(cellIndex); + const angle = rotation * 120 + (isDown ? 180 : 0); return ( -
+
{a} {b} {c} @@ -93,7 +170,7 @@ export default function LetterTriangles() {
@@ -124,18 +201,46 @@ export default function LetterTriangles() {
{BOARD_ROWS.map((rowSize, rowIdx) => (
- {Array.from({ length: rowSize }).map((_, i) => { + {Array.from({ length: rowSize }).map(() => { const current = cellIndex++; - const orientation = i % 2 === 1 ? styles.down : styles.up; + const tileId = placement[current]; return ( - + + + {tileId !== null && tileId !== undefined && ( + <> + +
handleDragStart(tileId, event)} + onDragEnd={handleDragEnd} + aria-label={t('letterTriangles.dragTile', { defaultValue: 'Drag tile' })} + title={t('letterTriangles.dragTile', { defaultValue: 'Drag tile' })} + > + ⋮⋮ +
+ + )} +
); })}
@@ -148,16 +253,30 @@ export default function LetterTriangles() {

)} -
+
event.preventDefault()} onDrop={handleDropOnTray}> {trayTileIds.map((tileId) => ( - + + +
))}
diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css index 078acd6a..22e0f4df 100644 --- a/src/pages/LetterTriangles/LetterTriangles.module.css +++ b/src/pages/LetterTriangles/LetterTriangles.module.css @@ -46,10 +46,23 @@ border: 2px solid #64748b; border-radius: 8px; background: #0f172a; - cursor: pointer; padding: 0; + position: relative; } +.cellButton, +.trayMainButton { + border: none; + background: transparent; + width: 100%; + height: 100%; + padding: 0; + cursor: pointer; +} + +.dragTarget { + border-color: #93c5fd; +} .tileFace { position: relative; @@ -58,12 +71,16 @@ background: #ffffff; clip-path: polygon(50% 4%, 4% 96%, 96% 96%); border: 2px solid #94a3b8; + transition: transform 120ms ease; } -.down .tileFace { + + +.down { transform: rotate(180deg); } + .letter { position: absolute; color: #0f172a; @@ -96,11 +113,40 @@ transform: rotate(180deg); } +.rotateButton { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border: 1px solid #94a3b8; + border-radius: 999px; + background: #e2e8f0; + font-size: 0.85rem; + line-height: 1; + cursor: pointer; +} + +.dragHandle { + position: absolute; + top: 4px; + left: 4px; + color: #cbd5e1; + font-size: 0.8rem; + letter-spacing: -1px; + user-select: none; + cursor: grab; +} + .tray { display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 0.4rem; margin-bottom: 1rem; + min-height: 84px; + border: 1px dashed #64748b; + padding: 0.35rem; + border-radius: 8px; } .selected .tileFace { @@ -134,7 +180,6 @@ .incorrect { color: #f97316; font-weight: 700; } .pending { color: #334155; } - .cell:hover .tileFace, .trayTile:hover .tileFace { border-color: #3b82f6; diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js index 42ee3423..def2f1be 100644 --- a/src/pages/LetterTriangles/LetterTriangles.test.js +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -5,6 +5,7 @@ import { generateLetterTrianglesPuzzle, buildLineWordsFromPlacement, isSolvedPlacement, + getTileLetterForLine, } from './letterTrianglesLogic'; describe('Letter Triangles generator', () => { @@ -15,6 +16,16 @@ describe('Letter Triangles generator', () => { expect(a.targetWords).toEqual(b.targetWords); expect(a.solvedTiles).toEqual(b.solvedTiles); expect(a.shuffledTiles).toEqual(b.shuffledTiles); + expect(a.initialRotations).toEqual(b.initialRotations); + }); + + it('seeds each tile with a random initial rotation', () => { + const puzzle = generateLetterTrianglesPuzzle(9876); + expect(Object.keys(puzzle.initialRotations)).toHaveLength(CELL_COUNT); + Object.values(puzzle.initialRotations).forEach((rotation) => { + expect(rotation).toBeGreaterThanOrEqual(0); + expect(rotation).toBeLessThanOrEqual(2); + }); }); it('generates valid English words with configured line lengths', () => { @@ -40,31 +51,30 @@ describe('Letter Triangles generator', () => { expect(commonCount).toBeGreaterThanOrEqual(2); }); - it('solved placement reconstructs exactly the target line words', () => { + it('solved placement reconstructs exactly the target line words when rotations are reset', () => { const puzzle = generateLetterTrianglesPuzzle(42); const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index); + const zeroRotations = Object.fromEntries(solvedPlacement.map((id) => [id, 0])); const tileById = new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile])); - const lines = buildLineWordsFromPlacement(solvedPlacement, tileById); + const lines = buildLineWordsFromPlacement(solvedPlacement, tileById, zeroRotations); expect(lines).toEqual(puzzle.targetWords); - expect(isSolvedPlacement(solvedPlacement)).toBe(true); + expect(isSolvedPlacement(solvedPlacement, zeroRotations)).toBe(true); }); + it('applies upside-down cell reversal before rotation lookup', () => { + const tile = { letters: ['A', 'B', 'C'] }; - - it('applies upside-down cell corner reversal when building lines', () => { - const placement = Array.from({ length: CELL_COUNT }, (_, index) => index); - const tiles = Array.from({ length: CELL_COUNT }, (_, id) => ({ id, letters: ['A', 'B', 'C'] })); - const tileById = new Map(tiles.map((tile) => [tile.id, tile])); - - const lines = buildLineWordsFromPlacement(placement, tileById); - - // Line 3 includes cell 2 B then C; for upside-down cells this should read C then B - expect(lines[2]).toBe('ACBA'); + // Cell 2 is down: display corner B maps to physical C. + // With one clockwise turn, display B should use previous corner -> B. + expect(getTileLetterForLine(tile, 'B', 1, 2)).toBe('B'); }); - it('unsolved placements are never falsely considered solved', () => { + it('unsolved placements or rotations are never falsely considered solved', () => { const placement = [0, 1, 2, 3, 4, 5, 6, 8, 7]; expect(isSolvedPlacement(placement)).toBe(false); + + const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index); + expect(isSolvedPlacement(solvedPlacement, { 0: 1 })).toBe(false); }); }); diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js index b657cc4d..16fb35b7 100644 --- a/src/pages/LetterTriangles/letterTrianglesLogic.js +++ b/src/pages/LetterTriangles/letterTrianglesLogic.js @@ -4,17 +4,11 @@ export const CELL_COUNT = 9; export const CORNERS = ['A', 'B', 'C']; export const LINE_DEFINITIONS = [ - // Row 1 top (1) [{ cell: 0, corner: 'A' }], - // Row 1 bottom edge (2) [{ cell: 0, corner: 'B' }, { cell: 0, corner: 'C' }], - // Row 2 upper pattern (4) [{ cell: 1, corner: 'A' }, { cell: 2, corner: 'B' }, { cell: 2, corner: 'C' }, { cell: 3, corner: 'A' }], - // Row 2 lower pattern (5) [{ cell: 1, corner: 'B' }, { cell: 1, corner: 'C' }, { cell: 2, corner: 'A' }, { cell: 3, corner: 'B' }, { cell: 3, corner: 'C' }], - // Row 3 upper pattern (7) [{ cell: 4, corner: 'A' }, { cell: 5, corner: 'B' }, { cell: 5, corner: 'C' }, { cell: 6, corner: 'A' }, { cell: 7, corner: 'B' }, { cell: 7, corner: 'C' }, { cell: 8, corner: 'A' }], - // Row 3 lower pattern (8) [{ cell: 4, corner: 'B' }, { cell: 4, corner: 'C' }, { cell: 5, corner: 'A' }, { cell: 6, corner: 'B' }, { cell: 6, corner: 'C' }, { cell: 7, corner: 'A' }, { cell: 8, corner: 'B' }, { cell: 8, corner: 'C' }], ]; @@ -25,16 +19,8 @@ const COMMON_TWO_LETTER_WORDS = [ ]; const WORD_POOL_CACHE = new Map(); - const DOWN_CELLS = new Set([2, 5, 7]); -function getPhysicalCorner(cell, displayCorner) { - if (!DOWN_CELLS.has(cell)) return displayCorner; - if (displayCorner === 'B') return 'C'; - if (displayCorner === 'C') return 'B'; - return displayCorner; -} - function slotKey(cell, corner) { return `${cell}:${corner}`; } @@ -43,11 +29,9 @@ function getWordPool(length) { if (WORD_POOL_CACHE.has(length)) return WORD_POOL_CACHE.get(length); let words; - if (length === 1) { - words = ONE_LETTER_WORDS; - } else if (length === 2) { - words = COMMON_TWO_LETTER_WORDS; - } else { + if (length === 1) words = ONE_LETTER_WORDS; + else if (length === 2) words = COMMON_TWO_LETTER_WORDS; + else { words = getWordsByLength(length) .map((word) => word.toUpperCase()) .filter((word) => /^[A-Z]+$/.test(word)); @@ -64,6 +48,29 @@ function compatibleWithPattern(word, pattern) { return true; } +export function isDownCell(cell) { + return DOWN_CELLS.has(cell); +} + +export function getPhysicalCorner(cell, displayCorner) { + if (!isDownCell(cell)) return displayCorner; + if (displayCorner === 'B') return 'C'; + if (displayCorner === 'C') return 'B'; + return displayCorner; +} + +export function getRotatedCornerIndex(displayCorner, rotation = 0) { + const displayIndex = CORNERS.indexOf(displayCorner); + const normalized = ((rotation % 3) + 3) % 3; + return (displayIndex - normalized + 3) % 3; +} + +export function getTileLetterForLine(tile, displayCorner, rotation = 0, cellIndex = null) { + const effectiveCorner = cellIndex === null ? displayCorner : getPhysicalCorner(cellIndex, displayCorner); + const cornerIndex = getRotatedCornerIndex(effectiveCorner, rotation); + return tile?.letters[cornerIndex] ?? '_'; +} + function generateAttempt(seed, preferCommon) { const random = createSeededRandom(seed); const pools = LINE_DEFINITIONS.map((line) => seededShuffleArray([...getWordPool(line.length)], random)); @@ -82,9 +89,7 @@ function generateAttempt(seed, preferCommon) { for (const word of pools[lineIndex]) { if (usedWords.has(word) || !compatibleWithPattern(word, pattern)) continue; matching.push(word); - if (word.length <= 2 || isCommonWord(word)) { - matchingCommon.push(word); - } + if (word.length <= 2 || isCommonWord(word)) matchingCommon.push(word); if (matching.length >= 1200) break; } @@ -106,10 +111,16 @@ function generateAttempt(seed, preferCommon) { letters: CORNERS.map((corner) => assignedSlots.get(slotKey(cell, corner))), })); + const initialRotations = {}; + for (const tile of solvedTiles) { + initialRotations[tile.id] = Math.floor(random() * 3); + } + return { targetWords: chosenWords, solvedTiles, shuffledTiles: seededShuffleArray([...solvedTiles], random), + initialRotations, }; } @@ -127,16 +138,16 @@ export function generateLetterTrianglesPuzzle(seed) { throw new Error('Failed to generate Letter Triangles puzzle using valid words'); } -export function buildLineWordsFromPlacement(placement, tileById) { +export function buildLineWordsFromPlacement(placement, tileById, rotations = {}) { return LINE_DEFINITIONS.map((line) => line.map(({ cell, corner }) => { const tileId = placement[cell]; if (tileId === null || tileId === undefined) return '_'; const tile = tileById.get(tileId); - const cornerIndex = CORNERS.indexOf(getPhysicalCorner(cell, corner)); - return tile?.letters[cornerIndex] ?? '_'; + const rotation = rotations[tileId] ?? 0; + return getTileLetterForLine(tile, corner, rotation, cell); }).join('')); } -export function isSolvedPlacement(placement) { - return placement.every((tileId, cellIndex) => tileId === cellIndex); +export function isSolvedPlacement(placement, rotations = {}) { + return placement.every((tileId, cellIndex) => tileId === cellIndex && ((rotations[tileId] ?? 0) % 3) === 0); } From e716ac6f47ddeb2ae7f36a2621cec2e2dde1b857 Mon Sep 17 00:00:00 2001 From: Ian Hunter Date: Fri, 27 Feb 2026 16:31:32 +0000 Subject: [PATCH 8/8] Fix Letter Triangles to rotate letters without rotating tiles --- src/pages/LetterTriangles/LetterTriangles.jsx | 8 +++++--- src/pages/LetterTriangles/LetterTriangles.test.js | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/LetterTriangles/LetterTriangles.jsx b/src/pages/LetterTriangles/LetterTriangles.jsx index bdf8031f..896545a6 100644 --- a/src/pages/LetterTriangles/LetterTriangles.jsx +++ b/src/pages/LetterTriangles/LetterTriangles.jsx @@ -10,6 +10,7 @@ import { buildLineWordsFromPlacement, isSolvedPlacement, isDownCell, + getTileLetterForLine, } from './letterTrianglesLogic'; import styles from './LetterTriangles.module.css'; @@ -150,13 +151,14 @@ export default function LetterTriangles() { const renderTile = (tileId, cellIndex = null) => { if (tileId === null || tileId === undefined) return null; const tile = tileById.get(tileId); - const [a, b, c] = tile.letters; const rotation = rotations[tileId] ?? 0; const isDown = cellIndex !== null && isDownCell(cellIndex); - const angle = rotation * 120 + (isDown ? 180 : 0); + const a = getTileLetterForLine(tile, 'A', rotation, cellIndex); + const b = getTileLetterForLine(tile, 'B', rotation, cellIndex); + const c = getTileLetterForLine(tile, 'C', rotation, cellIndex); return ( -
+
{a} {b} {c} diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js index def2f1be..d284cfd4 100644 --- a/src/pages/LetterTriangles/LetterTriangles.test.js +++ b/src/pages/LetterTriangles/LetterTriangles.test.js @@ -70,6 +70,15 @@ describe('Letter Triangles generator', () => { expect(getTileLetterForLine(tile, 'B', 1, 2)).toBe('B'); }); + it('rotates letter positions without mutating source letter order', () => { + const tile = { letters: ['A', 'B', 'C'] }; + + expect(getTileLetterForLine(tile, 'A', 0, 0)).toBe('A'); + expect(getTileLetterForLine(tile, 'A', 1, 0)).toBe('C'); + expect(getTileLetterForLine(tile, 'A', 2, 0)).toBe('B'); + expect(tile.letters).toEqual(['A', 'B', 'C']); + }); + it('unsolved placements or rotations are never falsely considered solved', () => { const placement = [0, 1, 2, 3, 4, 5, 6, 8, 7]; expect(isSolvedPlacement(placement)).toBe(false);