kopia lustrzana https://github.com/lynn/hello-wordl
331 wiersze
9.1 KiB
TypeScript
331 wiersze
9.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Row, RowState } from "./Row";
|
|
import dictionary from "./dictionary.json";
|
|
import { Clue, clue, describeClue, violation } from "./clue";
|
|
import { Keyboard } from "./Keyboard";
|
|
import targetList from "./targets.json";
|
|
import {
|
|
dictionarySet,
|
|
Difficulty,
|
|
pick,
|
|
resetRng,
|
|
seed,
|
|
speak,
|
|
urlParam,
|
|
} from "./util";
|
|
import { decode, encode } from "./base64";
|
|
|
|
enum GameState {
|
|
Playing,
|
|
Won,
|
|
Lost,
|
|
}
|
|
|
|
interface GameProps {
|
|
maxGuesses: number;
|
|
hidden: boolean;
|
|
difficulty: Difficulty;
|
|
colorBlind: boolean;
|
|
}
|
|
|
|
const targets = targetList.slice(0, targetList.indexOf("murky") + 1); // Words no rarer than this one
|
|
const minWordLength = 4;
|
|
const maxWordLength = 11;
|
|
|
|
function randomTarget(wordLength: number): string {
|
|
const eligible = targets.filter((word) => word.length === wordLength);
|
|
let candidate: string;
|
|
do {
|
|
candidate = pick(eligible);
|
|
} while (/\*/.test(candidate));
|
|
return candidate;
|
|
}
|
|
|
|
function getChallengeUrl(target: string): string {
|
|
return (
|
|
window.location.origin +
|
|
window.location.pathname +
|
|
"?challenge=" +
|
|
encode(target)
|
|
);
|
|
}
|
|
|
|
let initChallenge = "";
|
|
let challengeError = false;
|
|
try {
|
|
initChallenge = decode(urlParam("challenge") ?? "").toLowerCase();
|
|
} catch (e) {
|
|
console.warn(e);
|
|
challengeError = true;
|
|
}
|
|
if (initChallenge && !dictionarySet.has(initChallenge)) {
|
|
initChallenge = "";
|
|
challengeError = true;
|
|
}
|
|
|
|
function Game(props: GameProps) {
|
|
const [gameState, setGameState] = useState(GameState.Playing);
|
|
const [guesses, setGuesses] = useState<string[]>([]);
|
|
const [currentGuess, setCurrentGuess] = useState<string>("");
|
|
const [hint, setHint] = useState<string>(
|
|
challengeError
|
|
? `Invalid challenge string, playing random game.`
|
|
: `Make your first guess!`
|
|
);
|
|
const [challenge, setChallenge] = useState<string>(initChallenge);
|
|
const [wordLength, setWordLength] = useState(
|
|
challenge ? challenge.length : 5
|
|
);
|
|
const [target, setTarget] = useState(() => {
|
|
resetRng();
|
|
return challenge || randomTarget(wordLength);
|
|
});
|
|
const [gameNumber, setGameNumber] = useState(1);
|
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
const startNextGame = () => {
|
|
if (challenge) {
|
|
// Clear the URL parameters:
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
setChallenge("");
|
|
const newWordLength =
|
|
wordLength < minWordLength || wordLength > maxWordLength ? 5 : wordLength;
|
|
setWordLength(newWordLength);
|
|
setTarget(randomTarget(newWordLength));
|
|
setGuesses([]);
|
|
setCurrentGuess("");
|
|
setHint("");
|
|
setGameState(GameState.Playing);
|
|
setGameNumber((x) => x + 1);
|
|
};
|
|
|
|
async function share(url: string, copiedHint: string, text?: string) {
|
|
const body = url + (text ? "\n\n" + text : "");
|
|
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") {
|
|
startNextGame();
|
|
}
|
|
return;
|
|
}
|
|
if (guesses.length === props.maxGuesses) return;
|
|
if (/^[a-z]$/i.test(key)) {
|
|
setCurrentGuess((guess) =>
|
|
(guess + key.toLowerCase()).slice(0, wordLength)
|
|
);
|
|
tableRef.current?.focus();
|
|
setHint("");
|
|
} else if (key === "Backspace") {
|
|
setCurrentGuess((guess) => guess.slice(0, -1));
|
|
setHint("");
|
|
} else if (key === "Enter") {
|
|
if (currentGuess.length !== wordLength) {
|
|
setHint("Too short");
|
|
return;
|
|
}
|
|
if (!dictionary.includes(currentGuess)) {
|
|
setHint("Not a valid word");
|
|
return;
|
|
}
|
|
for (const g of guesses) {
|
|
const c = clue(g, target);
|
|
const feedback = violation(props.difficulty, c, currentGuess);
|
|
if (feedback) {
|
|
setHint(feedback);
|
|
return;
|
|
}
|
|
}
|
|
setGuesses((guesses) => guesses.concat([currentGuess]));
|
|
setCurrentGuess((guess) => "");
|
|
|
|
const gameOver = (verbed: string) =>
|
|
`You ${verbed}! The answer was ${target.toUpperCase()}. (Enter to ${
|
|
challenge ? "play a random game" : "play again"
|
|
})`;
|
|
|
|
if (currentGuess === target) {
|
|
setHint(gameOver("won"));
|
|
setGameState(GameState.Won);
|
|
} else if (guesses.length + 1 === props.maxGuesses) {
|
|
setHint(gameOver("lost"));
|
|
setGameState(GameState.Lost);
|
|
} else {
|
|
setHint("");
|
|
speak(describeClue(clue(currentGuess, target)));
|
|
}
|
|
}
|
|
};
|
|
|
|
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(props.maxGuesses)
|
|
.fill(undefined)
|
|
.map((_, i) => {
|
|
const guess = [...guesses, currentGuess][i] ?? "";
|
|
const cluedLetters = clue(guess, target);
|
|
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={wordLength}
|
|
rowState={
|
|
lockedIn
|
|
? RowState.LockedIn
|
|
: i === guesses.length
|
|
? RowState.Editing
|
|
: RowState.Pending
|
|
}
|
|
cluedLetters={cluedLetters}
|
|
/>
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div className="Game" style={{ display: props.hidden ? "none" : "block" }}>
|
|
<div className="Game-options">
|
|
<label htmlFor="wordLength">Letters:</label>
|
|
<input
|
|
type="range"
|
|
min={minWordLength}
|
|
max={maxWordLength}
|
|
id="wordLength"
|
|
disabled={
|
|
gameState === GameState.Playing &&
|
|
(guesses.length > 0 || currentGuess !== "" || challenge !== "")
|
|
}
|
|
value={wordLength}
|
|
onChange={(e) => {
|
|
const length = Number(e.target.value);
|
|
resetRng();
|
|
setGameNumber(1);
|
|
setGameState(GameState.Playing);
|
|
setGuesses([]);
|
|
setCurrentGuess("");
|
|
setTarget(randomTarget(length));
|
|
setWordLength(length);
|
|
setHint(`${length} letters`);
|
|
}}
|
|
></input>
|
|
<button
|
|
style={{ flex: "0 0 auto" }}
|
|
disabled={gameState !== GameState.Playing || guesses.length === 0}
|
|
onClick={() => {
|
|
setHint(
|
|
`The answer was ${target.toUpperCase()}. (Enter to play again)`
|
|
);
|
|
setGameState(GameState.Lost);
|
|
(document.activeElement as HTMLElement)?.blur();
|
|
}}
|
|
>
|
|
Give up
|
|
</button>
|
|
</div>
|
|
<table
|
|
className="Game-rows"
|
|
tabIndex={0}
|
|
aria-label="Table of guesses"
|
|
ref={tableRef}
|
|
>
|
|
<tbody>{tableRows}</tbody>
|
|
</table>
|
|
<p
|
|
role="alert"
|
|
style={{
|
|
userSelect: /https?:/.test(hint) ? "text" : "none",
|
|
whiteSpace: "pre-wrap",
|
|
}}
|
|
>
|
|
{hint || `\u00a0`}
|
|
</p>
|
|
<Keyboard letterInfo={letterInfo} onKey={onKey} />
|
|
{gameState !== GameState.Playing && (
|
|
<p>
|
|
<button
|
|
onClick={() => {
|
|
share(
|
|
getChallengeUrl(target),
|
|
"Challenge link copied to clipboard!"
|
|
);
|
|
}}
|
|
>
|
|
Challenge a friend to this word
|
|
</button>{" "}
|
|
<button
|
|
onClick={() => {
|
|
const emoji = props.colorBlind
|
|
? ["⬛", "🟦", "🟧"]
|
|
: ["⬛", "🟨", "🟩"];
|
|
share(
|
|
getChallengeUrl(target),
|
|
"Result copied to clipboard!",
|
|
guesses
|
|
.map((guess) =>
|
|
clue(guess, target)
|
|
.map((c) => emoji[c.clue ?? 0])
|
|
.join("")
|
|
)
|
|
.join("\n")
|
|
);
|
|
}}
|
|
>
|
|
Share emoji results
|
|
</button>
|
|
</p>
|
|
)}
|
|
{challenge ? (
|
|
<div className="Game-seed-info">playing a challenge game</div>
|
|
) : seed ? (
|
|
<div className="Game-seed-info">
|
|
seed {seed}, length {wordLength}, game {gameNumber}
|
|
</div>
|
|
) : undefined}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Game;
|