|
1 | | -import { Box, Text } from "ink"; |
2 | | -import { useCallback, useEffect, useMemo, useReducer } from "react"; |
| 1 | +import { Box, type DOMElement, Text, useBoxMetrics } from "ink"; |
| 2 | +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; |
3 | 3 | import { type ShortcutBinding, useInkShortcuts } from "./ink-shortcuts.js"; |
4 | 4 | import { |
5 | 5 | createSnake, |
6 | 6 | pauseSnake, |
7 | 7 | renderSnake, |
| 8 | + resizeSnake, |
8 | 9 | type SnakeDirection, |
9 | 10 | type SnakeState, |
10 | 11 | type SnakeStatus, |
@@ -32,7 +33,7 @@ const MAX_BOARD_TERMINAL_ROWS = 14; |
32 | 33 | const BOARD_CHROME_ROWS = 3; |
33 | 34 |
|
34 | 35 | /** Board size in cells for a pane of `cols` × `rows` terminal cells. */ |
35 | | -export function snakeBoardSize( |
| 36 | +function snakeBoardSize( |
36 | 37 | cols: number, |
37 | 38 | rows: number, |
38 | 39 | ): { width: number; height: number } { |
@@ -67,31 +68,50 @@ const STATUS_HINT: Record<SnakeStatus, string> = { |
67 | 68 | over: "Game over · r to retry", |
68 | 69 | }; |
69 | 70 |
|
70 | | -export function SnakeGame({ |
| 71 | +type SnakeGameProps = { |
| 72 | + accent: string; |
| 73 | + muted: string; |
| 74 | + onCancel: () => void; |
| 75 | + onExit: () => void; |
| 76 | + session: SnakeSession; |
| 77 | +}; |
| 78 | + |
| 79 | +/** Fills the space it is given and sizes the board from the measured box. */ |
| 80 | +export function SnakeGame(props: SnakeGameProps): React.ReactNode { |
| 81 | + const ref = useRef<DOMElement>(null); |
| 82 | + const pane = useBoxMetrics(ref); |
| 83 | + const board = pane.hasMeasured |
| 84 | + ? snakeBoardSize(pane.width, pane.height) |
| 85 | + : null; |
| 86 | + return ( |
| 87 | + <Box |
| 88 | + flexDirection="column" |
| 89 | + flexGrow={1} |
| 90 | + flexShrink={1} |
| 91 | + minHeight={0} |
| 92 | + overflow="hidden" |
| 93 | + ref={ref} |
| 94 | + > |
| 95 | + {board ? ( |
| 96 | + <SnakeBoard {...props} height={board.height} width={board.width} /> |
| 97 | + ) : null} |
| 98 | + </Box> |
| 99 | + ); |
| 100 | +} |
| 101 | + |
| 102 | +function SnakeBoard({ |
71 | 103 | accent, |
72 | 104 | height, |
73 | 105 | muted, |
74 | 106 | onCancel, |
75 | 107 | onExit, |
76 | 108 | session, |
77 | 109 | width, |
78 | | -}: { |
79 | | - accent: string; |
80 | | - height: number; |
81 | | - muted: string; |
82 | | - onCancel: () => void; |
83 | | - onExit: () => void; |
84 | | - session: SnakeSession; |
85 | | - width: number; |
86 | | -}): React.ReactNode { |
| 110 | +}: SnakeGameProps & { height: number; width: number }): React.ReactNode { |
87 | 111 | const [, redraw] = useReducer((frame: number) => frame + 1, 0); |
88 | | - if ( |
89 | | - !session.state || |
90 | | - session.state.width !== width || |
91 | | - session.state.height !== height |
92 | | - ) { |
93 | | - session.state = createSnake(width, height); |
94 | | - } |
| 112 | + session.state = session.state |
| 113 | + ? resizeSnake(session.state, width, height) |
| 114 | + : createSnake(width, height); |
95 | 115 | const state = session.state; |
96 | 116 |
|
97 | 117 | const apply = useCallback( |
@@ -175,12 +195,12 @@ export function SnakeGame({ |
175 | 195 | ); |
176 | 196 | useInkShortcuts("snake-game", bindings); |
177 | 197 |
|
178 | | - const board = useMemo(() => renderSnake(state).join("\n"), [state]); |
| 198 | + const frame = useMemo(() => renderSnake(state).join("\n"), [state]); |
179 | 199 |
|
180 | 200 | return ( |
181 | 201 | <Box flexDirection="column" flexShrink={0}> |
182 | 202 | <Box borderColor={muted} borderStyle="round" width={width + 2}> |
183 | | - <Text>{board}</Text> |
| 203 | + <Text>{frame}</Text> |
184 | 204 | </Box> |
185 | 205 | <Box gap={2} paddingX={1}> |
186 | 206 | <Text color={accent}>Bugs squashed {state.score}</Text> |
|
0 commit comments