521 lines
15 KiB
JavaScript
521 lines
15 KiB
JavaScript
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
|
import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
|
|
|
|
class GameStateManager {
|
|
constructor() {
|
|
this.currentPuzzle = null;
|
|
this.boardTiles = []; // Array of { id, text, isQuartileSolved, isLocked }
|
|
this.activeTileIds = []; // Array of tile IDs in construction tray
|
|
this.foundWords = new Map(); // word -> wordData
|
|
this.score = 0;
|
|
this.quartilesBonusAwarded = false;
|
|
this.lockQuartiles = localStorage.getItem('quartiles_lock_setting') !== 'false';
|
|
this.listeners = new Set();
|
|
this.toastListeners = new Set();
|
|
this.isRevealed = false;
|
|
this.hintIndex = 0;
|
|
}
|
|
|
|
subscribe(listener) {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
notify() {
|
|
for (const listener of this.listeners) {
|
|
listener(this);
|
|
}
|
|
}
|
|
|
|
subscribeToast(listener) {
|
|
this.toastListeners.add(listener);
|
|
return () => this.toastListeners.delete(listener);
|
|
}
|
|
|
|
notifyToast(message, type = 'info', duration = 2500) {
|
|
for (const listener of this.toastListeners) {
|
|
listener({ message, type, duration });
|
|
}
|
|
}
|
|
|
|
// Load a puzzle (by object or find by date/id)
|
|
loadPuzzle(puzzleOrId) {
|
|
let puzzle = puzzleOrId;
|
|
if (typeof puzzleOrId === 'string') {
|
|
puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0];
|
|
}
|
|
|
|
this.currentPuzzle = puzzle;
|
|
this.activeTileIds = [];
|
|
this.foundWords = new Map();
|
|
this.score = 0;
|
|
this.quartilesBonusAwarded = false;
|
|
this.isRevealed = false;
|
|
this.hintIndex = 0;
|
|
|
|
// Initialize board tiles from the 20 puzzle tiles
|
|
this.boardTiles = puzzle.tiles.map((text, id) => ({
|
|
id,
|
|
text: text.toUpperCase(),
|
|
originalText: text,
|
|
isQuartileSolved: false,
|
|
isLocked: false
|
|
}));
|
|
|
|
// Restore saved state from LocalStorage if available
|
|
const hasSavedTileOrder = this.restoreProgress();
|
|
|
|
// If fresh puzzle without saved order, scramble the board so target words are not formed!
|
|
if (!hasSavedTileOrder) {
|
|
this.scrambleBoard(puzzle.id || String(Date.now()));
|
|
}
|
|
|
|
// Organize board tiles according to lock state
|
|
this.reorganizeBoard();
|
|
|
|
this.notify();
|
|
}
|
|
|
|
// Seeded Fisher-Yates scramble for initial board dealing
|
|
scrambleBoard(seed = '') {
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < seed.length; i++) {
|
|
h ^= seed.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
let s = (h >>> 0) || 123456789;
|
|
const nextRandom = () => {
|
|
s = (Math.imul(1664525, s) + 1013904223) >>> 0;
|
|
return s / 4294967296;
|
|
};
|
|
|
|
const shuffled = [...this.boardTiles];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(nextRandom() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
this.boardTiles = shuffled;
|
|
this.saveProgress();
|
|
}
|
|
|
|
// Get current date string in YYYY-MM-DD
|
|
getTodayDateString() {
|
|
const today = new Date();
|
|
const y = today.getFullYear();
|
|
const m = String(today.getMonth() + 1).padStart(2, '0');
|
|
const d = String(today.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
// Save current puzzle progress to LocalStorage
|
|
saveProgress() {
|
|
if (!this.currentPuzzle) return;
|
|
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
|
const payload = {
|
|
foundWords: Array.from(this.foundWords.entries()),
|
|
score: this.score,
|
|
bonus: this.quartilesBonusAwarded,
|
|
tileOrder: this.boardTiles.map(t => t.id)
|
|
};
|
|
try {
|
|
localStorage.setItem(saveKey, JSON.stringify(payload));
|
|
} catch (err) {
|
|
console.warn('LocalStorage save failed:', err);
|
|
}
|
|
}
|
|
|
|
// Restore puzzle progress
|
|
restoreProgress() {
|
|
if (!this.currentPuzzle) return false;
|
|
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
|
try {
|
|
const saved = localStorage.getItem(saveKey);
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
if (parsed && Array.isArray(parsed.foundWords)) {
|
|
this.foundWords = new Map(parsed.foundWords);
|
|
this.score = parsed.score || 0;
|
|
this.quartilesBonusAwarded = !!parsed.bonus;
|
|
|
|
// Re-mark solved quartiles
|
|
this.updateSolvedQuartileTiles();
|
|
|
|
// Restore tile order if saved
|
|
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
|
|
// Check if saved order was the trivial unscrambled [0..19] order
|
|
const isSequential = parsed.tileOrder.every((id, idx) => id === idx);
|
|
if (isSequential && this.foundWords.size === 0) {
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Failed to restore progress:', err);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Check if a tile belongs to an already found Quartile
|
|
updateSolvedQuartileTiles() {
|
|
const solvedQuartileWords = new Set();
|
|
for (const [w, data] of this.foundWords.entries()) {
|
|
if (data.isTargetQuartile) {
|
|
solvedQuartileWords.add(w.toLowerCase());
|
|
}
|
|
}
|
|
|
|
// For each target quartet, check if solved
|
|
this.boardTiles.forEach(tile => {
|
|
tile.isQuartileSolved = false;
|
|
tile.isLocked = false;
|
|
});
|
|
|
|
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
|
const quartet = this.currentPuzzle.quartets[qIdx];
|
|
const word = quartet.join('').toLowerCase();
|
|
if (solvedQuartileWords.has(word)) {
|
|
// Mark tiles belonging to this quartet
|
|
const startTileIndex = qIdx * 4;
|
|
for (let i = startTileIndex; i < startTileIndex + 4; i++) {
|
|
const tile = this.boardTiles.find(t => t.id === i);
|
|
if (tile) {
|
|
tile.isQuartileSolved = true;
|
|
if (this.lockQuartiles) {
|
|
tile.isLocked = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reorganizes the board: locked solved quartiles go to top rows
|
|
reorganizeBoard() {
|
|
this.updateSolvedQuartileTiles();
|
|
|
|
if (this.lockQuartiles) {
|
|
// Group locked solved quartiles first in neat rows
|
|
const lockedTiles = [];
|
|
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
|
const quartet = this.currentPuzzle.quartets[qIdx];
|
|
const word = quartet.join('').toLowerCase();
|
|
if (this.foundWords.has(word)) {
|
|
const start = qIdx * 4;
|
|
for (let i = start; i < start + 4; i++) {
|
|
const t = this.boardTiles.find(tile => tile.id === i);
|
|
if (t) lockedTiles.push(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
const lockedSet = new Set(lockedTiles.map(t => t.id));
|
|
const unlockedTiles = this.boardTiles.filter(t => !lockedSet.has(t.id));
|
|
|
|
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
|
}
|
|
}
|
|
|
|
// Toggle lock quartiles setting
|
|
toggleLockQuartiles() {
|
|
this.lockQuartiles = !this.lockQuartiles;
|
|
localStorage.setItem('quartiles_lock_setting', this.lockQuartiles ? 'true' : 'false');
|
|
this.reorganizeBoard();
|
|
this.notify();
|
|
this.notifyToast(
|
|
this.lockQuartiles ? 'Quartiles locked at top' : 'Quartiles unlocked for free shuffle',
|
|
'info'
|
|
);
|
|
}
|
|
|
|
// Tile clicked on board: add to tray or remove if already in tray
|
|
onTileClicked(tileId) {
|
|
const existingIndex = this.activeTileIds.indexOf(tileId);
|
|
if (existingIndex !== -1) {
|
|
// Already selected, remove it
|
|
this.removeActiveTile(existingIndex);
|
|
return;
|
|
}
|
|
|
|
if (this.activeTileIds.length >= 4) {
|
|
this.notifyToast('Maximum 4 tiles per word', 'warning');
|
|
return;
|
|
}
|
|
|
|
this.activeTileIds.push(tileId);
|
|
this.notify();
|
|
}
|
|
|
|
// Remove tile from active tray by index
|
|
removeActiveTile(index) {
|
|
if (index >= 0 && index < this.activeTileIds.length) {
|
|
this.activeTileIds.splice(index, 1);
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
// Reorder tiles in active tray (e.g. from drag & drop)
|
|
reorderActiveTiles(fromIndex, toIndex) {
|
|
if (
|
|
fromIndex >= 0 &&
|
|
fromIndex < this.activeTileIds.length &&
|
|
toIndex >= 0 &&
|
|
toIndex < this.activeTileIds.length &&
|
|
fromIndex !== toIndex
|
|
) {
|
|
const [item] = this.activeTileIds.splice(fromIndex, 1);
|
|
this.activeTileIds.splice(toIndex, 0, item);
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
// Backspace: removes last tile in tray
|
|
backspace() {
|
|
if (this.activeTileIds.length > 0) {
|
|
this.activeTileIds.pop();
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
// Clear all tiles in tray
|
|
clearActive() {
|
|
if (this.activeTileIds.length > 0) {
|
|
this.activeTileIds = [];
|
|
this.notify();
|
|
}
|
|
}
|
|
|
|
// Shuffle unlocked tiles
|
|
shuffle() {
|
|
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
|
|
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
|
|
|
|
// Fisher-Yates shuffle
|
|
for (let i = unlockedTiles.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[unlockedTiles[i], unlockedTiles[j]] = [unlockedTiles[j], unlockedTiles[i]];
|
|
}
|
|
|
|
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
|
this.saveProgress();
|
|
this.notify();
|
|
}
|
|
|
|
// Sort unlocked 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];
|
|
|
|
unlockedTiles.sort((a, b) => a.text.localeCompare(b.text));
|
|
|
|
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
|
this.saveProgress();
|
|
this.notify();
|
|
}
|
|
|
|
// Submit current word in active construction tray
|
|
submitWord() {
|
|
if (this.activeTileIds.length === 0) {
|
|
this.notifyToast('Tap tiles to form a word', 'info');
|
|
return { success: false, reason: 'empty' };
|
|
}
|
|
|
|
const constructedWord = this.activeTileIds
|
|
.map(id => {
|
|
const tile = this.boardTiles.find(t => t.id === id);
|
|
return tile ? tile.text.toLowerCase() : '';
|
|
})
|
|
.join('');
|
|
|
|
// Check if already found
|
|
if (this.foundWords.has(constructedWord)) {
|
|
this.notifyToast(`"${constructedWord.toUpperCase()}" already found!`, 'warning');
|
|
this.activeTileIds = [];
|
|
this.notify();
|
|
return { success: false, reason: 'duplicate' };
|
|
}
|
|
|
|
// Look up in current puzzle's valid words list
|
|
const match = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord);
|
|
|
|
if (!match) {
|
|
this.notifyToast(`"${constructedWord.toUpperCase()}" is not in the word list`, 'error');
|
|
return { success: false, reason: 'invalid' };
|
|
}
|
|
|
|
// Valid word!
|
|
const tileSegments = this.activeTileIds.map(id => {
|
|
const tile = this.boardTiles.find(t => t.id === id);
|
|
return tile ? tile.text : '';
|
|
});
|
|
const tileCount = this.activeTileIds.length;
|
|
const isTargetQuartile = this.currentPuzzle.targetQuartiles.some(t => t.toLowerCase() === constructedWord);
|
|
const isQuartile = tileCount === 4;
|
|
|
|
const wordEntry = {
|
|
word: constructedWord,
|
|
points: match.points,
|
|
tileCount,
|
|
tileSegments,
|
|
isTargetQuartile,
|
|
isQuartile,
|
|
foundAt: Date.now()
|
|
};
|
|
|
|
this.foundWords.set(constructedWord, wordEntry);
|
|
this.score += match.points;
|
|
|
|
// Check if all 5 target quartiles are solved
|
|
let quartilesCount = 0;
|
|
for (const target of this.currentPuzzle.targetQuartiles) {
|
|
if (this.foundWords.has(target.toLowerCase())) {
|
|
quartilesCount++;
|
|
}
|
|
}
|
|
|
|
let bonusUnlocked = false;
|
|
if (quartilesCount === 5 && !this.quartilesBonusAwarded) {
|
|
this.quartilesBonusAwarded = true;
|
|
this.score += QUARTILE_COMPLETION_BONUS;
|
|
bonusUnlocked = true;
|
|
}
|
|
|
|
// Clear active tray
|
|
this.activeTileIds = [];
|
|
|
|
// Reorganize board if quartiles locked
|
|
this.reorganizeBoard();
|
|
|
|
// Save progress and update player stats
|
|
this.saveProgress();
|
|
this.updateStats();
|
|
|
|
this.notify();
|
|
|
|
return {
|
|
success: true,
|
|
word: constructedWord,
|
|
points: match.points,
|
|
isQuartile,
|
|
isTargetQuartile,
|
|
quartilesFound: quartilesCount,
|
|
bonusUnlocked
|
|
};
|
|
}
|
|
|
|
// Get target quartiles solved count
|
|
getTargetQuartilesSolvedCount() {
|
|
if (!this.currentPuzzle) return 0;
|
|
let count = 0;
|
|
for (const q of this.currentPuzzle.targetQuartiles) {
|
|
if (this.foundWords.has(q.toLowerCase())) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// Get rank data
|
|
getRank() {
|
|
return getRankInfo(
|
|
this.score,
|
|
this.foundWords.size,
|
|
this.currentPuzzle ? this.currentPuzzle.wordCount : 0
|
|
);
|
|
}
|
|
|
|
// Progressive hint system
|
|
getHint() {
|
|
if (!this.currentPuzzle) return null;
|
|
|
|
// 1. Prioritize unfound target Quartiles
|
|
const unfoundQuartiles = this.currentPuzzle.quartileDetails.filter(
|
|
qd => !this.foundWords.has(qd.word.toLowerCase())
|
|
);
|
|
|
|
if (unfoundQuartiles.length > 0) {
|
|
const target = unfoundQuartiles[this.hintIndex % unfoundQuartiles.length];
|
|
this.hintIndex++;
|
|
return {
|
|
type: 'quartile',
|
|
word: target.word,
|
|
firstTile: target.tiles[0].toUpperCase(),
|
|
clue: target.clue || 'A 4-part Quartile word',
|
|
remainingQuartiles: unfoundQuartiles.length
|
|
};
|
|
}
|
|
|
|
// 2. Unfound other words
|
|
const unfoundWords = this.currentPuzzle.words.filter(
|
|
w => !this.foundWords.has(w.word.toLowerCase())
|
|
);
|
|
|
|
if (unfoundWords.length > 0) {
|
|
const sample = unfoundWords[Math.floor(Math.random() * unfoundWords.length)];
|
|
return {
|
|
type: 'word',
|
|
tileCount: sample.tileCount,
|
|
firstTile: sample.tileSegments[0].toUpperCase(),
|
|
points: sample.points,
|
|
remainingWords: unfoundWords.length
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Reveal all words (when expert reached or explicitly requested)
|
|
revealAllWords() {
|
|
this.isRevealed = true;
|
|
this.notify();
|
|
}
|
|
|
|
// Update persistent stats in LocalStorage
|
|
updateStats() {
|
|
try {
|
|
const statsKey = 'quartiles_player_stats';
|
|
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
|
|
|
stats.totalWordsFound = (stats.totalWordsFound || 0) + 1;
|
|
const rank = this.getRank();
|
|
if (rank.isExpert) {
|
|
stats.expertPuzzles = stats.expertPuzzles || {};
|
|
stats.expertPuzzles[this.currentPuzzle.id] = true;
|
|
}
|
|
if (rank.isPerfect) {
|
|
stats.perfectPuzzles = stats.perfectPuzzles || {};
|
|
stats.perfectPuzzles[this.currentPuzzle.id] = true;
|
|
}
|
|
|
|
localStorage.setItem(statsKey, JSON.stringify(stats));
|
|
} catch (err) {
|
|
console.warn('Stats update error:', err);
|
|
}
|
|
}
|
|
|
|
// Get player stats
|
|
getStats() {
|
|
try {
|
|
const statsKey = 'quartiles_player_stats';
|
|
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
|
const expertCount = Object.keys(stats.expertPuzzles || {}).length;
|
|
const perfectCount = Object.keys(stats.perfectPuzzles || {}).length;
|
|
|
|
return {
|
|
totalWordsFound: stats.totalWordsFound || 0,
|
|
expertCount,
|
|
perfectCount
|
|
};
|
|
} catch (err) {
|
|
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
|
|
}
|
|
}
|
|
}
|
|
|
|
export const gameState = new GameStateManager();
|