362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Moon, RotateCcw } from 'lucide-react';
|
|
import { AccessWarning } from './components/AccessWarning';
|
|
import { Board } from './components/Board';
|
|
import { GameOverPanel } from './components/GameOverPanel';
|
|
import { Keyboard } from './components/Keyboard';
|
|
import { ModeSelector } from './components/ModeSelector';
|
|
import { SettingsPanel } from './components/SettingsPanel';
|
|
import { StatsPanel } from './components/StatsPanel';
|
|
import { DEFAULT_SETTINGS, MODE_LABELS, getMaxGuesses } from './lib/modes';
|
|
import { useLocalStorage } from './lib/storage';
|
|
import {
|
|
getDateKey,
|
|
getInfiniteLength,
|
|
getSuperEvilLength,
|
|
isValidGuess,
|
|
mergeKeyboardState,
|
|
normalizeGuess,
|
|
pickDailyWord,
|
|
pickRandomWord,
|
|
} from './lib/wordUtils';
|
|
import type { GameMode, GameStatus, SavedProgress, Settings, Stats } from './types';
|
|
|
|
type Session = {
|
|
mode: GameMode;
|
|
target: string;
|
|
guesses: string[];
|
|
currentGuess: string;
|
|
status: GameStatus;
|
|
level: number;
|
|
isPractice: boolean;
|
|
message: string;
|
|
};
|
|
|
|
const initialStats: Stats = {
|
|
gamesPlayed: 0,
|
|
wins: 0,
|
|
losses: 0,
|
|
currentStreak: 0,
|
|
bestStreak: 0,
|
|
infiniteLevel: 1,
|
|
infiniteWins: 0,
|
|
infiniteLosses: 0,
|
|
};
|
|
|
|
const getModeLength = (mode: GameMode, settings: Settings, level: number, practice: boolean) => {
|
|
if (mode === 'custom') return settings.wordLength;
|
|
if (mode === 'infinite') return getInfiniteLength(level);
|
|
if (mode === 'super-evil') return practice ? getSuperEvilLength() : 10 + (level % 6);
|
|
return 5;
|
|
};
|
|
|
|
const createSession = (
|
|
mode: GameMode,
|
|
settings: Settings,
|
|
level = 1,
|
|
isPractice = false,
|
|
): Session => {
|
|
const wordLength = getModeLength(mode, settings, level, isPractice);
|
|
const target =
|
|
mode === 'daily' && !isPractice
|
|
? pickDailyWord(wordLength, 'daily')
|
|
: mode === 'evil-daily' && !isPractice
|
|
? pickDailyWord(wordLength, 'evil')
|
|
: pickRandomWord(wordLength);
|
|
|
|
return {
|
|
mode,
|
|
target,
|
|
guesses: [],
|
|
currentGuess: '',
|
|
status: 'playing',
|
|
level,
|
|
isPractice,
|
|
message: mode === 'super-evil' ? 'Super Evil gives fewer guesses and very long words.' : '',
|
|
};
|
|
};
|
|
|
|
const restoreSession = (saved: SavedProgress): Session => ({
|
|
mode: saved.mode,
|
|
target: saved.target,
|
|
guesses: saved.guesses,
|
|
currentGuess: '',
|
|
status: saved.status,
|
|
level: saved.level,
|
|
isPractice: saved.isPractice,
|
|
message: '',
|
|
});
|
|
|
|
const canRestore = (saved: SavedProgress | null) => {
|
|
if (!saved) return false;
|
|
if ((saved.mode === 'daily' || saved.mode === 'evil-daily') && !saved.isPractice) {
|
|
return saved.dateKey === getDateKey();
|
|
}
|
|
return true;
|
|
};
|
|
|
|
export default function App() {
|
|
const [settings, setSettings] = useLocalStorage<Settings>('evil-wordle-settings', DEFAULT_SETTINGS);
|
|
const [stats, setStats] = useLocalStorage<Stats>('evil-wordle-stats', initialStats);
|
|
const [savedProgress, setSavedProgress] = useLocalStorage<SavedProgress | null>('evil-wordle-progress', null);
|
|
const [shake, setShake] = useState(false);
|
|
const [session, setSession] = useState<Session>(() =>
|
|
canRestore(savedProgress) ? restoreSession(savedProgress as SavedProgress) : createSession('daily', settings),
|
|
);
|
|
|
|
const wordLength = session.target.length;
|
|
const maxGuesses = useMemo(
|
|
() => getMaxGuesses(session.mode, wordLength, settings.difficulty),
|
|
[session.mode, settings.difficulty, wordLength],
|
|
);
|
|
const evilMemoryPhase = session.mode === 'evil-daily' && session.guesses.length >= 2;
|
|
const keyboardStates = useMemo(
|
|
() => (evilMemoryPhase ? new Map() : mergeKeyboardState(session.guesses, session.target)),
|
|
[evilMemoryPhase, session.guesses, session.target],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setSavedProgress({
|
|
dateKey: getDateKey(),
|
|
mode: session.mode,
|
|
target: session.target,
|
|
guesses: session.guesses,
|
|
status: session.status,
|
|
level: session.level,
|
|
isPractice: session.isPractice,
|
|
});
|
|
}, [session, setSavedProgress]);
|
|
|
|
useEffect(() => {
|
|
if (!shake) return undefined;
|
|
const timer = window.setTimeout(() => setShake(false), 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, [shake]);
|
|
|
|
const startNewSession = useCallback(
|
|
(mode: GameMode, level = mode === 'infinite' ? stats.infiniteLevel : 1, practice = false) => {
|
|
setSession(createSession(mode, settings, level, practice));
|
|
},
|
|
[settings, stats.infiniteLevel],
|
|
);
|
|
|
|
const updateStatsForResult = useCallback(
|
|
(status: GameStatus, mode: GameMode, level: number) => {
|
|
if (status === 'playing') return;
|
|
setStats((current) => {
|
|
const won = status === 'won';
|
|
const nextStreak = won ? current.currentStreak + 1 : 0;
|
|
return {
|
|
...current,
|
|
gamesPlayed: current.gamesPlayed + 1,
|
|
wins: current.wins + (won ? 1 : 0),
|
|
losses: current.losses + (won ? 0 : 1),
|
|
currentStreak: nextStreak,
|
|
bestStreak: Math.max(current.bestStreak, nextStreak),
|
|
infiniteLevel:
|
|
mode === 'infinite' && won ? Math.max(current.infiniteLevel, level + 1) : current.infiniteLevel,
|
|
infiniteWins: current.infiniteWins + (mode === 'infinite' && won ? 1 : 0),
|
|
infiniteLosses: current.infiniteLosses + (mode === 'infinite' && !won ? 1 : 0),
|
|
};
|
|
});
|
|
},
|
|
[setStats],
|
|
);
|
|
|
|
const submitGuess = useCallback(() => {
|
|
if (session.status !== 'playing') return;
|
|
const guess = normalizeGuess(session.currentGuess);
|
|
|
|
if (guess.length !== session.target.length) {
|
|
setShake(true);
|
|
setSession((current) => ({ ...current, message: `Enter ${session.target.length} letters.` }));
|
|
return;
|
|
}
|
|
|
|
if (!isValidGuess(guess, session.target.length)) {
|
|
setShake(true);
|
|
setSession((current) => ({ ...current, message: 'That word is not in the word list.' }));
|
|
return;
|
|
}
|
|
|
|
const guesses = [...session.guesses, guess];
|
|
const status: GameStatus = guess === session.target ? 'won' : guesses.length >= maxGuesses ? 'lost' : 'playing';
|
|
updateStatsForResult(status, session.mode, session.level);
|
|
|
|
setSession({
|
|
...session,
|
|
guesses,
|
|
currentGuess: '',
|
|
status,
|
|
message:
|
|
status === 'won'
|
|
? 'Result recorded.'
|
|
: status === 'lost'
|
|
? 'No guesses remain.'
|
|
: session.mode === 'evil-daily' && guesses.length === 2
|
|
? 'Memory phase begins now.'
|
|
: '',
|
|
});
|
|
}, [maxGuesses, session, updateStatsForResult]);
|
|
|
|
const handleKey = useCallback(
|
|
(key: string) => {
|
|
if (session.status !== 'playing') return;
|
|
|
|
if (key === 'Enter') {
|
|
submitGuess();
|
|
return;
|
|
}
|
|
|
|
if (key === 'Backspace' || key === 'Delete') {
|
|
setSession((current) => ({ ...current, currentGuess: current.currentGuess.slice(0, -1), message: '' }));
|
|
return;
|
|
}
|
|
|
|
const clean = normalizeGuess(key);
|
|
if (clean.length !== 1) return;
|
|
|
|
setSession((current) => {
|
|
if (current.currentGuess.length >= current.target.length) return current;
|
|
return { ...current, currentGuess: current.currentGuess + clean, message: '' };
|
|
});
|
|
},
|
|
[session.status, submitGuess],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
if (event.key === 'Enter' || event.key === 'Backspace' || /^[a-zA-Z]$/.test(event.key)) {
|
|
event.preventDefault();
|
|
handleKey(event.key);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, [handleKey]);
|
|
|
|
const changeMode = (mode: GameMode) => startNewSession(mode);
|
|
|
|
const changeSettings = (nextSettings: Settings) => {
|
|
setSettings(nextSettings);
|
|
if (session.mode === 'custom') {
|
|
setSession(createSession('custom', nextSettings, 1, false));
|
|
}
|
|
};
|
|
|
|
const restart = () => {
|
|
if (session.mode === 'daily' && !session.isPractice) {
|
|
startNewSession('daily', 1, true);
|
|
return;
|
|
}
|
|
|
|
const nextLevel = session.mode === 'infinite' && session.status === 'won' ? session.level + 1 : session.level;
|
|
startNewSession(session.mode, nextLevel, session.isPractice);
|
|
};
|
|
|
|
const startPractice = () => startNewSession('daily', 1, true);
|
|
|
|
const modeNote =
|
|
session.mode === 'infinite'
|
|
? `Level ${session.level}. Current word length: ${wordLength}.`
|
|
: session.mode === 'custom'
|
|
? `${wordLength} letter custom puzzle.`
|
|
: session.mode === 'super-evil'
|
|
? `${wordLength} letters, ${maxGuesses} guesses.`
|
|
: session.mode === 'evil-daily'
|
|
? evilMemoryPhase
|
|
? 'Keyboard and feedback are hidden.'
|
|
: 'Two guesses before the memory phase.'
|
|
: session.isPractice
|
|
? 'Practice round.'
|
|
: `Daily seed: ${getDateKey()}.`;
|
|
|
|
return (
|
|
<main className="min-h-screen bg-slate-950 text-slate-100">
|
|
<AccessWarning />
|
|
<div className="fixed inset-0 -z-10 bg-[radial-gradient(circle_at_18%_0%,rgba(244,63,94,0.18),transparent_34%),radial-gradient(circle_at_90%_18%,rgba(14,165,233,0.10),transparent_28%),linear-gradient(180deg,#09090d_0%,#020617_100%)]" />
|
|
<div className="mx-auto grid min-h-screen w-full max-w-7xl gap-5 px-4 py-4 sm:px-6 lg:grid-cols-[21rem_minmax(0,1fr)_19rem] lg:py-6">
|
|
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
|
<header className="rounded-xl border border-slate-800/90 bg-slate-950/85 p-4 shadow-2xl shadow-black/20 backdrop-blur">
|
|
<div className="flex items-center gap-3">
|
|
<div className="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-xl border border-rose-300/20 bg-slate-900 shadow-ember">
|
|
<img alt="Evil Wordle" className="h-full w-full object-cover" src="/icon-192.png" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-black text-white">Evil Wordle</h1>
|
|
<p className="mt-1 text-xs font-bold uppercase tracking-[0.18em] text-rose-200">Wordle, but meaner</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
|
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
|
<div className="text-base font-black text-white">{maxGuesses}</div>
|
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Guesses</div>
|
|
</div>
|
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
|
<div className="text-base font-black text-white">{wordLength}</div>
|
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Letters</div>
|
|
</div>
|
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
|
<div className="text-base font-black text-white">{session.guesses.length}</div>
|
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Used</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<ModeSelector activeMode={session.mode} onSelect={changeMode} />
|
|
</aside>
|
|
|
|
<section className="flex min-w-0 flex-col justify-between gap-5 rounded-2xl border border-slate-800/90 bg-slate-900/55 p-3 shadow-2xl shadow-black/30 backdrop-blur sm:p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 pb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 text-sm font-black uppercase tracking-[0.18em] text-rose-200">
|
|
<Moon size={16} />
|
|
{MODE_LABELS[session.mode]}
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-400">{modeNote}</p>
|
|
</div>
|
|
<button
|
|
className="inline-flex items-center gap-2 rounded-md border border-slate-700 bg-slate-950/70 px-3 py-2 text-sm font-black text-slate-100 hover:border-rose-400"
|
|
onClick={() => startNewSession(session.mode, session.level, session.isPractice)}
|
|
type="button"
|
|
>
|
|
<RotateCcw size={16} />
|
|
Restart
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col justify-center gap-5">
|
|
<Board
|
|
currentGuess={session.currentGuess}
|
|
guesses={session.guesses}
|
|
hideFeedback={evilMemoryPhase}
|
|
maxGuesses={maxGuesses}
|
|
shake={shake}
|
|
target={session.target}
|
|
wordLength={wordLength}
|
|
/>
|
|
|
|
<div className="min-h-6 text-center text-sm font-semibold text-rose-100">{session.message}</div>
|
|
|
|
<GameOverPanel
|
|
isPracticeAvailable={session.mode === 'daily' && !session.isPractice}
|
|
onPractice={startPractice}
|
|
onRestart={restart}
|
|
status={session.status}
|
|
target={session.target}
|
|
/>
|
|
</div>
|
|
|
|
<Keyboard hidden={evilMemoryPhase && session.status === 'playing'} letterStates={keyboardStates} onKey={handleKey} />
|
|
</section>
|
|
|
|
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
|
<SettingsPanel onChange={changeSettings} settings={settings} />
|
|
<StatsPanel level={session.mode === 'infinite' ? session.level : stats.infiniteLevel} stats={stats} />
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|