@g3nomaly/g3n-mods 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +17 -4
- package/mods/agents-panel/hooks/register.tsx +49 -21
- package/mods/agents-panel/types/index.d.ts +16 -1
- package/mods/agents-panel/view/view.css +51 -0
- package/mods/agents-panel/view/view.ts +126 -0
- package/mods/custom-crab-agents/hooks/register.tsx +108 -95
- package/mods/custom-crab-agents/hooks/sprite.ts +131 -0
- package/mods/custom-crab-agents/types/index.d.ts +35 -0
- package/mods/custom-crab-agents/view/view.css +74 -0
- package/mods/custom-crab-agents/view/view.ts +154 -0
- package/mods/delete-guard/hooks/register.tsx +115 -33
- package/mods/delete-guard/types/index.d.ts +2 -0
- package/mods/mini-game/.claude-plugin/plugin.json +1 -1
- package/mods/mini-game/hooks/bubbles.ts +27 -0
- package/mods/mini-game/hooks/minesweeper.ts +108 -0
- package/mods/mini-game/hooks/mole.ts +91 -0
- package/mods/mini-game/hooks/register.tsx +196 -152
- package/mods/mini-game/types/index.d.ts +18 -1
- package/mods/tool-timeline/hooks/register.tsx +141 -38
- package/mods/tool-timeline/types/index.d.ts +33 -2
- package/mods/tool-timeline/view/view.css +124 -0
- package/mods/tool-timeline/view/view.ts +164 -0
- package/mods/tsconfig.view.json +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/mini-game/hooks/mole.ts
|
|
2
|
+
import type { Whack } from '../types'
|
|
3
|
+
|
|
4
|
+
const SIZE = 4
|
|
5
|
+
export const LIVES = 3
|
|
6
|
+
/** How long the first mole stays up. */
|
|
7
|
+
const START_WINDOW_MS = 1400
|
|
8
|
+
/** How long a mole stays up at the fastest. */
|
|
9
|
+
const MIN_WINDOW_MS = 450
|
|
10
|
+
/** What each hit multiplies the window by, so the game speeds up. */
|
|
11
|
+
const SPEEDUP = 0.94
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a game in the `ready` state, with no mole up.
|
|
15
|
+
*
|
|
16
|
+
* @returns The new game, see `Whack`.
|
|
17
|
+
*/
|
|
18
|
+
export function newWhack(): Whack {
|
|
19
|
+
return { size: SIZE, mole: null, moleId: 0, score: 0, lives: LIVES, windowMs: START_WINDOW_MS, status: 'ready' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Puts the next mole up, in a hole other than the current one.
|
|
24
|
+
*
|
|
25
|
+
* @param game - Game to put the mole up in, see `Whack`.
|
|
26
|
+
* @returns The game with the new mole and its id.
|
|
27
|
+
*/
|
|
28
|
+
function nextMole(game: Whack): Whack {
|
|
29
|
+
const holes = game.size * game.size
|
|
30
|
+
const offset = 1 + Math.floor(Math.random() * (holes - 1))
|
|
31
|
+
const mole = game.mole === null ? Math.floor(Math.random() * holes) : (game.mole + offset) % holes
|
|
32
|
+
return { ...game, mole, moleId: game.moleId + 1 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Takes a life; the last one ends the game.
|
|
37
|
+
*
|
|
38
|
+
* @param game - Game to take the life from, see `Whack`.
|
|
39
|
+
* @returns The game after the loss.
|
|
40
|
+
*/
|
|
41
|
+
function loseLife(game: Whack): Whack {
|
|
42
|
+
const lives = game.lives - 1
|
|
43
|
+
return lives === 0 ? { ...game, lives, mole: null, status: 'over' } : { ...game, lives }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Starts the game with its first mole up.
|
|
48
|
+
*
|
|
49
|
+
* @param game - Game to start, see `Whack`.
|
|
50
|
+
* @returns The game in the `playing` state, or the same game when it is already on.
|
|
51
|
+
*/
|
|
52
|
+
export function startWhack(game: Whack): Whack {
|
|
53
|
+
return game.status === 'playing' ? game : nextMole({ ...newWhack(), status: 'playing' })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whacks one hole: the mole scores and speeds the game up, an empty hole costs a life.
|
|
58
|
+
*
|
|
59
|
+
* @param game - Game to whack in, see `Whack`.
|
|
60
|
+
* @param hole - Hole number, counted row by row from `0`.
|
|
61
|
+
* @returns The game after the whack, or the same game when it is not on.
|
|
62
|
+
*/
|
|
63
|
+
export function whack(game: Whack, hole: number): Whack {
|
|
64
|
+
if (game.status !== 'playing') return game
|
|
65
|
+
if (hole !== game.mole) return loseLife(game)
|
|
66
|
+
const windowMs = Math.max(MIN_WINDOW_MS, Math.round(game.windowMs * SPEEDUP))
|
|
67
|
+
return nextMole({ ...game, score: game.score + 1, windowMs })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lets a mole escape when its time runs out, which costs a life.
|
|
72
|
+
*
|
|
73
|
+
* @param game - Game the mole was up in, see `Whack`.
|
|
74
|
+
* @param moleId - Mole whose time ran out; a mole already whacked or gone is ignored.
|
|
75
|
+
* @returns The game after the escape, or the same game when that mole is no longer up.
|
|
76
|
+
*/
|
|
77
|
+
export function escape(game: Whack, moleId: number): Whack {
|
|
78
|
+
if (game.status !== 'playing' || game.moleId !== moleId) return game
|
|
79
|
+
const after = loseLife(game)
|
|
80
|
+
return after.status === 'over' ? after : nextMole(after)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ends a round early, keeping its score.
|
|
85
|
+
*
|
|
86
|
+
* @param game - Game to end, see `Whack`.
|
|
87
|
+
* @returns The game in the `over` state, or the same game when it is not on.
|
|
88
|
+
*/
|
|
89
|
+
export function quit(game: Whack): Whack {
|
|
90
|
+
return game.status === 'playing' ? { ...game, mole: null, status: 'over' } : game
|
|
91
|
+
}
|
|
@@ -1,123 +1,69 @@
|
|
|
1
1
|
// packages/g3n-mods/mods/mini-game/hooks/register.tsx
|
|
2
2
|
import { atom, read, update } from 'claude-code'
|
|
3
|
-
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register, RenderChildren } from 'claude-code'
|
|
4
4
|
|
|
5
|
-
import type { Game } from '../types'
|
|
5
|
+
import type { Game, View, Whack } from '../types'
|
|
6
|
+
import { adjacentMines, newGame, reveal, toggleFlag } from './minesweeper'
|
|
7
|
+
import { LIVES, escape, newWhack, quit, startWhack, whack as whackHole } from './mole'
|
|
6
8
|
|
|
7
9
|
const PANE = 'mini-game'
|
|
8
|
-
const
|
|
10
|
+
const TITLES: Record<View, string> = { mines: 'Minesweeper', mole: 'Whack-a-mole' }
|
|
11
|
+
/** What `/mini-game` takes after its name, each mapped to the game it opens. */
|
|
12
|
+
const VIEW_ARGS: Record<string, View> = { mines: 'mines', minesweeper: 'mines', mole: 'mole', whack: 'mole' }
|
|
13
|
+
|
|
14
|
+
const view = atom({ plugin: 'mini-game', key: 'view' } as const, 'mines')
|
|
9
15
|
const game = atom({ plugin: 'mini-game', key: 'game' } as const, null)
|
|
10
16
|
const best = atom({ plugin: 'mini-game', key: 'best' } as const, null)
|
|
17
|
+
const whack = atom({ plugin: 'mini-game', key: 'whack' } as const, null)
|
|
18
|
+
const whackBest = atom({ plugin: 'mini-game', key: 'whackBest' } as const, null)
|
|
11
19
|
|
|
12
|
-
const SIZE = 9
|
|
13
|
-
const MINES = 10
|
|
14
20
|
const BEST_KEY = 'best-seconds'
|
|
21
|
+
const WHACK_BEST_KEY = 'whack-best'
|
|
15
22
|
/** How long a turn runs before the mod suggests a game, once per session. */
|
|
16
23
|
const HINT_AFTER_MS = 60_000
|
|
17
|
-
/** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`,
|
|
24
|
+
/** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`, `🐹`) on any surface. */
|
|
18
25
|
const CELL_WIDTH = 2
|
|
19
26
|
|
|
20
27
|
const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* @param cell - Cell number, counted row by row from `0`.
|
|
26
|
-
* @param size - Board width and height in cells.
|
|
27
|
-
* @returns The neighboring cell numbers, from one to eight of them.
|
|
28
|
-
*/
|
|
29
|
-
export function neighbors(cell: number, size: number): number[] {
|
|
30
|
-
const row = Math.floor(cell / size)
|
|
31
|
-
const col = cell % size
|
|
32
|
-
const found: number[] = []
|
|
33
|
-
for (let dr = -1; dr <= 1; dr++) {
|
|
34
|
-
for (let dc = -1; dc <= 1; dc++) {
|
|
35
|
-
const r = row + dr
|
|
36
|
-
const c = col + dc
|
|
37
|
-
if ((dr !== 0 || dc !== 0) && r >= 0 && r < size && c >= 0 && c < size) found.push(r * size + c)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return found
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Counts the mines in the cells around `cell`.
|
|
45
|
-
*
|
|
46
|
-
* @param board - Board to read, see `Game`.
|
|
47
|
-
* @param cell - Cell number to count around.
|
|
48
|
-
* @returns Number of neighboring mines, from `0` to `8`.
|
|
49
|
-
*/
|
|
50
|
-
function adjacentMines(board: Game, cell: number): number {
|
|
51
|
-
return neighbors(cell, board.size).filter(n => board.mines.includes(n)).length
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Picks random cells for the mines, leaving `safe` and the cells around it free.
|
|
56
|
-
*
|
|
57
|
-
* @param board - Board to place mines on, see `Game`.
|
|
58
|
-
* @param safe - Cell the first reveal hit, which must not be a mine.
|
|
59
|
-
* @returns Mine cell numbers, `board.mineCount` of them.
|
|
60
|
-
*/
|
|
61
|
-
function placeMines(board: Game, safe: number): number[] {
|
|
62
|
-
const banned = new Set([safe, ...neighbors(safe, board.size)])
|
|
63
|
-
const free = Array.from({ length: board.size * board.size }, (_, i) => i).filter(i => !banned.has(i))
|
|
64
|
-
for (let i = free.length - 1; i > 0; i--) {
|
|
65
|
-
const j = Math.floor(Math.random() * (i + 1))
|
|
66
|
-
;[free[i], free[j]] = [free[j] ?? 0, free[i] ?? 0]
|
|
67
|
-
}
|
|
68
|
-
return free.slice(0, board.mineCount)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @brief Reveals one cell: the first reveal places the mines, a zero opens its
|
|
73
|
-
* neighbours, a mine ends the game, and the last safe cell wins it.
|
|
30
|
+
* Shows the pane, titled after the game it holds.
|
|
74
31
|
*
|
|
75
|
-
* @param
|
|
76
|
-
* @param cell - Cell number to reveal.
|
|
77
|
-
* @param now - Milliseconds since the epoch, stamped as the start or end time.
|
|
78
|
-
* @returns The board after the reveal, or the same board when the cell cannot be revealed.
|
|
32
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
79
33
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (board.revealed.includes(cell) || board.flagged.includes(cell)) return board
|
|
83
|
-
|
|
84
|
-
const mines = board.status === 'ready' ? placeMines(board, cell) : board.mines
|
|
85
|
-
const started: Game = board.status === 'ready' ? { ...board, mines, status: 'playing', startedAt: now } : board
|
|
86
|
-
if (mines.includes(cell)) return { ...started, revealed: [...started.revealed, cell], status: 'lost', endedAt: now }
|
|
87
|
-
|
|
88
|
-
const open = new Set(started.revealed)
|
|
89
|
-
const queue = [cell]
|
|
90
|
-
while (queue.length > 0) {
|
|
91
|
-
const next = queue.pop() ?? cell
|
|
92
|
-
if (open.has(next) || started.flagged.includes(next)) continue
|
|
93
|
-
open.add(next)
|
|
94
|
-
if (adjacentMines(started, next) === 0) queue.push(...neighbors(next, started.size).filter(n => !open.has(n)))
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const revealed = [...open]
|
|
98
|
-
const isWon = revealed.length === started.size * started.size - started.mineCount
|
|
99
|
-
return isWon ? { ...started, revealed, status: 'won', endedAt: now } : { ...started, revealed }
|
|
34
|
+
async function openPane($: EngineInterface): Promise<void> {
|
|
35
|
+
await $.ui.open({ id: PANE, title: TITLES[await read($, view)] })
|
|
100
36
|
}
|
|
101
37
|
|
|
102
38
|
/**
|
|
103
|
-
*
|
|
39
|
+
* Starts a new Minesweeper board and shows the pane.
|
|
104
40
|
*
|
|
105
|
-
* @param
|
|
106
|
-
* @returns The new board, see `Game`.
|
|
41
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
107
42
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
43
|
+
async function startGame($: EngineInterface): Promise<void> {
|
|
44
|
+
const now = await $.clock.now()
|
|
45
|
+
await update($, game, () => newGame(now))
|
|
46
|
+
await openPane($)
|
|
110
47
|
}
|
|
111
48
|
|
|
112
49
|
/**
|
|
113
|
-
*
|
|
50
|
+
* Switches the pane to `next`, setting up its game when there is none to play.
|
|
51
|
+
* Leaving whack-a-mole mid-round ends the round, so moles do not escape unseen.
|
|
114
52
|
*
|
|
115
53
|
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
54
|
+
* @param next - Game to show.
|
|
116
55
|
*/
|
|
117
|
-
async function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
56
|
+
async function showView($: EngineInterface, next: View): Promise<void> {
|
|
57
|
+
await update($, view, () => next)
|
|
58
|
+
if (next === 'mole') {
|
|
59
|
+
await update($, whack, current => current ?? newWhack())
|
|
60
|
+
await openPane($)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
await changeWhack($, quit)
|
|
64
|
+
const current = await read($, game)
|
|
65
|
+
if (current === null || current.status === 'won' || current.status === 'lost') await startGame($)
|
|
66
|
+
else await openPane($)
|
|
121
67
|
}
|
|
122
68
|
|
|
123
69
|
/**
|
|
@@ -130,10 +76,7 @@ async function pressCell($: EngineInterface, cell: number): Promise<void> {
|
|
|
130
76
|
const now = await $.clock.now()
|
|
131
77
|
const after = await update($, game, board => {
|
|
132
78
|
if (board === null) return board
|
|
133
|
-
|
|
134
|
-
if (board.revealed.includes(cell) || board.status === 'won' || board.status === 'lost') return board
|
|
135
|
-
const flagged = board.flagged.includes(cell) ? board.flagged.filter(c => c !== cell) : [...board.flagged, cell]
|
|
136
|
-
return { ...board, flagged }
|
|
79
|
+
return board.isFlagMode ? toggleFlag(board, cell) : reveal(board, cell, now)
|
|
137
80
|
})
|
|
138
81
|
if (after?.status === 'won' && after.endedAt === now) {
|
|
139
82
|
const seconds = Math.round((now - after.startedAt) / 1000)
|
|
@@ -156,12 +99,46 @@ async function toggleFlagMode($: EngineInterface): Promise<void> {
|
|
|
156
99
|
}
|
|
157
100
|
|
|
158
101
|
/**
|
|
159
|
-
*
|
|
102
|
+
* Applies `change` to the whack-a-mole game, times each new mole's escape, and
|
|
103
|
+
* records the score when the round ends.
|
|
104
|
+
*
|
|
105
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
106
|
+
* @param change - Pure step from `mole.ts` to apply.
|
|
107
|
+
*/
|
|
108
|
+
async function changeWhack($: EngineInterface, change: (current: Whack) => Whack): Promise<void> {
|
|
109
|
+
let wasPlaying = false
|
|
110
|
+
let moleIdBefore = 0
|
|
111
|
+
const after = await update($, whack, current => {
|
|
112
|
+
if (current === null) return current
|
|
113
|
+
wasPlaying = current.status === 'playing'
|
|
114
|
+
moleIdBefore = current.moleId
|
|
115
|
+
return change(current)
|
|
116
|
+
})
|
|
117
|
+
if (after === null) return
|
|
118
|
+
|
|
119
|
+
if (after.status === 'playing' && after.moleId !== moleIdBefore) {
|
|
120
|
+
const moleId = after.moleId
|
|
121
|
+
$.clock.after(after.windowMs, () => void changeWhack($, current => escape(current, moleId)))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (wasPlaying && after.status === 'over') {
|
|
125
|
+
const previous = await read($, whackBest)
|
|
126
|
+
const isBest = after.score > 0 && (previous === null || after.score > previous)
|
|
127
|
+
if (isBest) {
|
|
128
|
+
await update($, whackBest, () => after.score)
|
|
129
|
+
await $.store.set(WHACK_BEST_KEY, after.score)
|
|
130
|
+
}
|
|
131
|
+
$.ui.toast(`Game over: ${after.score} whacked${isBest ? ', a new high score' : ''}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Redraws the timer once a second while a Minesweeper game is on.
|
|
160
137
|
*
|
|
161
138
|
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
162
139
|
*/
|
|
163
140
|
async function tick($: EngineInterface): Promise<void> {
|
|
164
|
-
if ((await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
|
|
141
|
+
if ((await read($, view)) === 'mines' && (await read($, game))?.status === 'playing') $.ui.invalidate('ui.render')
|
|
165
142
|
}
|
|
166
143
|
|
|
167
144
|
let hintTurn: string | undefined
|
|
@@ -177,7 +154,7 @@ async function hint($: EngineInterface, turnId: string): Promise<void> {
|
|
|
177
154
|
if (isHinted || hintTurn !== turnId) return
|
|
178
155
|
if ((await $.ui.panes()).some(pane => pane.id === PANE)) return
|
|
179
156
|
isHinted = true
|
|
180
|
-
$.ui.toast('Waiting on Claude? /mini-game opens Minesweeper.')
|
|
157
|
+
$.ui.toast('Waiting on Claude? /mini-game opens Minesweeper, /mini-game mole opens whack-a-mole.')
|
|
181
158
|
}
|
|
182
159
|
|
|
183
160
|
/**
|
|
@@ -187,19 +164,26 @@ async function hint($: EngineInterface, turnId: string): Promise<void> {
|
|
|
187
164
|
*/
|
|
188
165
|
export const register: Register = on => {
|
|
189
166
|
on('session.start', async ($, e, next) => {
|
|
190
|
-
await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
|
|
191
|
-
const
|
|
192
|
-
if (typeof
|
|
193
|
-
|
|
167
|
+
await $.command.register({ name: 'mini-game', description: 'Play Minesweeper or whack-a-mole while Claude works' })
|
|
168
|
+
const storedBest = await $.store.get(BEST_KEY)
|
|
169
|
+
if (typeof storedBest === 'number') await update($, best, () => storedBest)
|
|
170
|
+
const storedWhackBest = await $.store.get(WHACK_BEST_KEY)
|
|
171
|
+
if (typeof storedWhackBest === 'number') await update($, whackBest, () => storedWhackBest)
|
|
172
|
+
void openPane($)
|
|
194
173
|
$.clock.every(1000, () => void tick($))
|
|
195
174
|
return next(e)
|
|
196
175
|
})
|
|
197
176
|
|
|
198
|
-
on('command.run', { command: 'mini-game' }, async
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
177
|
+
on('command.run', { command: 'mini-game' }, async ($, e) => {
|
|
178
|
+
const arg = e.args.trim().toLowerCase()
|
|
179
|
+
const next = arg === '' ? await read($, view) : VIEW_ARGS[arg]
|
|
180
|
+
if (next === undefined) return { text: `Unknown game "${arg}". Use /mini-game mines or /mini-game mole.`, exitCode: 1 }
|
|
181
|
+
await showView($, next)
|
|
182
|
+
return {
|
|
183
|
+
text: next === 'mines'
|
|
184
|
+
? 'Minesweeper is open. Clear the board without hitting a mine.'
|
|
185
|
+
: 'Whack-a-mole is open. Hit the mole before it hides; three misses and the round ends.',
|
|
186
|
+
}
|
|
203
187
|
})
|
|
204
188
|
|
|
205
189
|
on('turn.start', async ($, e, next) => {
|
|
@@ -216,66 +200,126 @@ export const register: Register = on => {
|
|
|
216
200
|
|
|
217
201
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
218
202
|
const { Box, Text, Button } = $.ui.resolve(e)
|
|
219
|
-
const
|
|
220
|
-
if (board === null) {
|
|
221
|
-
return <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} />
|
|
222
|
-
}
|
|
203
|
+
const current = await read($, view)
|
|
223
204
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
205
|
+
const tabs = (
|
|
206
|
+
<Box flexDirection="row" columnGap={1}>
|
|
207
|
+
{(Object.keys(TITLES) as View[]).map(v => (
|
|
208
|
+
<Button key={`view:${v}`} label={TITLES[v]} variant={v === current ? 'primary' : 'secondary'} onPress={() => showView($, v)} />
|
|
209
|
+
))}
|
|
210
|
+
</Box>
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// Every cell sits in a box of one fixed width, so changing a cell never moves the others: glyphs differ in width.
|
|
214
|
+
const grid = (size: number, face: (cell: number) => RenderChildren) => (
|
|
215
|
+
<Box flexDirection="column">
|
|
216
|
+
{Array.from({ length: size }, (_, r) => (
|
|
217
|
+
<Box flexDirection="row" columnGap={1}>
|
|
218
|
+
{Array.from({ length: size }, (_, c) => (
|
|
219
|
+
<Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{face(r * size + c)}</Box>
|
|
220
|
+
))}
|
|
221
|
+
</Box>
|
|
222
|
+
))}
|
|
223
|
+
</Box>
|
|
224
|
+
)
|
|
228
225
|
|
|
229
226
|
/**
|
|
230
|
-
* Draws
|
|
227
|
+
* Draws whack-a-mole: lives, score and high score, and the holes, each a button while a round is on.
|
|
231
228
|
*
|
|
232
|
-
* @param
|
|
233
|
-
* @returns The
|
|
229
|
+
* @param round - Game to draw, see `Whack`.
|
|
230
|
+
* @returns The status line, the controls and the holes.
|
|
234
231
|
*/
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return
|
|
232
|
+
const moleView = async (round: Whack) => {
|
|
233
|
+
const record = await read($, whackBest)
|
|
234
|
+
const isPlaying = round.status === 'playing'
|
|
235
|
+
|
|
236
|
+
const holeFace = (hole: number) => {
|
|
237
|
+
if (!isPlaying) return <Text dimColor>○</Text>
|
|
238
|
+
return (
|
|
239
|
+
<Button key={`hole:${hole}`} plain onPress={() => changeWhack($, current => whackHole(current, hole))}>
|
|
240
|
+
{hole === round.mole ? <Text>🐹</Text> : <Text color="#6b7280">○</Text>}
|
|
241
|
+
</Button>
|
|
242
|
+
)
|
|
242
243
|
}
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
|
|
245
|
+
const hearts = '♥'.repeat(round.lives) + '♡'.repeat(LIVES - round.lives)
|
|
246
|
+
const recordText = record === null ? '' : ` · best ${record}`
|
|
247
|
+
const status = round.status === 'over'
|
|
248
|
+
? <Text color="#e06c6c" bold>Game over. {round.score} whacked{recordText}</Text>
|
|
249
|
+
: round.status === 'ready'
|
|
250
|
+
? <Text dimColor>Hit the mole before it hides{recordText}</Text>
|
|
251
|
+
: <Text><Text color="#e06c6c">{hearts}</Text><Text dimColor> · {round.score} whacked{recordText}</Text></Text>
|
|
252
|
+
|
|
245
253
|
return (
|
|
246
|
-
<
|
|
247
|
-
<
|
|
248
|
-
|
|
254
|
+
<Box flexDirection="column" gap={1}>
|
|
255
|
+
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
|
|
256
|
+
{status}
|
|
257
|
+
{isPlaying ? '' : <Button key="play" label={round.status === 'over' ? 'play again' : 'start'} variant="primary" onPress={() => changeWhack($, startWhack)} />}
|
|
258
|
+
</Box>
|
|
259
|
+
{grid(round.size, holeFace)}
|
|
260
|
+
</Box>
|
|
249
261
|
)
|
|
250
262
|
}
|
|
251
263
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Draws the Minesweeper board: numbers, flags, mines, and buttons for hidden cells.
|
|
266
|
+
*
|
|
267
|
+
* @param board - Board to draw, see `Game`.
|
|
268
|
+
* @returns The status line, the controls and the board.
|
|
269
|
+
*/
|
|
270
|
+
const minesView = async (board: Game) => {
|
|
271
|
+
const now = await $.clock.now()
|
|
272
|
+
const elapsed = board.status === 'ready' ? 0 : Math.round(((board.endedAt ?? now) - board.startedAt) / 1000)
|
|
273
|
+
const record = await read($, best)
|
|
274
|
+
const isOver = board.status === 'won' || board.status === 'lost'
|
|
256
275
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
const cellFace = (cell: number) => {
|
|
277
|
+
const isRevealed = board.revealed.includes(cell)
|
|
278
|
+
const isMine = board.mines.includes(cell)
|
|
279
|
+
if (isRevealed || (isOver && isMine)) {
|
|
280
|
+
if (isMine) return <Text color="#e06c6c">✹</Text>
|
|
281
|
+
const n = adjacentMines(board, cell)
|
|
282
|
+
return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
|
|
283
|
+
}
|
|
284
|
+
const label = board.flagged.includes(cell) ? '⚑' : '■'
|
|
285
|
+
if (isOver) return <Text dimColor>{label}</Text>
|
|
286
|
+
return (
|
|
287
|
+
<Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
|
|
288
|
+
<Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
|
|
289
|
+
</Button>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
293
|
+
const status = board.status === 'won'
|
|
294
|
+
? <Text color="#7cc47f" bold>Cleared in {elapsed}s</Text>
|
|
295
|
+
: board.status === 'lost'
|
|
296
|
+
? <Text color="#e06c6c" bold>Boom. {board.revealed.length} cells cleared</Text>
|
|
297
|
+
: <Text dimColor>💣 {board.mineCount - board.flagged.length} · ⏱ {elapsed}s{record === null ? '' : ` · best ${record}s`}</Text>
|
|
268
298
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
299
|
+
return (
|
|
300
|
+
<Box flexDirection="column" gap={1}>
|
|
301
|
+
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
|
|
302
|
+
{status}
|
|
303
|
+
<Box flexDirection="row" columnGap={1}>
|
|
304
|
+
{isOver ? '' : <Button key="flag" label={board.isFlagMode ? '⚑ flagging' : '⚑ flag'} variant={board.isFlagMode ? 'primary' : 'secondary'} onPress={() => toggleFlagMode($)} />}
|
|
305
|
+
<Button key="new" label="new game" onPress={() => startGame($)} />
|
|
306
|
+
</Box>
|
|
276
307
|
</Box>
|
|
308
|
+
{grid(board.size, cellFace)}
|
|
277
309
|
</Box>
|
|
278
|
-
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const board = await read($, game)
|
|
314
|
+
const round = await read($, whack)
|
|
315
|
+
const body = current === 'mole'
|
|
316
|
+
? await moleView(round ?? newWhack())
|
|
317
|
+
: (board === null ? <Button key="new" label="new game" variant="primary" onPress={() => startGame($)} /> : await minesView(board))
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Box flexDirection="column" gap={1}>
|
|
321
|
+
{tabs}
|
|
322
|
+
{body}
|
|
279
323
|
</Box>
|
|
280
324
|
)
|
|
281
325
|
})
|
|
@@ -14,8 +14,25 @@ export type Game = {
|
|
|
14
14
|
endedAt?: number
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** A whack-a-mole game; holes are numbered row by row from 0. */
|
|
18
|
+
export type Whack = {
|
|
19
|
+
size: number
|
|
20
|
+
/** Hole the mole is up in; `null` before the start and after the end */
|
|
21
|
+
mole: number | null
|
|
22
|
+
/** Counts the moles put up, so a timer knows whether its mole is still the one up */
|
|
23
|
+
moleId: number
|
|
24
|
+
score: number
|
|
25
|
+
lives: number
|
|
26
|
+
/** How long the current mole stays up; shrinks with each hit */
|
|
27
|
+
windowMs: number
|
|
28
|
+
status: 'ready' | 'playing' | 'over'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The game the pane shows. */
|
|
32
|
+
export type View = 'mines' | 'mole'
|
|
33
|
+
|
|
17
34
|
declare module 'claude-code' {
|
|
18
35
|
interface PluginState {
|
|
19
|
-
'mini-game': { game: Game | null; best: number | null }
|
|
36
|
+
'mini-game': { view: View; game: Game | null; best: number | null; whack: Whack | null; whackBest: number | null }
|
|
20
37
|
}
|
|
21
38
|
}
|