👁️
1import { useCallback, useEffect, useMemo, useState } from "react";
2import type { DeckCard } from "@/lib/deck-types";
3import type { Card, ScryfallId } from "@/lib/scryfall-types";
4import { useSeededRandom } from "@/lib/useSeededRandom";
5import * as engine from "./engine";
6import type { GameState, Zone } from "./types";
7
8export interface GoldfishActions {
9 draw: () => void;
10 mulligan: () => void;
11 reset: () => void;
12 tap: (instanceId: number) => void;
13 untap: (instanceId: number) => void;
14 toggleTap: (instanceId: number) => void;
15 untapAll: () => void;
16 cycleFace: (instanceId: number, maxFaces: number) => void;
17 toggleFaceDown: (instanceId: number) => void;
18 flipCard: (instanceId: number, maxFaces: number) => void;
19 moveCard: (
20 instanceId: number,
21 toZone: Zone,
22 options?: { position?: { x: number; y: number }; faceDown?: boolean },
23 ) => void;
24 setHoveredCard: (instanceId: number | null) => void;
25 addCounter: (
26 instanceId: number,
27 counterType: string,
28 amount?: number,
29 ) => void;
30 removeCounter: (
31 instanceId: number,
32 counterType: string,
33 amount?: number,
34 ) => void;
35 setCounter: (instanceId: number, counterType: string, value: number) => void;
36 clearCounters: (instanceId: number) => void;
37 adjustLife: (amount: number) => void;
38 adjustPoison: (amount: number) => void;
39}
40
41export interface UseGoldfishGameOptions {
42 startingLife?: number;
43 cardLookup?: (id: ScryfallId) => Card | undefined;
44}
45
46export interface UseGoldfishGameResult {
47 state: GameState;
48 actions: GoldfishActions;
49 SeedEmbed: () => React.ReactElement;
50}
51
52export function useGoldfishGame(
53 deck: DeckCard[],
54 options: UseGoldfishGameOptions = {},
55): UseGoldfishGameResult {
56 const { startingLife = 20, cardLookup } = options;
57 const { rng, SeedEmbed } = useSeededRandom();
58
59 const [state, setState] = useState<GameState>(() =>
60 engine.createInitialState(deck, rng, startingLife),
61 );
62
63 const actions: GoldfishActions = useMemo(
64 () => ({
65 draw: () => setState((s) => engine.draw(s)),
66 mulligan: () => setState((s) => engine.mulligan(s, rng, startingLife)),
67 reset: () => setState(engine.createInitialState(deck, rng, startingLife)),
68 tap: (id) => setState((s) => engine.tap(s, id)),
69 untap: (id) => setState((s) => engine.untap(s, id)),
70 toggleTap: (id) => setState((s) => engine.toggleTap(s, id)),
71 untapAll: () => setState((s) => engine.untapAll(s)),
72 cycleFace: (id, maxFaces) =>
73 setState((s) => engine.cycleFace(s, id, maxFaces)),
74 toggleFaceDown: (id) => setState((s) => engine.toggleFaceDown(s, id)),
75 flipCard: (id, maxFaces) =>
76 setState((s) => engine.flipCard(s, id, maxFaces)),
77 moveCard: (id, zone, opts) =>
78 setState((s) => engine.moveCard(s, id, zone, opts)),
79 setHoveredCard: (id) => setState((s) => engine.setHoveredCard(s, id)),
80 addCounter: (id, type, amount) =>
81 setState((s) => engine.addCounter(s, id, type, amount)),
82 removeCounter: (id, type, amount) =>
83 setState((s) => engine.removeCounter(s, id, type, amount)),
84 setCounter: (id, type, value) =>
85 setState((s) => engine.setCounter(s, id, type, value)),
86 clearCounters: (id) => setState((s) => engine.clearCounters(s, id)),
87 adjustLife: (amount) => setState((s) => engine.adjustLife(s, amount)),
88 adjustPoison: (amount) => setState((s) => engine.adjustPoison(s, amount)),
89 }),
90 [deck, rng, startingLife],
91 );
92
93 const getFaceCount = useCallback(
94 (cardId: ScryfallId): number => {
95 if (!cardLookup) return 1;
96 const card = cardLookup(cardId);
97 if (!card) return 1;
98 return card.card_faces?.length ?? 1;
99 },
100 [cardLookup],
101 );
102
103 useEffect(() => {
104 function handleKeyDown(e: KeyboardEvent) {
105 if (
106 e.target instanceof HTMLInputElement ||
107 e.target instanceof HTMLTextAreaElement
108 ) {
109 return;
110 }
111
112 const hoveredId = state.hoveredId;
113
114 switch (e.key.toLowerCase()) {
115 case "d":
116 e.preventDefault();
117 actions.draw();
118 break;
119 case "u":
120 e.preventDefault();
121 actions.untapAll();
122 break;
123 case "t":
124 case " ":
125 if (hoveredId !== null) {
126 e.preventDefault();
127 actions.toggleTap(hoveredId);
128 }
129 break;
130 case "f":
131 if (hoveredId !== null) {
132 e.preventDefault();
133 // Include library top so it can be revealed
134 const card = [
135 ...state.hand,
136 ...state.battlefield,
137 ...state.graveyard,
138 ...state.exile,
139 ...state.library.slice(0, 1),
140 ].find((c) => c.instanceId === hoveredId);
141 if (card) {
142 actions.flipCard(hoveredId, getFaceCount(card.cardId));
143 }
144 }
145 break;
146 case "g":
147 if (hoveredId !== null) {
148 e.preventDefault();
149 actions.moveCard(hoveredId, "graveyard");
150 }
151 break;
152 case "e":
153 if (hoveredId !== null) {
154 e.preventDefault();
155 actions.moveCard(hoveredId, "exile");
156 }
157 break;
158 case "h":
159 if (hoveredId !== null) {
160 e.preventDefault();
161 actions.moveCard(hoveredId, "hand");
162 }
163 break;
164 case "b":
165 if (hoveredId !== null) {
166 e.preventDefault();
167 actions.moveCard(hoveredId, "battlefield");
168 }
169 break;
170 }
171 }
172
173 window.addEventListener("keydown", handleKeyDown);
174 return () => window.removeEventListener("keydown", handleKeyDown);
175 }, [
176 state.hoveredId,
177 state.hand,
178 state.library,
179 state.battlefield,
180 state.graveyard,
181 state.exile,
182 actions,
183 getFaceCount,
184 ]);
185
186 return { state, actions, SeedEmbed };
187}