init
This commit is contained in:
485
src/state/game-state.js
Normal file
485
src/state/game-state.js
Normal file
@@ -0,0 +1,485 @@
|
||||
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
|
||||
this.restoreProgress();
|
||||
|
||||
// Organize board tiles according to lock state
|
||||
this.reorganizeBoard();
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// 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;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to restore progress:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
Reference in New Issue
Block a user