more words

This commit is contained in:
2026-10-24 13:26:24 -04:00
parent 9e44b07e98
commit 076e70992d
7 changed files with 446 additions and 625 deletions

View File

@@ -1,6 +1,6 @@
import { sound } from '../engine/audio.js';
export function createTileGrid({ onTileSelected, onTogglePin }) {
export function createTileGrid({ onTileSelected }) {
const container = document.createElement('div');
container.className = 'board-container';
@@ -13,27 +13,17 @@ export function createTileGrid({ onTileSelected, onTogglePin }) {
.map(tile => {
const isSelected = activeSet.has(tile.id);
const isLocked = tile.isLocked;
const isPinned = !!tile.isPinned;
return `
<div
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''} ${isPinned ? 'pinned' : ''}"
<button
type="button"
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''}"
data-id="${tile.id}"
role="button"
tabindex="0"
aria-label="Tile ${tile.text}${isPinned ? ', pinned' : ''}"
aria-label="Tile ${tile.text}"
aria-pressed="${isSelected}"
>
<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>
${tile.text}
</button>
`;
})
.join('')}
@@ -41,48 +31,12 @@ export function createTileGrid({ onTileSelected, onTogglePin }) {
`;
const tiles = container.querySelectorAll('.board-tile');
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;
tiles.forEach(tileBtn => {
const id = parseInt(tileBtn.getAttribute('data-id'), 10);
tileBtn.addEventListener('click', () => {
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

@@ -18,7 +18,9 @@ export function solveBoard(quartets, maxWords = 30) {
}
if (prefix.length >= 2 && DICTIONARY_SET.has(prefix)) {
if (!foundMap.has(prefix)) {
const existing = foundMap.get(prefix);
// Keep first path found, but a single tile that is a word always wins
if (!existing || (usedIndices.length === 1 && existing.length > 1)) {
foundMap.set(prefix, [...usedIndices]);
}
}
@@ -75,9 +77,12 @@ export function solveBoard(quartets, maxWords = 30) {
return a.word.localeCompare(b.word);
});
// Cap at maxWords (default: 30)
// Cap at maxWords (default: 30). Target quartiles and single-tile words
// (tiles that are words on their own) always keep their spot.
if (maxWords && words.length > maxWords) {
words = words.slice(0, maxWords);
const reserved = words.filter(w => w.isTargetQuartile || w.tileCount === 1);
const rest = words.filter(w => !w.isTargetQuartile && w.tileCount !== 1);
words = [...reserved, ...rest.slice(0, Math.max(0, maxWords - reserved.length))];
}
// Final display sort: Quartiles first, then by points descending, then alphabetically

View File

@@ -145,9 +145,6 @@ async function initApp() {
const tileGrid = createTileGrid({
onTileSelected: tileId => {
gameState.onTileClicked(tileId);
},
onTogglePin: tileId => {
gameState.togglePinTile(tileId);
}
});
appContainer.appendChild(tileGrid.element);
@@ -348,6 +345,17 @@ async function initApp() {
if (rawCustom) {
const parsedCustom = JSON.parse(rawCustom);
if (parsedCustom && parsedCustom.id) {
try {
const { solveBoard } = await import('./engine/solver.js');
if (Array.isArray(parsedCustom.quartets) && parsedCustom.quartets.length === 5) {
const solved = solveBoard(parsedCustom.quartets, 30);
parsedCustom.words = solved.words;
parsedCustom.wordCount = solved.wordCount;
parsedCustom.maxPossibleScore = solved.maxPossibleScore;
}
} catch (e) {
console.warn('Could not re-solve saved puzzle, using stored word list:', e);
}
gameState.loadPuzzle(parsedCustom);
return;
}

View File

@@ -4,9 +4,8 @@ import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
class GameStateManager {
constructor() {
this.currentPuzzle = null;
this.boardTiles = []; // Array of { id, text, originalText, isQuartileSolved, isLocked, isPinned }
this.boardTiles = []; // Array of { id, text, originalText, isQuartileSolved, isLocked }
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;
@@ -49,7 +48,6 @@ class GameStateManager {
this.currentPuzzle = puzzle;
this.saveActivePuzzle();
this.activeTileIds = [];
this.userPinnedTileIds = new Set();
this.foundWords = new Map();
this.score = 0;
this.quartilesBonusAwarded = false;
@@ -62,8 +60,7 @@ class GameStateManager {
text: text.toUpperCase(),
originalText: text,
isQuartileSolved: false,
isLocked: false,
isPinned: false
isLocked: false
}));
// Restore saved state from LocalStorage if available
@@ -139,7 +136,6 @@ 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,
@@ -196,13 +192,6 @@ class GameStateManager {
this.lockQuartiles = parsed.lockQuartiles;
}
// 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)) {
this.activeTileIds = parsed.activeTileIds.filter(id => id >= 0 && id < 20);
@@ -211,18 +200,20 @@ 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 || this.userPinnedTileIds.size > 0) {
if (!isSequential || this.foundWords.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;
this.updateSolvedQuartileTiles();
return true;
}
}
}
// Re-mark solved quartiles and user-pinned tiles
// Re-mark solved quartiles
this.updateSolvedQuartileTiles();
return this.foundWords.size > 0 || this.userPinnedTileIds.size > 0;
return this.foundWords.size > 0;
}
}
} catch (err) {
@@ -231,7 +222,7 @@ class GameStateManager {
return false;
}
// Check if a tile belongs to an already found Quartile or is user-pinned
// Check if a tile belongs to an already found Quartile
updateSolvedQuartileTiles() {
const solvedQuartileWords = new Set();
for (const [w, data] of this.foundWords.entries()) {
@@ -240,11 +231,9 @@ class GameStateManager {
}
}
// Initialize all tiles with their pin state
this.boardTiles.forEach(tile => {
tile.isQuartileSolved = false;
tile.isPinned = this.userPinnedTileIds.has(tile.id);
tile.isLocked = tile.isPinned;
tile.isLocked = false;
});
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
@@ -257,7 +246,7 @@ class GameStateManager {
const tile = this.boardTiles.find(t => t.id === i);
if (tile) {
tile.isQuartileSolved = true;
if (this.lockQuartiles || tile.isPinned) {
if (this.lockQuartiles) {
tile.isLocked = true;
}
}
@@ -266,25 +255,6 @@ 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();
@@ -392,7 +362,7 @@ class GameStateManager {
const unpinnedTiles = [];
this.boardTiles.forEach((tile, idx) => {
const isFixed = tile.isPinned || (this.lockQuartiles && tile.isLocked);
const isFixed = this.lockQuartiles && tile.isLocked;
if (!isFixed) {
unpinnedPositions.push(idx);
unpinnedTiles.push(tile);
@@ -421,7 +391,7 @@ class GameStateManager {
const unpinnedTiles = [];
this.boardTiles.forEach((tile, idx) => {
const isFixed = tile.isPinned || (this.lockQuartiles && tile.isLocked);
const isFixed = this.lockQuartiles && tile.isLocked;
if (!isFixed) {
unpinnedPositions.push(idx);
unpinnedTiles.push(tile);

View File

@@ -329,55 +329,6 @@
border-color: var(--accent-quartile);
}
.board-tile.locked::before {
content: '🔒';
position: absolute;
top: 4px;
left: 6px;
font-size: 0.65rem;
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);