@omercnet/paseo-queens 0.1.0-next.126.2

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.
@@ -0,0 +1,100 @@
1
+ import type { Puzzle } from "./types";
2
+
3
+ export const PUZZLES = [
4
+ {
5
+ id: "paseo-queens-01",
6
+ size: 6,
7
+ regions: [
8
+ 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 2, 1, 1, 1, 3, 0, 0, 4, 4, 4, 3, 0, 4, 4, 4, 4, 5, 0,
9
+ 0, 4, 5, 5, 5,
10
+ ],
11
+ },
12
+ {
13
+ id: "paseo-queens-02",
14
+ size: 6,
15
+ regions: [
16
+ 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 2, 3, 5, 5, 1, 1, 2, 3, 5, 4, 4, 1, 2, 5,
17
+ 5, 5, 5, 1, 2,
18
+ ],
19
+ },
20
+ {
21
+ id: "paseo-queens-03",
22
+ size: 6,
23
+ regions: [
24
+ 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 2, 0, 4, 4, 1, 1, 2, 2, 3, 4, 4, 4, 2, 2, 3, 3, 4, 4, 3,
25
+ 3, 3, 5, 4, 4,
26
+ ],
27
+ },
28
+ {
29
+ id: "paseo-queens-04",
30
+ size: 6,
31
+ regions: [
32
+ 1, 1, 3, 3, 0, 2, 1, 1, 3, 3, 0, 2, 4, 1, 4, 3, 2, 2, 4, 4, 4, 3, 3, 3, 4, 4, 4, 4, 5, 3, 5,
33
+ 5, 5, 5, 5, 5,
34
+ ],
35
+ },
36
+ {
37
+ id: "paseo-queens-05",
38
+ size: 6,
39
+ regions: [
40
+ 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 4, 4, 1, 3, 3, 2, 2, 4, 1, 3, 3, 4, 2, 4, 5, 3, 5, 4, 4, 4, 5,
41
+ 5, 5, 5, 4, 4,
42
+ ],
43
+ },
44
+ {
45
+ id: "paseo-queens-06",
46
+ size: 6,
47
+ regions: [
48
+ 0, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1, 2, 2, 3, 3, 3, 1, 5, 5, 3, 3, 3, 4, 5, 5, 5, 3, 4, 4, 5,
49
+ 5, 5, 5, 5, 5,
50
+ ],
51
+ },
52
+ {
53
+ id: "paseo-queens-07",
54
+ size: 6,
55
+ regions: [
56
+ 2, 0, 0, 0, 0, 0, 2, 2, 1, 1, 0, 0, 2, 2, 2, 1, 4, 4, 2, 2, 2, 3, 4, 4, 2, 2, 5, 4, 4, 4, 5,
57
+ 5, 5, 5, 4, 4,
58
+ ],
59
+ },
60
+ {
61
+ id: "paseo-queens-08",
62
+ size: 6,
63
+ regions: [
64
+ 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 3, 3, 2, 0, 4, 3, 3, 5, 2, 2, 4, 3, 3, 5, 2, 2, 4,
65
+ 3, 3, 5, 5, 2,
66
+ ],
67
+ },
68
+ {
69
+ id: "paseo-queens-09",
70
+ size: 6,
71
+ regions: [
72
+ 1, 1, 1, 2, 2, 0, 4, 1, 1, 2, 2, 2, 4, 1, 3, 2, 2, 2, 4, 3, 3, 3, 3, 3, 4, 3, 4, 3, 3, 3, 4,
73
+ 4, 4, 5, 5, 5,
74
+ ],
75
+ },
76
+ {
77
+ id: "paseo-queens-10",
78
+ size: 6,
79
+ regions: [
80
+ 0, 0, 0, 1, 1, 1, 0, 2, 2, 1, 1, 1, 0, 2, 2, 1, 1, 1, 3, 2, 2, 5, 5, 1, 3, 3, 2, 4, 5, 5, 3,
81
+ 3, 3, 5, 5, 5,
82
+ ],
83
+ },
84
+ {
85
+ id: "paseo-queens-11",
86
+ size: 6,
87
+ regions: [
88
+ 2, 2, 1, 1, 0, 0, 2, 2, 1, 1, 1, 0, 2, 2, 1, 1, 1, 0, 4, 4, 1, 3, 3, 0, 4, 4, 4, 3, 3, 3, 4,
89
+ 3, 3, 3, 3, 5,
90
+ ],
91
+ },
92
+ {
93
+ id: "paseo-queens-12",
94
+ size: 6,
95
+ regions: [
96
+ 1, 1, 1, 2, 2, 0, 3, 3, 1, 1, 2, 2, 5, 3, 4, 4, 2, 2, 5, 3, 4, 4, 4, 4, 5, 5, 4, 4, 4, 4, 5,
97
+ 5, 4, 4, 4, 4,
98
+ ],
99
+ },
100
+ ] as const satisfies readonly Puzzle[];
@@ -0,0 +1,348 @@
1
+ import { createEmptyCells, findConflicts, isSolved, setCellStates } from "./engine";
2
+ import type { CellState, Puzzle } from "./types";
3
+
4
+ export interface TimerState {
5
+ readonly startedAt: number | null;
6
+ readonly completedAt: number | null;
7
+ readonly elapsedMs: number;
8
+ readonly lastTickAt: number | null;
9
+ }
10
+
11
+ export interface GameHistoryEntry {
12
+ readonly cells: readonly CellState[];
13
+ readonly timer: TimerState;
14
+ readonly restoresTimer: boolean;
15
+ }
16
+
17
+ export interface PuzzleProgress {
18
+ readonly cells: readonly CellState[];
19
+ readonly history: readonly GameHistoryEntry[];
20
+ readonly solved: boolean;
21
+ readonly timer: TimerState;
22
+ }
23
+
24
+ export interface GameState {
25
+ readonly puzzles: readonly Puzzle[];
26
+ readonly solutions: readonly (readonly number[])[];
27
+ readonly progress: readonly PuzzleProgress[];
28
+ readonly activePuzzleIndex: number;
29
+ }
30
+
31
+ export type PuzzleSolution = readonly number[];
32
+
33
+ export type GameAction =
34
+ | {
35
+ readonly type: "set-cells";
36
+ readonly indexes: readonly number[];
37
+ readonly state: CellState;
38
+ readonly now: number;
39
+ }
40
+ | { readonly type: "undo"; readonly now: number }
41
+ | { readonly type: "reset"; readonly now: number }
42
+ | { readonly type: "hint"; readonly now: number }
43
+ | { readonly type: "select-puzzle"; readonly index: number; readonly now: number }
44
+ | { readonly type: "tick"; readonly now: number };
45
+
46
+ const EMPTY_TIMER: TimerState = {
47
+ startedAt: null,
48
+ completedAt: null,
49
+ elapsedMs: 0,
50
+ lastTickAt: null,
51
+ };
52
+
53
+ function assertTimestamp(now: number): void {
54
+ if (!Number.isFinite(now)) throw new RangeError("Timestamp must be finite.");
55
+ }
56
+
57
+ function advanceTimer(timer: TimerState, now: number): TimerState {
58
+ assertTimestamp(now);
59
+ if (timer.lastTickAt === null) return timer;
60
+
61
+ const nextTickAt = Math.max(timer.lastTickAt, now);
62
+ if (nextTickAt === timer.lastTickAt) return timer;
63
+
64
+ return {
65
+ ...timer,
66
+ elapsedMs: timer.elapsedMs + nextTickAt - timer.lastTickAt,
67
+ lastTickAt: nextTickAt,
68
+ };
69
+ }
70
+
71
+ function startTimer(timer: TimerState, now: number): TimerState {
72
+ assertTimestamp(now);
73
+ if (timer.startedAt !== null) return advanceTimer(timer, now);
74
+
75
+ return {
76
+ startedAt: now,
77
+ completedAt: null,
78
+ elapsedMs: 0,
79
+ lastTickAt: now,
80
+ };
81
+ }
82
+
83
+ function stopTimer(timer: TimerState, now: number): TimerState {
84
+ const advanced = advanceTimer(timer, now);
85
+ if (advanced.startedAt === null) return advanced;
86
+
87
+ return {
88
+ ...advanced,
89
+ completedAt: advanced.lastTickAt ?? Math.max(advanced.startedAt, now),
90
+ lastTickAt: null,
91
+ };
92
+ }
93
+
94
+ function resumeTimer(timer: TimerState, now: number): TimerState {
95
+ assertTimestamp(now);
96
+ if (timer.startedAt === null) return timer;
97
+
98
+ const latestTimestamp = timer.completedAt ?? timer.lastTickAt ?? timer.startedAt;
99
+ return {
100
+ ...timer,
101
+ completedAt: null,
102
+ lastTickAt: Math.max(latestTimestamp, now),
103
+ };
104
+ }
105
+
106
+ function firstSolution(puzzle: Puzzle): readonly number[] {
107
+ // This also validates the puzzle before the one-time solution search.
108
+ isSolved(puzzle, createEmptyCells(puzzle.size));
109
+
110
+ const usedColumns = new Uint8Array(puzzle.size);
111
+ const usedRegions = new Uint8Array(puzzle.size);
112
+ const solution = Array<number>(puzzle.size).fill(-1);
113
+
114
+ function search(row: number, previousColumn: number): boolean {
115
+ if (row === puzzle.size) return true;
116
+
117
+ for (let column = 0; column < puzzle.size; column += 1) {
118
+ const index = row * puzzle.size + column;
119
+ const region = puzzle.regions[index];
120
+ if (
121
+ usedColumns[column] === 1 ||
122
+ usedRegions[region] === 1 ||
123
+ (row > 0 && Math.abs(column - previousColumn) <= 1)
124
+ ) {
125
+ continue;
126
+ }
127
+
128
+ usedColumns[column] = 1;
129
+ usedRegions[region] = 1;
130
+ solution[row] = index;
131
+ if (search(row + 1, column)) return true;
132
+ usedColumns[column] = 0;
133
+ usedRegions[region] = 0;
134
+ }
135
+
136
+ return false;
137
+ }
138
+
139
+ if (!search(0, -2)) throw new RangeError(`Puzzle ${puzzle.id} has no solution.`);
140
+ return solution;
141
+ }
142
+
143
+ function normalizeSolution(puzzle: Puzzle, solution: PuzzleSolution): readonly number[] {
144
+ const cells = createEmptyCells(puzzle.size);
145
+ for (const index of solution) {
146
+ if (
147
+ !Number.isInteger(index) ||
148
+ index < 0 ||
149
+ index >= cells.length ||
150
+ cells[index] === "marked"
151
+ ) {
152
+ throw new RangeError(`Puzzle ${puzzle.id} has an invalid known solution.`);
153
+ }
154
+ cells[index] = "marked";
155
+ }
156
+
157
+ if (!isSolved(puzzle, cells))
158
+ throw new RangeError(`Puzzle ${puzzle.id} has an invalid known solution.`);
159
+ return [...solution].sort((left, right) => left - right);
160
+ }
161
+
162
+ export function createGameState(
163
+ puzzles: readonly Puzzle[],
164
+ knownSolutions: Readonly<Record<string, PuzzleSolution>> = {},
165
+ ): GameState {
166
+ if (puzzles.length === 0) throw new RangeError("The puzzle bank must not be empty.");
167
+ if (new Set(puzzles.map((puzzle) => puzzle.id)).size !== puzzles.length) {
168
+ throw new RangeError("Puzzle ids must be unique.");
169
+ }
170
+
171
+ const bank = puzzles.slice();
172
+ const solutions = bank.map((puzzle) => {
173
+ const known = knownSolutions[puzzle.id];
174
+ return known === undefined ? firstSolution(puzzle) : normalizeSolution(puzzle, known);
175
+ });
176
+ const emptyCellsBySize = new Map<number, readonly CellState[]>();
177
+ const progress = bank.map((puzzle): PuzzleProgress => {
178
+ let cells = emptyCellsBySize.get(puzzle.size);
179
+ if (!cells) {
180
+ cells = createEmptyCells(puzzle.size);
181
+ emptyCellsBySize.set(puzzle.size, cells);
182
+ }
183
+ return {
184
+ cells,
185
+ history: [],
186
+ solved: false,
187
+ timer: EMPTY_TIMER,
188
+ };
189
+ });
190
+
191
+ return {
192
+ puzzles: bank,
193
+ solutions,
194
+ progress,
195
+ activePuzzleIndex: 0,
196
+ };
197
+ }
198
+
199
+ function replaceActiveProgress(state: GameState, next: PuzzleProgress): GameState {
200
+ const progress = state.progress.slice();
201
+ progress[state.activePuzzleIndex] = next;
202
+ return { ...state, progress };
203
+ }
204
+
205
+ function changeCells(
206
+ state: GameState,
207
+ cells: readonly CellState[],
208
+ now: number,
209
+ restoresTimer: boolean,
210
+ ): GameState {
211
+ const current = state.progress[state.activePuzzleIndex];
212
+ const puzzle = state.puzzles[state.activePuzzleIndex];
213
+ const advancedTimer = advanceTimer(current.timer, now);
214
+ const runningTimer = startTimer(advancedTimer, now);
215
+ const solved = isSolved(puzzle, cells);
216
+ const timer = solved ? stopTimer(runningTimer, now) : runningTimer;
217
+ const historyEntry: GameHistoryEntry = {
218
+ cells: current.cells,
219
+ timer: advancedTimer,
220
+ restoresTimer,
221
+ };
222
+
223
+ return replaceActiveProgress(state, {
224
+ cells,
225
+ history: [...current.history, historyEntry],
226
+ solved,
227
+ timer,
228
+ });
229
+ }
230
+
231
+ function reset(state: GameState, now: number): GameState {
232
+ assertTimestamp(now);
233
+ const current = state.progress[state.activePuzzleIndex];
234
+ const pristine =
235
+ current.timer.startedAt === null && current.cells.every((cell) => cell === "empty");
236
+ if (pristine) return state;
237
+
238
+ const timer = advanceTimer(current.timer, now);
239
+ const historyEntry: GameHistoryEntry = {
240
+ cells: current.cells,
241
+ timer,
242
+ restoresTimer: true,
243
+ };
244
+
245
+ return replaceActiveProgress(state, {
246
+ cells: createEmptyCells(state.puzzles[state.activePuzzleIndex].size),
247
+ history: [...current.history, historyEntry],
248
+ solved: false,
249
+ timer: EMPTY_TIMER,
250
+ });
251
+ }
252
+
253
+ function undo(state: GameState, now: number): GameState {
254
+ assertTimestamp(now);
255
+ const current = state.progress[state.activePuzzleIndex];
256
+ const historyEntry = current.history[current.history.length - 1];
257
+ if (historyEntry === undefined) return state;
258
+
259
+ const puzzle = state.puzzles[state.activePuzzleIndex];
260
+ const solved = isSolved(puzzle, historyEntry.cells);
261
+ const currentTimer = advanceTimer(current.timer, now);
262
+ const restoredTimer = historyEntry.restoresTimer ? historyEntry.timer : currentTimer;
263
+ const timer = solved ? restoredTimer : resumeTimer(restoredTimer, now);
264
+
265
+ return replaceActiveProgress(state, {
266
+ cells: historyEntry.cells,
267
+ history: current.history.slice(0, -1),
268
+ solved,
269
+ timer,
270
+ });
271
+ }
272
+
273
+ function hint(state: GameState, now: number): GameState {
274
+ assertTimestamp(now);
275
+ const current = state.progress[state.activePuzzleIndex];
276
+ if (current.solved) return state;
277
+
278
+ const puzzle = state.puzzles[state.activePuzzleIndex];
279
+ const solution = state.solutions[state.activePuzzleIndex];
280
+ const conflicts = findConflicts(puzzle, current.cells);
281
+ let target = -1;
282
+ let replacement: CellState = "empty";
283
+
284
+ for (let index = 0; index < current.cells.length; index += 1) {
285
+ if (conflicts.has(index)) {
286
+ target = index;
287
+ break;
288
+ }
289
+ }
290
+
291
+ if (target === -1) {
292
+ const solutionCells = new Set(solution);
293
+ target = current.cells.findIndex(
294
+ (cell, index) => cell === "marked" && !solutionCells.has(index),
295
+ );
296
+ }
297
+
298
+ if (target === -1) {
299
+ target = solution.find((index) => current.cells[index] !== "marked") ?? -1;
300
+ replacement = "marked";
301
+ }
302
+
303
+ if (target === -1) return state;
304
+ const cells = current.cells.slice();
305
+ cells[target] = replacement;
306
+ return changeCells(state, cells, now, false);
307
+ }
308
+
309
+ export function gameReducer(state: GameState, action: GameAction): GameState {
310
+ const current = state.progress[state.activePuzzleIndex];
311
+
312
+ switch (action.type) {
313
+ case "set-cells": {
314
+ if (current.solved) return state;
315
+ const cells = setCellStates(current.cells, action.indexes, action.state);
316
+ return cells === current.cells ? state : changeCells(state, cells, action.now, false);
317
+ }
318
+ case "undo":
319
+ return undo(state, action.now);
320
+ case "reset":
321
+ return reset(state, action.now);
322
+ case "hint":
323
+ return hint(state, action.now);
324
+ case "select-puzzle": {
325
+ if (!Number.isInteger(action.index)) throw new RangeError("Puzzle index must be an integer.");
326
+ const activePuzzleIndex = Math.min(Math.max(action.index, 0), state.puzzles.length - 1);
327
+ const currentTimer = advanceTimer(current.timer, action.now);
328
+ if (activePuzzleIndex === state.activePuzzleIndex) {
329
+ return currentTimer === current.timer
330
+ ? state
331
+ : replaceActiveProgress(state, { ...current, timer: currentTimer });
332
+ }
333
+
334
+ const progress = state.progress.slice();
335
+ if (currentTimer !== current.timer)
336
+ progress[state.activePuzzleIndex] = { ...current, timer: currentTimer };
337
+ const selected = progress[activePuzzleIndex];
338
+ const selectedTimer = advanceTimer(selected.timer, action.now);
339
+ if (selectedTimer !== selected.timer)
340
+ progress[activePuzzleIndex] = { ...selected, timer: selectedTimer };
341
+ return { ...state, progress, activePuzzleIndex };
342
+ }
343
+ case "tick": {
344
+ const timer = advanceTimer(current.timer, action.now);
345
+ return timer === current.timer ? state : replaceActiveProgress(state, { ...current, timer });
346
+ }
347
+ }
348
+ }
@@ -0,0 +1,7 @@
1
+ export type CellState = "empty" | "excluded" | "marked";
2
+
3
+ export interface Puzzle {
4
+ readonly id: string;
5
+ readonly size: number;
6
+ readonly regions: readonly number[];
7
+ }
@@ -0,0 +1,199 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useMemo } from "react";
3
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
4
+
5
+ export type GameControlsProps = {
6
+ canUndo: boolean;
7
+ canHint: boolean;
8
+ canReset: boolean;
9
+ canGoPrevious: boolean;
10
+ canGoNext: boolean;
11
+ saving: boolean;
12
+ disabled: boolean;
13
+ theme: PluginSurfaceProps["theme"];
14
+ dense?: boolean;
15
+ showNavigation?: boolean;
16
+ showSavingStatus?: boolean;
17
+ onUndo(): void;
18
+ onHint(): void;
19
+ onReset(): void;
20
+ onPrevious(): void;
21
+ onNext(): void;
22
+ };
23
+
24
+ export function GameControls({
25
+ canUndo,
26
+ canHint,
27
+ canReset,
28
+ canGoPrevious,
29
+ canGoNext,
30
+ saving,
31
+ disabled,
32
+ dense = false,
33
+ theme,
34
+ showNavigation = true,
35
+ showSavingStatus = true,
36
+ onUndo,
37
+ onHint,
38
+ onReset,
39
+ onPrevious,
40
+ onNext,
41
+ }: GameControlsProps) {
42
+ const styles = useMemo(() => createStyles(theme, dense), [dense, theme]);
43
+ const controls = [
44
+ {
45
+ key: "previous",
46
+ label: "Previous",
47
+ accessibilityLabel: "Go to previous puzzle",
48
+ available: canGoPrevious,
49
+ onPress: onPrevious,
50
+ },
51
+ {
52
+ key: "undo",
53
+ label: "Undo",
54
+ accessibilityLabel: "Undo last move",
55
+ available: canUndo,
56
+ onPress: onUndo,
57
+ },
58
+ {
59
+ key: "hint",
60
+ label: "Hint",
61
+ accessibilityLabel: "Show a hint",
62
+ available: canHint,
63
+ onPress: onHint,
64
+ },
65
+ {
66
+ key: "reset",
67
+ label: "Reset",
68
+ accessibilityLabel: "Reset puzzle",
69
+ available: canReset,
70
+ onPress: onReset,
71
+ },
72
+ {
73
+ key: "next",
74
+ label: "Next",
75
+ accessibilityLabel: "Go to next puzzle",
76
+ available: canGoNext,
77
+ onPress: onNext,
78
+ },
79
+ ] as const;
80
+
81
+ return (
82
+ <View style={styles.container}>
83
+ <View style={styles.controls}>
84
+ {controls
85
+ .filter(
86
+ (control) => showNavigation || (control.key !== "previous" && control.key !== "next"),
87
+ )
88
+ .map((control) => {
89
+ const controlDisabled = disabled || !control.available;
90
+
91
+ return (
92
+ <Pressable
93
+ key={control.key}
94
+ accessibilityRole="button"
95
+ accessibilityLabel={control.accessibilityLabel}
96
+ accessibilityState={{ disabled: controlDisabled }}
97
+ disabled={controlDisabled}
98
+ hitSlop={2}
99
+ onPress={control.onPress}
100
+ style={({ pressed }) => [
101
+ styles.control,
102
+ pressed && !controlDisabled && styles.controlPressed,
103
+ ]}
104
+ >
105
+ <Text style={[styles.controlText, controlDisabled && styles.controlTextDisabled]}>
106
+ {control.label}
107
+ </Text>
108
+ </Pressable>
109
+ );
110
+ })}
111
+ </View>
112
+ {showSavingStatus ? (
113
+ <View style={styles.savingSlot}>
114
+ {saving ? (
115
+ <View
116
+ accessibilityLiveRegion="polite"
117
+ accessibilityRole="progressbar"
118
+ accessibilityLabel="Saving game progress"
119
+ accessibilityState={{ busy: true }}
120
+ accessibilityValue={{ text: "Saving" }}
121
+ style={styles.savingStatus}
122
+ >
123
+ <ActivityIndicator size="small" color={theme.colors.accent} />
124
+ <Text style={styles.savingText}>Saving…</Text>
125
+ </View>
126
+ ) : null}
127
+ </View>
128
+ ) : null}
129
+ </View>
130
+ );
131
+ }
132
+
133
+ function createStyles(theme: PluginSurfaceProps["theme"], dense: boolean) {
134
+ return StyleSheet.create({
135
+ container: {
136
+ width: "100%",
137
+ alignItems: "center",
138
+ gap: 8,
139
+ },
140
+ controls: {
141
+ width: "100%",
142
+ flexDirection: "row",
143
+ flexWrap: "wrap",
144
+ alignItems: "stretch",
145
+ justifyContent: "center",
146
+ gap: 8,
147
+ },
148
+ control: {
149
+ minWidth: dense ? 68 : 94,
150
+ maxWidth: dense ? 96 : 132,
151
+ minHeight: 44,
152
+ flexGrow: 1,
153
+ flexBasis: dense ? 68 : 94,
154
+ flexShrink: 1,
155
+ flexDirection: "row",
156
+ alignItems: "center",
157
+ justifyContent: "center",
158
+ gap: 7,
159
+ paddingHorizontal: 12,
160
+ paddingVertical: 9,
161
+ borderWidth: StyleSheet.hairlineWidth,
162
+ borderColor: theme.colors.border,
163
+ borderRadius: 9,
164
+ backgroundColor: theme.colors.surface2,
165
+ },
166
+ controlPressed: {
167
+ opacity: 0.7,
168
+ transform: [{ scale: 0.98 }],
169
+ },
170
+ controlText: {
171
+ color: theme.colors.foreground,
172
+ fontSize: 12,
173
+ fontWeight: "600",
174
+ },
175
+ controlTextDisabled: {
176
+ color: theme.colors.foregroundMuted,
177
+ },
178
+ savingSlot: {
179
+ minHeight: 24,
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ },
183
+ savingStatus: {
184
+ minHeight: 24,
185
+ flexDirection: "row",
186
+ alignItems: "center",
187
+ justifyContent: "center",
188
+ gap: 6,
189
+ paddingHorizontal: 10,
190
+ borderRadius: 999,
191
+ backgroundColor: theme.colors.surface1,
192
+ },
193
+ savingText: {
194
+ color: theme.colors.foregroundMuted,
195
+ fontSize: 11,
196
+ fontWeight: "600",
197
+ },
198
+ });
199
+ }
@@ -0,0 +1,54 @@
1
+ import { Text, View } from "react-native";
2
+
3
+ export type GameMarkProps = {
4
+ size: number;
5
+ color: string;
6
+ conflicted?: boolean;
7
+ };
8
+
9
+ export function GameMark({ size, color, conflicted = false }: GameMarkProps) {
10
+ const visualOffset = -Math.max(1, Math.round(size * 0.1));
11
+ return (
12
+ <View
13
+ accessible={false}
14
+ importantForAccessibility="no-hide-descendants"
15
+ style={{
16
+ width: size,
17
+ height: size,
18
+ alignItems: "center",
19
+ justifyContent: "center",
20
+ }}
21
+ >
22
+ <Text
23
+ accessible={false}
24
+ importantForAccessibility="no"
25
+ style={{
26
+ width: size,
27
+ height: size,
28
+ color,
29
+ fontSize: size,
30
+ lineHeight: size,
31
+ fontWeight: "700",
32
+ includeFontPadding: false,
33
+ textAlign: "center",
34
+ textAlignVertical: "center",
35
+ transform: [{ translateY: visualOffset }],
36
+ }}
37
+ >
38
+
39
+ </Text>
40
+ {conflicted ? (
41
+ <View
42
+ style={{
43
+ position: "absolute",
44
+ width: size * 0.9,
45
+ height: Math.max(2, size * 0.08),
46
+ borderRadius: size,
47
+ backgroundColor: color,
48
+ transform: [{ translateY: visualOffset }, { rotate: "-45deg" }],
49
+ }}
50
+ />
51
+ ) : null}
52
+ </View>
53
+ );
54
+ }