378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
import { h, JSX, Fragment } from 'preact';
|
|
import { useState, useRef, useMemo, useCallback, useEffect } from 'preact/hooks';
|
|
|
|
import * as wasm from 'web-optimle';
|
|
import { clue, describeClue, type Clue, type GuessResult } from './clue';
|
|
import { gameName, speak } from './util';
|
|
|
|
import { COMMON_WORDS, UNCOMMON_WORDS } from './dictionary';
|
|
import { Row, RowState } from './Row';
|
|
import { Keyboard } from './Keyboard';
|
|
import { useLocalStorage } from './localstorage';
|
|
import { PuzzleType, PuzzleTypeDisplay } from './PuzzleType';
|
|
|
|
const MAX_GUESSES = 2;
|
|
const WORD_LENGTH = 5;
|
|
|
|
enum GameState {
|
|
Playing,
|
|
Won,
|
|
Lost,
|
|
}
|
|
|
|
type History = GuessResult[];
|
|
|
|
|
|
type Puzzle = {
|
|
history: History,
|
|
seed: number,
|
|
solutions: number,
|
|
}
|
|
|
|
interface GameProps {
|
|
puzzleType: PuzzleType;
|
|
puzzle: Puzzle;
|
|
day: number;
|
|
hidden: boolean;
|
|
colorBlind: boolean;
|
|
keyboardLayout: string;
|
|
}
|
|
|
|
|
|
type PuzzleStats = {
|
|
tries: number,
|
|
won: boolean,
|
|
guesses?: GuessResult[],
|
|
};
|
|
|
|
function defaultPuzzleStats() {
|
|
return { tries: 1, won: false };
|
|
}
|
|
|
|
type DailyStats = {
|
|
[key in PuzzleType]: PuzzleStats
|
|
};
|
|
|
|
export function Game(props: GameProps) {
|
|
const { puzzleType, puzzle, day } = props;
|
|
|
|
const [gameState, setGameState] = useState(GameState.Playing);
|
|
const [guesses, setGuesses] = useState<GuessResult[]>([]);
|
|
const [currentGuess, setCurrentGuess] = useState<string>("");
|
|
const [dailyStats, setDailyStats] = useLocalStorage<{ [key: number]: DailyStats }>("dailyStats", {});
|
|
const [todayStats, setTodayStats] = useState<DailyStats>({
|
|
2: defaultPuzzleStats(),
|
|
3: defaultPuzzleStats(),
|
|
4: defaultPuzzleStats(),
|
|
});
|
|
const [hint, setHint] = useState<JSX.Element | string>(
|
|
<p>
|
|
Try to guarantee a win in <strong>2 guesses</strong>!
|
|
</p>
|
|
);
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const game = useMemo(
|
|
() => wasm.Game.new(COMMON_WORDS, puzzle.history),
|
|
[puzzle]
|
|
);
|
|
const [gameTree, setGameTree] = useState<GuessResult[][]>();
|
|
const reset = useCallback(() => {
|
|
game.reset();
|
|
setGuesses([...puzzle.history]);
|
|
setCurrentGuess("");
|
|
setGameState(GameState.Playing);
|
|
setTodayStats((oldStats) => ({
|
|
...oldStats,
|
|
[puzzleType]: {
|
|
...oldStats[puzzleType],
|
|
guesses: [],
|
|
},
|
|
}));
|
|
}, [puzzle]);
|
|
|
|
useEffect(() => {
|
|
const selectedPuzzle = todayStats[puzzleType];
|
|
setGuesses([...puzzle.history, ...(selectedPuzzle.guesses || [])]);
|
|
game.reset();
|
|
setGameTree(undefined);
|
|
selectedPuzzle.guesses?.forEach(
|
|
(result) => game.guess(result.word)
|
|
);
|
|
if (selectedPuzzle.won) {
|
|
setGameState(GameState.Won);
|
|
if (selectedPuzzle.tries === 1) {
|
|
setHint(<>
|
|
You won today's <PuzzleTypeDisplay type={puzzleType} />{' '}
|
|
puzzle on your first try! Amazing job.
|
|
</>);
|
|
} else {
|
|
setHint(<>
|
|
You won today's <PuzzleTypeDisplay type={puzzleType} />{' '}
|
|
puzzle in {selectedPuzzle.tries} tries! Great job.
|
|
</>);
|
|
}
|
|
} else if ((selectedPuzzle.guesses?.length || 0) >= 2) {
|
|
setGameState(GameState.Lost);
|
|
setHint(<p>Not quite! The answer could've been <strong>{game.possible_word().toUpperCase()}</strong>. (Enter to try again)</p>);
|
|
} else {
|
|
setGameState(GameState.Playing);
|
|
setHint("");
|
|
}
|
|
}, [puzzle, puzzleType, todayStats]);
|
|
|
|
useEffect(() => {
|
|
const storedTodayStats = dailyStats[day];
|
|
if (storedTodayStats !== undefined) {
|
|
setTodayStats(storedTodayStats);
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
setDailyStats({
|
|
...dailyStats,
|
|
[day]: todayStats,
|
|
});
|
|
}, [todayStats]);
|
|
|
|
async function share(copiedHint: string, text?: string) {
|
|
const url = window.location.origin + window.location.pathname;
|
|
const body = `${text}\n${url}`;
|
|
if (
|
|
/android|iphone|ipad|ipod|webos/i.test(navigator.userAgent) &&
|
|
!/firefox/i.test(navigator.userAgent)
|
|
) {
|
|
try {
|
|
await navigator.share({ text: body });
|
|
return;
|
|
} catch (e) {
|
|
console.warn("navigator.share failed:", e);
|
|
}
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(body);
|
|
setHint(copiedHint);
|
|
return;
|
|
} catch (e) {
|
|
console.warn("navigator.clipboard.writeText failed:", e);
|
|
}
|
|
setHint(url);
|
|
}
|
|
|
|
const onKey = (key: string) => {
|
|
if (gameState !== GameState.Playing) {
|
|
if (key === "Enter" && gameState === GameState.Lost) {
|
|
reset();
|
|
}
|
|
return;
|
|
}
|
|
if (guesses.length === MAX_GUESSES) return;
|
|
if (/^[a-z]$/i.test(key)) {
|
|
// We're about to update the current guess, but
|
|
// we can't rely on the value updating in this render
|
|
const newGuess = currentGuess + key.toLowerCase();
|
|
setCurrentGuess((guess) =>
|
|
(guess + key.toLowerCase()).slice(0, WORD_LENGTH)
|
|
);
|
|
tableRef.current?.focus();
|
|
if (newGuess.length === WORD_LENGTH
|
|
&& !COMMON_WORDS.includes(newGuess)
|
|
&& UNCOMMON_WORDS.includes(newGuess)
|
|
) {
|
|
setHint(`(The word ${newGuess.toUpperCase()} isn't in the secret list)`);
|
|
} else {
|
|
setHint("");
|
|
}
|
|
} else if (key === "Backspace") {
|
|
setCurrentGuess((guess) => guess.slice(0, -1));
|
|
setHint("");
|
|
} else if (key === "Enter") {
|
|
if (currentGuess.length !== WORD_LENGTH) {
|
|
setHint("Too short");
|
|
return;
|
|
}
|
|
if (!COMMON_WORDS.includes(currentGuess)
|
|
&& !UNCOMMON_WORDS.includes(currentGuess)
|
|
) {
|
|
setHint("Not a valid word");
|
|
return;
|
|
}
|
|
const matches: number[] = game.guess(currentGuess);
|
|
const result: GuessResult = {
|
|
word: currentGuess,
|
|
matches: matches,
|
|
}
|
|
setGuesses((guesses) => guesses.concat([result]));
|
|
setTodayStats((oldStats) => ({
|
|
...oldStats,
|
|
[puzzleType]: {
|
|
...oldStats[puzzleType],
|
|
guesses: [
|
|
...(oldStats[puzzleType].guesses || []),
|
|
result,
|
|
],
|
|
},
|
|
}));
|
|
setCurrentGuess("");
|
|
|
|
if (matches.every((m) => m === 2)) {
|
|
setTodayStats((oldStats) => ({
|
|
...oldStats,
|
|
[puzzleType]: {
|
|
...oldStats[puzzleType],
|
|
won: true,
|
|
guesses: [
|
|
...(oldStats[puzzleType].guesses || []),
|
|
result,
|
|
],
|
|
},
|
|
}));
|
|
} else if (guesses.length + 1 === puzzle.history.length + MAX_GUESSES) {
|
|
setTodayStats((oldStats) => ({
|
|
...oldStats,
|
|
[puzzleType]: {
|
|
...oldStats[puzzleType],
|
|
tries: oldStats[puzzleType].tries + 1,
|
|
},
|
|
}));
|
|
} else {
|
|
speak(describeClue(clue(result)));
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
onKey(e.key);
|
|
}
|
|
if (e.key === "Backspace") {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [currentGuess, gameState]);
|
|
|
|
let letterInfo = new Map<string, Clue>();
|
|
const tableRows = Array(puzzle.history.length + MAX_GUESSES)
|
|
.fill(undefined)
|
|
.map((_, i) => {
|
|
const isPartOfPuzzle = i < puzzle.history.length;
|
|
const result = [
|
|
...guesses,
|
|
{ word: currentGuess, matches: [0, 0, 0, 0, 0] },
|
|
][i] ?? { word: "", matches: [0, 0, 0, 0, 0] };
|
|
const cluedLetters = clue(result);
|
|
const lockedIn = i < guesses.length;
|
|
if (lockedIn) {
|
|
for (const { clue, letter } of cluedLetters) {
|
|
if (clue === undefined) break;
|
|
const old = letterInfo.get(letter);
|
|
if (old === undefined || clue > old) {
|
|
letterInfo.set(letter, clue);
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
<Row
|
|
key={i}
|
|
wordLength={WORD_LENGTH}
|
|
rowState={
|
|
lockedIn
|
|
? RowState.LockedIn
|
|
: i === guesses.length
|
|
? RowState.Editing
|
|
: RowState.Pending
|
|
}
|
|
cluedLetters={cluedLetters}
|
|
isPartOfPuzzle={isPartOfPuzzle}
|
|
/>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="Game" style={{ display: props.hidden ? "none" : "block" }}>
|
|
<table
|
|
className="Game-rows"
|
|
tabIndex={0}
|
|
aria-label="Table of guesses"
|
|
ref={tableRef}
|
|
>
|
|
<tbody>{tableRows}</tbody>
|
|
</table>
|
|
<p
|
|
role="alert"
|
|
style={{
|
|
userSelect: "none",
|
|
whiteSpace: "pre-wrap",
|
|
}}
|
|
>
|
|
{hint || `\u00a0`}
|
|
</p>
|
|
<Keyboard
|
|
layout={props.keyboardLayout}
|
|
letterInfo={letterInfo}
|
|
onKey={onKey}
|
|
/>
|
|
<p>
|
|
{gameState === GameState.Won && (
|
|
<div class="win-buttons">
|
|
<button
|
|
onClick={() => {
|
|
const emoji = props.colorBlind
|
|
? ["⬛", "🟦", "🟧"]
|
|
: ["⬛", "🟨", "🟩"];
|
|
const score = (todayStats[puzzleType].tries === 1
|
|
? "my first try!"
|
|
: `try #${todayStats[puzzleType].tries}!`
|
|
);
|
|
share(
|
|
"Result copied to clipboard!",
|
|
`I solved ${gameName} #${puzzle.seed} (${puzzleType}🟩) on ${score}\n` +
|
|
guesses
|
|
.slice(puzzle.history.length)
|
|
.map((result) =>
|
|
clue(result)
|
|
.map((c) => emoji[c.clue ?? 0])
|
|
.join("")
|
|
)
|
|
.join("\n")
|
|
);
|
|
}}
|
|
>
|
|
Share results
|
|
</button>
|
|
<button onClick={() => setGameTree(game.explore_game_tree())}>
|
|
Explore game tree
|
|
</button>
|
|
</div>
|
|
)}
|
|
</p>
|
|
{gameTree && (
|
|
<div className="game-tree">
|
|
<p>Here are all the ways the game could've responded:</p>
|
|
{gameTree?.map((option) =>
|
|
<div className="game-tree-option">
|
|
{option.map((guessResult, i) => {
|
|
const cluedLetters = clue(guessResult);
|
|
return (
|
|
<Row
|
|
key={i}
|
|
wordLength={WORD_LENGTH}
|
|
rowState={RowState.LockedIn}
|
|
cluedLetters={cluedLetters}
|
|
isPartOfPuzzle={false}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<p>
|
|
Today's puzzle (#{puzzle.seed}) has {puzzle.solutions} path{puzzle.solutions === 1 ? '' : 's'} to a win.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|