more words

This commit is contained in:
2026-10-24 12:50:19 -04:00
parent cf18b5a99e
commit 9e44b07e98
10 changed files with 9241 additions and 3697 deletions

View File

@@ -1,6 +1,6 @@
import { sound } from '../engine/audio.js';
export function createTileGrid({ onTileSelected }) {
export function createTileGrid({ onTileSelected, onTogglePin }) {
const container = document.createElement('div');
container.className = 'board-container';
@@ -13,15 +13,27 @@ export function createTileGrid({ onTileSelected }) {
.map(tile => {
const isSelected = activeSet.has(tile.id);
const isLocked = tile.isLocked;
const isPinned = !!tile.isPinned;
return `
<button
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''}"
<div
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''} ${isPinned ? 'pinned' : ''}"
data-id="${tile.id}"
aria-label="Tile ${tile.text}"
role="button"
tabindex="0"
aria-label="Tile ${tile.text}${isPinned ? ', pinned' : ''}"
>
${tile.text}
</button>
<span class="tile-letter-text">${tile.text}</span>
<button
type="button"
class="tile-pin-btn ${isPinned ? 'is-pinned' : ''}"
data-pin-id="${tile.id}"
aria-label="${isPinned ? 'Unpin tile' : 'Pin tile in place'}"
title="${isPinned ? 'Unpin tile (Click or right-click to pin/unpin)' : 'Pin tile to current spot (keeps it from moving on shuffle)'}"
>
<span class="pin-icon">${isPinned ? '📌' : '📍'}</span>
</button>
</div>
`;
})
.join('')}
@@ -29,12 +41,48 @@ export function createTileGrid({ onTileSelected }) {
`;
const tiles = container.querySelectorAll('.board-tile');
tiles.forEach(tileBtn => {
const id = parseInt(tileBtn.getAttribute('data-id'), 10);
tileBtn.addEventListener('click', () => {
tiles.forEach(tileEl => {
const id = parseInt(tileEl.getAttribute('data-id'), 10);
// Main tile click to add/remove from tray
tileEl.addEventListener('click', e => {
// If clicking pin button, don't trigger tile selection
if (e.target.closest('.tile-pin-btn')) return;
sound.playTileClick();
onTileSelected(id);
});
// Keyboard accessibility
tileEl.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
sound.playTileClick();
onTileSelected(id);
}
});
// Right-click to toggle pin
tileEl.addEventListener('contextmenu', e => {
e.preventDefault();
if (onTogglePin) {
sound.playTileClick();
onTogglePin(id);
}
});
});
// Pin button click listener
const pinBtns = container.querySelectorAll('.tile-pin-btn');
pinBtns.forEach(btn => {
const pinId = parseInt(btn.getAttribute('data-pin-id'), 10);
btn.addEventListener('click', e => {
e.stopPropagation();
e.preventDefault();
if (onTogglePin) {
sound.playTileClick();
onTogglePin(pinId);
}
});
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,22 +4,91 @@ import { solveBoard } from './solver.js';
/**
* Generates a random valid Quartiles puzzle from the target word pool
*/
/**
* Generates a random valid Quartiles puzzle from the target word pool
* Guarantees:
* - 20 strictly unique tiles (no duplicates anywhere on board)
* - All tiles are at least 2 characters (never 1 letter)
* - Anti-repetition: tracks recently used words in localStorage across games
* - Highly varied and random puzzles
*/
export function generateRandomPuzzle(seed) {
const pool = [...TARGET_WORDS_POOL];
// Shuffle pool
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
// Load recently used target words to prevent repetition across games
let recentWords = [];
try {
const rawRecent = localStorage.getItem('quartiles_recent_target_words');
if (rawRecent) {
recentWords = JSON.parse(rawRecent);
if (!Array.isArray(recentWords)) recentWords = [];
}
} catch (e) {
recentWords = [];
}
const recentSet = new Set(recentWords.map(w => w.toLowerCase()));
// Filter pool to prioritize words not played recently
const nonRecentPool = TARGET_WORDS_POOL.filter(w => !recentSet.has(w.word.toLowerCase()));
// If non-recent pool is too small, use full pool
const poolCandidate = nonRecentPool.length >= 25 ? nonRecentPool : [...TARGET_WORDS_POOL];
// Helper to shuffle an array
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Pick 5 words that don't repeat letters/roots too excessively
const selectedWords = pool.slice(0, 5);
const quartets = selectedWords.map(w => w.tiles);
// Attempt to find 5 words with 0 tile collisions (strictly 20 unique tiles)
let selectedWords = null;
const attemptsPools = [shuffle(poolCandidate), shuffle(TARGET_WORDS_POOL), shuffle(TARGET_WORDS_POOL)];
const solved = solveBoard(quartets);
for (const pool of attemptsPools) {
const picked = [];
const usedTiles = new Set();
for (const item of pool) {
if (!item.tiles || item.tiles.length !== 4) continue;
// Ensure no 1-letter tiles
if (!item.tiles.every(t => t && t.length >= 2)) continue;
// Check collision with already used tiles
const hasCollision = item.tiles.some(t => usedTiles.has(t.toLowerCase()));
if (!hasCollision) {
picked.push(item);
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
if (picked.length === 5) {
selectedWords = picked;
break;
}
}
}
if (selectedWords && selectedWords.length === 5) break;
}
// Fallback if extreme constraints somehow failed
if (!selectedWords || selectedWords.length < 5) {
selectedWords = shuffle(TARGET_WORDS_POOL).slice(0, 5);
}
// Save selected words into recent words history (keep up to last 50 words)
try {
const updatedRecent = [
...selectedWords.map(w => w.word.toLowerCase()),
...recentWords.filter(w => !selectedWords.some(sw => sw.word.toLowerCase() === w))
].slice(0, 50);
localStorage.setItem('quartiles_recent_target_words', JSON.stringify(updatedRecent));
} catch (e) {
// Non-fatal if localStorage unavailable
}
const quartets = selectedWords.map(w => w.tiles.map(t => t.toLowerCase().trim()));
const solved = solveBoard(quartets, 30);
const id = 'custom-' + Date.now();
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & MORE` : 'Unlimited Challenge';
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & BEYOND` : 'Unlimited Challenge';
return {
id,
@@ -44,12 +113,17 @@ export function generateRandomPuzzle(seed) {
/**
* Validates and creates a custom puzzle from user-provided quartets
* Expects array of 5 quartets: [[t1,t2,t3,t4], [t5,t6,t7,t8], ...]
* Enforces:
* - Exactly 20 tiles total
* - All 20 tiles must be unique (no duplicate tiles on board)
* - Every tile must be at least 2 letters (no 1-letter tiles)
*/
export function createCustomPuzzle(quartets, customTitle = 'Custom Community Puzzle') {
if (!Array.isArray(quartets) || quartets.length !== 5) {
throw new Error('Puzzle must have exactly 5 quartets (4-tile words)');
}
const allTiles = [];
for (let i = 0; i < quartets.length; i++) {
const q = quartets[i];
if (!Array.isArray(q) || q.length !== 4) {
@@ -57,14 +131,21 @@ export function createCustomPuzzle(quartets, customTitle = 'Custom Community Puz
}
for (let j = 0; j < 4; j++) {
const tile = q[j].toLowerCase().trim();
if (!/^[a-z]{2,4}$/.test(tile)) {
throw new Error(`Tile "${tile}" in quartet #${i + 1} must be 2 to 4 letters`);
if (!/^[a-z]{2,8}$/.test(tile)) {
throw new Error(`Tile "${tile}" in quartet #${i + 1} must be at least 2 letters`);
}
allTiles.push(tile);
}
}
// Enforce no duplicate tiles across the board
const uniqueTileSet = new Set(allTiles);
if (uniqueTileSet.size !== 20) {
throw new Error('All 20 tiles on the board must be unique. Duplicate tiles are not allowed.');
}
const normalizedQuartets = quartets.map(q => q.map(t => t.toLowerCase().trim()));
const solved = solveBoard(normalizedQuartets);
const solved = solveBoard(normalizedQuartets, 30);
return {
id: 'user-' + btoa(normalizedQuartets.map(q => q.join('-')).join('|')).slice(0, 16),

View File

@@ -37,6 +37,13 @@ export function solveBoard(quartets, maxWords = 30) {
const quartilesList = quartets.map(q => q.join('').toLowerCase());
const quartileSet = new Set(quartilesList);
// Target quartiles are always valid, even if missing from the dictionary
quartilesList.forEach((word, qIdx) => {
if (!foundMap.has(word)) {
foundMap.set(word, [qIdx * 4, qIdx * 4 + 1, qIdx * 4 + 2, qIdx * 4 + 3]);
}
});
let words = [];
for (const [word, path] of foundMap.entries()) {

View File

@@ -145,6 +145,9 @@ async function initApp() {
const tileGrid = createTileGrid({
onTileSelected: tileId => {
gameState.onTileClicked(tileId);
},
onTogglePin: tileId => {
gameState.togglePinTile(tileId);
}
});
appContainer.appendChild(tileGrid.element);

View File

@@ -4,8 +4,9 @@ import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
class GameStateManager {
constructor() {
this.currentPuzzle = null;
this.boardTiles = []; // Array of { id, text, isQuartileSolved, isLocked }
this.boardTiles = []; // Array of { id, text, originalText, isQuartileSolved, isLocked, isPinned }
this.activeTileIds = []; // Array of tile IDs in construction tray
this.userPinnedTileIds = new Set(); // Set of tile IDs manually pinned by user
this.foundWords = new Map(); // word -> wordData
this.score = 0;
this.quartilesBonusAwarded = false;
@@ -48,6 +49,7 @@ class GameStateManager {
this.currentPuzzle = puzzle;
this.saveActivePuzzle();
this.activeTileIds = [];
this.userPinnedTileIds = new Set();
this.foundWords = new Map();
this.score = 0;
this.quartilesBonusAwarded = false;
@@ -60,7 +62,8 @@ class GameStateManager {
text: text.toUpperCase(),
originalText: text,
isQuartileSolved: false,
isLocked: false
isLocked: false,
isPinned: false
}));
// Restore saved state from LocalStorage if available
@@ -136,6 +139,8 @@ class GameStateManager {
score: this.score,
bonus: this.quartilesBonusAwarded,
tileOrder: this.boardTiles.map(t => t.id),
pinnedTileIds: Array.from(this.userPinnedTileIds),
lockQuartiles: this.lockQuartiles,
activeTileIds: this.activeTileIds,
isRevealed: this.isRevealed,
hintIndex: this.hintIndex,
@@ -143,6 +148,11 @@ class GameStateManager {
};
try {
localStorage.setItem(saveKey, JSON.stringify(payload));
// Maintain persistent list of all played puzzle IDs
const rawAll = localStorage.getItem('quartiles_all_saved_puzzle_ids');
const allSaved = new Set(rawAll ? JSON.parse(rawAll) : []);
allSaved.add(this.currentPuzzle.id);
localStorage.setItem('quartiles_all_saved_puzzle_ids', JSON.stringify(Array.from(allSaved)));
} catch (err) {
console.warn('LocalStorage save failed:', err);
}
@@ -182,9 +192,16 @@ class GameStateManager {
}
this.isRevealed = !!parsed.isRevealed;
this.hintIndex = parsed.hintIndex || 0;
if (parsed.lockQuartiles !== undefined) {
this.lockQuartiles = parsed.lockQuartiles;
}
// Re-mark solved quartiles
this.updateSolvedQuartileTiles();
// Restore pinned tiles
if (Array.isArray(parsed.pinnedTileIds)) {
this.userPinnedTileIds = new Set(parsed.pinnedTileIds.filter(id => id >= 0 && id < 20));
} else {
this.userPinnedTileIds = new Set();
}
// Restore active tray tiles if any
if (Array.isArray(parsed.activeTileIds)) {
@@ -194,16 +211,18 @@ class GameStateManager {
// Restore tile order if saved
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
const isSequential = parsed.tileOrder.every((id, idx) => id === idx);
if (!isSequential || this.foundWords.size > 0) {
if (!isSequential || this.foundWords.size > 0 || this.userPinnedTileIds.size > 0) {
const tileMap = new Map(this.boardTiles.map(t => [t.id, t]));
const reordered = parsed.tileOrder.map(id => tileMap.get(id)).filter(Boolean);
if (reordered.length === 20) {
this.boardTiles = reordered;
return true;
}
}
}
return this.foundWords.size > 0;
// Re-mark solved quartiles and user-pinned tiles
this.updateSolvedQuartileTiles();
return this.foundWords.size > 0 || this.userPinnedTileIds.size > 0;
}
}
} catch (err) {
@@ -212,7 +231,7 @@ class GameStateManager {
return false;
}
// Check if a tile belongs to an already found Quartile
// Check if a tile belongs to an already found Quartile or is user-pinned
updateSolvedQuartileTiles() {
const solvedQuartileWords = new Set();
for (const [w, data] of this.foundWords.entries()) {
@@ -221,10 +240,11 @@ class GameStateManager {
}
}
// For each target quartet, check if solved
// Initialize all tiles with their pin state
this.boardTiles.forEach(tile => {
tile.isQuartileSolved = false;
tile.isLocked = false;
tile.isPinned = this.userPinnedTileIds.has(tile.id);
tile.isLocked = tile.isPinned;
});
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
@@ -237,7 +257,7 @@ class GameStateManager {
const tile = this.boardTiles.find(t => t.id === i);
if (tile) {
tile.isQuartileSolved = true;
if (this.lockQuartiles) {
if (this.lockQuartiles || tile.isPinned) {
tile.isLocked = true;
}
}
@@ -246,6 +266,25 @@ class GameStateManager {
}
}
// Toggle user pin on an individual tile
togglePinTile(tileId) {
if (this.userPinnedTileIds.has(tileId)) {
this.userPinnedTileIds.delete(tileId);
} else {
this.userPinnedTileIds.add(tileId);
}
this.updateSolvedQuartileTiles();
this.saveProgress();
this.notify();
const tile = this.boardTiles.find(t => t.id === tileId);
const isPinned = this.userPinnedTileIds.has(tileId);
this.notifyToast(
isPinned ? `📌 Pinned tile "${tile?.text || ''}" in place` : `🔓 Unpinned tile "${tile?.text || ''}"`,
'info',
1600
);
}
// Reorganizes the board: locked solved quartiles go to top rows
reorganizeBoard() {
this.updateSolvedQuartileTiles();
@@ -347,30 +386,56 @@ class GameStateManager {
}
}
// Shuffle unlocked tiles
// Shuffle unlocked & unpinned tiles
shuffle() {
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
const unpinnedPositions = [];
const unpinnedTiles = [];
this.boardTiles.forEach((tile, idx) => {
const isFixed = tile.isPinned || (this.lockQuartiles && tile.isLocked);
if (!isFixed) {
unpinnedPositions.push(idx);
unpinnedTiles.push(tile);
}
});
// Fisher-Yates shuffle
for (let i = unlockedTiles.length - 1; i > 0; i--) {
for (let i = unpinnedTiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[unlockedTiles[i], unlockedTiles[j]] = [unlockedTiles[j], unlockedTiles[i]];
[unpinnedTiles[i], unpinnedTiles[j]] = [unpinnedTiles[j], unpinnedTiles[i]];
}
this.boardTiles = [...lockedTiles, ...unlockedTiles];
const newBoard = [...this.boardTiles];
unpinnedPositions.forEach((pos, idx) => {
newBoard[pos] = unpinnedTiles[idx];
});
this.boardTiles = newBoard;
this.saveProgress();
this.notify();
}
// Sort unlocked tiles alphabetically
// Sort unlocked & unpinned tiles alphabetically
sortAlphabetical() {
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
const unpinnedPositions = [];
const unpinnedTiles = [];
unlockedTiles.sort((a, b) => a.text.localeCompare(b.text));
this.boardTiles.forEach((tile, idx) => {
const isFixed = tile.isPinned || (this.lockQuartiles && tile.isLocked);
if (!isFixed) {
unpinnedPositions.push(idx);
unpinnedTiles.push(tile);
}
});
this.boardTiles = [...lockedTiles, ...unlockedTiles];
unpinnedTiles.sort((a, b) => a.text.localeCompare(b.text));
const newBoard = [...this.boardTiles];
unpinnedPositions.forEach((pos, idx) => {
newBoard[pos] = unpinnedTiles[idx];
});
this.boardTiles = newBoard;
this.saveProgress();
this.notify();
}
@@ -385,13 +450,16 @@ class GameStateManager {
const constructedWord = this.activeTileIds
.map(id => {
const tile = this.boardTiles.find(t => t.id === id);
return tile ? tile.text.toLowerCase() : '';
return tile ? tile.text.toLowerCase().trim() : '';
})
.join('');
// Check if already found
if (this.foundWords.has(constructedWord)) {
this.notifyToast(`"${constructedWord.toUpperCase()}" already found!`, 'warning');
this.activeTileIds = [];
this.saveProgress();
this.notify();
return { success: false, reason: 'duplicate', word: constructedWord };
}

View File

@@ -338,6 +338,46 @@
opacity: 0.8;
}
/* User-pinned tile state */
.board-tile.pinned {
border-color: #f59e0b !important;
box-shadow: 0 0 0 1.5px rgba(245, 158, 11, 0.45), var(--tile-shadow) !important;
}
.tile-pin-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
border-radius: 4px;
opacity: 0.22;
transition: opacity var(--transition-fast), transform var(--transition-fast);
z-index: 5;
}
.board-tile:hover .tile-pin-btn,
.tile-pin-btn.is-pinned {
opacity: 0.95;
}
.tile-pin-btn .pin-icon {
font-size: 0.7rem;
line-height: 1;
}
.tile-pin-btn.is-pinned {
transform: scale(1.12);
filter: drop-shadow(0 1px 2px rgba(245, 158, 11, 0.4));
}
/* Board tile shuffle animation */
.board-tile.flipping {
animation: tileFlip 0.4s var(--ease-smooth);