optimle/web-optimle/www/src/Game.tsx
2022-03-15 22:00:04 -07:00

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>
);
}