Skip to content

Commit d61edf0

Browse files
committed
Support multiple word packs
1 parent 3a032ed commit d61edf0

File tree

9 files changed

+265
-68
lines changed

9 files changed

+265
-68
lines changed

public/cats.txt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
BOBCAT
2+
CARACAL
3+
CHEETAH
4+
CHESHIRE CAT
5+
COUGAR
6+
JAGUAR
7+
JAGUARUNDI
8+
KODKOD
9+
LEOPARD
10+
LION
11+
LYNX
12+
MARGAY
13+
NYAN CAT
14+
OCELOT
15+
ONCILLA
16+
PAMPAS CAT
17+
PUSS IN BOOTS
18+
SERVAL
19+
SNOW LEOPARD
20+
TIGER

public/us-states.txt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
ALABAMA
2+
ALASKA
3+
ARIZONA
4+
ARKANSAS
5+
CALIFORNIA
6+
COLORADO
7+
CONNECTICUT
8+
DELAWARE
9+
FLORIDA
10+
GEORGIA
11+
HAWAII
12+
IDAHO
13+
ILLINOIS
14+
INDIANA
15+
IOWA
16+
KANSAS
17+
KENTUCKY
18+
LOUISIANA
19+
MAINE
20+
MARYLAND
21+
MASSACHUSETTS
22+
MICHIGAN
23+
MINNESOTA
24+
MISSISSIPPI
25+
MISSOURI
26+
MONTANA
27+
NEBRASKA
28+
NEVADA
29+
NEW HAMPSHIRE
30+
NEW JERSEY
31+
NEW MEXICO
32+
NEW YORK
33+
NORTH CAROLINA
34+
NORTH DAKOTA
35+
OHIO
36+
OKLAHOMA
37+
OREGON
38+
PENNSYLVANIA
39+
RHODE ISLAND
40+
SOUTH CAROLINA
41+
SOUTH DAKOTA
42+
TENNESSEE
43+
TEXAS
44+
UTAH
45+
VERMONT
46+
VIRGINIA
47+
WASHINGTON
48+
WEST VIRGINIA
49+
WISCONSIN
50+
WYOMING

src/App.module.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,26 @@
4545
.buttonRow {
4646
gap: 1vw;
4747
}
48+
49+
.wordPackSelector {
50+
display: flex;
51+
flex-direction: column;
52+
}
53+
54+
.wordPackSelector button:first-child {
55+
border-bottom-left-radius: 0;
56+
border-bottom-right-radius: 0;
57+
}
58+
59+
.wordPackSelector button:last-child {
60+
border-top-left-radius: 0;
61+
border-top-right-radius: 0;
62+
}
63+
64+
.wordPackSelector button:not(:first-child):not(:last-child) {
65+
border-radius: 0;
66+
}
67+
68+
.wordPackSelector button:not(:first-child) {
69+
border-top: none;
70+
}

src/App.tsx

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { type ReactNode, useCallback, useEffect, useRef } from "react";
22

33
import styles from "./App.module.css";
44
import Rounds from "./Rounds";
5-
import useAppState, { type Round } from "./hooks/useAppState";
5+
import useAppState, { type Round, type WordPack } from "./hooks/useAppState";
66
import useLoadData from "./hooks/useLoadData";
77
import countIf from "./util/countIf";
88
import pluralize from "./util/pluralize";
@@ -31,6 +31,30 @@ function Container({
3131
);
3232
}
3333

34+
function getSortedWordPacks(wordPacks: Record<string, WordPack>): WordPack[] {
35+
return Object.values(wordPacks).sort((a, b) =>
36+
a.title.localeCompare(b.title),
37+
);
38+
}
39+
40+
function WordPackSelector({
41+
wordPacks,
42+
onSelect,
43+
}: {
44+
wordPacks: readonly WordPack[];
45+
onSelect: (wordPackId: string) => void;
46+
}) {
47+
return (
48+
<div className={styles.wordPackSelector}>
49+
{wordPacks.map((wordPack) => (
50+
<button onClick={() => onSelect(wordPack.id)} key={wordPack.id}>
51+
{wordPack.title} ({pluralize(wordPack.words.length, "item")})
52+
</button>
53+
))}
54+
</div>
55+
);
56+
}
57+
3458
export default function App() {
3559
const [state, dispatch] = useAppState();
3660
useLoadData(dispatch);
@@ -60,19 +84,20 @@ export default function App() {
6084

6185
switch (state.phase) {
6286
case "pre-game": {
63-
if (state.wordPack == null) {
87+
const wordPacks = getSortedWordPacks(state.wordPacks);
88+
if (state.bannedWords == null || wordPacks.length === 0) {
6489
return <Container>Loading data...</Container>;
6590
}
6691

6792
return (
6893
<Container>
69-
<div>
70-
Word pack (<strong>fruits</strong>) is ready with{" "}
71-
{pluralize(state.wordPack.length, "word")}!
72-
</div>
73-
<button onClick={() => dispatch({ type: "start-game" })} autoFocus>
74-
Play
75-
</button>
94+
<div>What would you like to unscramble?</div>
95+
<WordPackSelector
96+
wordPacks={wordPacks}
97+
onSelect={(selectedWordPackId) =>
98+
dispatch({ type: "start-game", selectedWordPackId })
99+
}
100+
/>
76101
</Container>
77102
);
78103
}
@@ -92,7 +117,7 @@ export default function App() {
92117
<Word
93118
word={
94119
state.guess.toUpperCase() ||
95-
// Make sure the container is never empty, so that it takes some vertical space.
120+
// Make sure the container is never empty, so that it takes up some vertical space.
96121
" "
97122
}
98123
highlightInReference
@@ -107,11 +132,11 @@ export default function App() {
107132
className={`${styles.guess} word`}
108133
value={state.guess}
109134
onChange={(ev) =>
110-
dispatch({ type: "update-guess", newGuess: ev.target.value })
135+
dispatch({ type: "change-guess", newGuess: ev.target.value })
111136
}
112137
/>
113138
</div>
114-
<div>Unscramble the word!</div>
139+
<div>Unscramble the word or phrase!</div>
115140
</label>
116141
<div className={`${styles.buttonRow} centered-container flex-row`}>
117142
<button onClick={skipWord}>Skip</button>
@@ -124,24 +149,29 @@ export default function App() {
124149
}
125150

126151
case "post-game": {
127-
const wordsGuessed = countIf(
152+
const itemsGuessed = countIf(
128153
state.finishedRounds,
129154
(round) => round.status === "guessed",
130155
);
131-
const wordsSkipped = countIf(
156+
const itemsSkipped = countIf(
132157
state.finishedRounds,
133158
(round) => round.status === "skipped",
134159
);
135160

136161
return (
137162
<Container finishedRounds={state.finishedRounds}>
138163
<div>
139-
You guessed {pluralize(wordsGuessed, "word")} and skipped{" "}
140-
{pluralize(wordsSkipped, "word")}.
164+
You guessed {pluralize(itemsGuessed, "item")} and skipped{" "}
165+
{pluralize(itemsSkipped, "item")}.
141166
</div>
142-
<button onClick={() => dispatch({ type: "start-game" })} autoFocus>
143-
Play again
144-
</button>
167+
<div />
168+
<div>Play again?</div>
169+
<WordPackSelector
170+
wordPacks={getSortedWordPacks(state.wordPacks)}
171+
onSelect={(selectedWordPackId) =>
172+
dispatch({ type: "start-game", selectedWordPackId })
173+
}
174+
/>
145175
</Container>
146176
);
147177
}

src/hooks/useAppState.ts

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export type Round = Readonly<{
1010
status?: "guessed" | "skipped";
1111
}>;
1212

13+
export type WordPack = Readonly<{
14+
id: string;
15+
title: string;
16+
words: readonly string[];
17+
}>;
18+
1319
function getNewRound(
1420
getNextWord: () => string,
1521
bannedWords: readonly string[],
@@ -31,7 +37,7 @@ function getNewRound(
3137
type PreGameState = Readonly<{
3238
phase: "pre-game";
3339
bannedWords: readonly string[] | null;
34-
wordPack: readonly string[] | null;
40+
wordPacks: Record<string, WordPack>;
3541
}>;
3642

3743
type InGameState = Readonly<{
@@ -40,15 +46,15 @@ type InGameState = Readonly<{
4046
finishedRounds: readonly Round[];
4147
guess: string;
4248
bannedWords: readonly string[];
43-
wordPack: readonly string[];
49+
wordPacks: Record<string, WordPack>;
4450
getNextWord: () => string;
4551
}>;
4652

4753
type PostGameState = {
4854
phase: "post-game";
4955
finishedRounds: readonly Round[];
5056
bannedWords: readonly string[];
51-
wordPack: readonly string[];
57+
wordPacks: Record<string, WordPack>;
5258
};
5359

5460
export type State = PreGameState | InGameState | PostGameState;
@@ -66,19 +72,38 @@ function getNewRoundState(state: InGameState, didGuess: boolean): InGameState {
6672
}
6773

6874
export function getInitialState(): State {
69-
return { phase: "pre-game", bannedWords: null, wordPack: null };
75+
return {
76+
phase: "pre-game",
77+
bannedWords: null,
78+
wordPacks: {},
79+
};
7080
}
7181

7282
export type Action =
83+
| { type: "change-guess"; newGuess: string }
7384
| { type: "end-game" }
7485
| { type: "load-banned-words"; bannedWords: readonly string[] }
75-
| { type: "load-word-pack"; wordPack: readonly string[] }
86+
| { type: "load-word-pack"; wordPack: WordPack }
7687
| { type: "skip-word" }
77-
| { type: "start-game" }
78-
| { type: "update-guess"; newGuess: string };
88+
| { type: "start-game"; selectedWordPackId: string };
7989

8090
export function reducer(state: State, action: Action): State {
8191
switch (action.type) {
92+
case "change-guess": {
93+
// No-op if not in a game.
94+
if (state.phase !== "in-game") {
95+
return state;
96+
}
97+
98+
if (
99+
normalizeString(action.newGuess) === state.currentRound.wordUnscrambled
100+
) {
101+
return getNewRoundState(state, true);
102+
}
103+
104+
return { ...state, guess: action.newGuess };
105+
}
106+
82107
case "end-game": {
83108
// No-op if not in a game.
84109
if (state.phase !== "in-game") {
@@ -89,7 +114,7 @@ export function reducer(state: State, action: Action): State {
89114
phase: "post-game",
90115
finishedRounds: [...state.finishedRounds, state.currentRound],
91116
bannedWords: state.bannedWords,
92-
wordPack: state.wordPack,
117+
wordPacks: state.wordPacks,
93118
};
94119
}
95120

@@ -103,12 +128,18 @@ export function reducer(state: State, action: Action): State {
103128
}
104129

105130
case "load-word-pack": {
106-
// No-op if not in pre-game phase, or if we already have a word pack.
107-
if (state.phase !== "pre-game" || state.wordPack) {
131+
// No-op if not in pre-game phase.
132+
if (state.phase !== "pre-game") {
108133
return state;
109134
}
110135

111-
return { ...state, wordPack: action.wordPack };
136+
return {
137+
...state,
138+
wordPacks: {
139+
...state.wordPacks,
140+
[action.wordPack.id]: action.wordPack,
141+
},
142+
};
112143
}
113144

114145
case "skip-word": {
@@ -126,38 +157,25 @@ export function reducer(state: State, action: Action): State {
126157
return state;
127158
}
128159

129-
// No-op if data is not loaded.
130-
const { bannedWords, wordPack } = state;
131-
if (bannedWords == null || wordPack == null) {
160+
const { wordPacks, bannedWords } = state;
161+
162+
// No-op if word pack or banned words aren't loaded.
163+
const wordPack = wordPacks[action.selectedWordPackId];
164+
if (wordPack == null || bannedWords == null) {
132165
return state;
133166
}
134167

135-
const getNextWord = shuffleInfinitely(wordPack);
168+
const getNextWord = shuffleInfinitely(wordPack.words);
136169
return {
137170
phase: "in-game",
138171
currentRound: getNewRound(getNextWord, bannedWords),
139172
finishedRounds: [],
140173
guess: "",
141174
bannedWords,
142-
wordPack,
143175
getNextWord,
176+
wordPacks,
144177
};
145178
}
146-
147-
case "update-guess": {
148-
// No-op if not in a game.
149-
if (state.phase !== "in-game") {
150-
return state;
151-
}
152-
153-
if (
154-
normalizeString(action.newGuess) === state.currentRound.wordUnscrambled
155-
) {
156-
return getNewRoundState(state, true);
157-
}
158-
159-
return { ...state, guess: action.newGuess };
160-
}
161179
}
162180

163181
// This should never happen!

0 commit comments

Comments
 (0)