@gamepark/react-game 7.5.7 → 7.5.9

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.
@@ -1,14 +1,55 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { css, keyframes } from '@emotion/react';
4
- import { useCallback, useRef, useState } from 'react';
4
+ import { useCallback, useContext, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { useGame } from '../../../hooks/useGame';
7
7
  import { usePlayerId, usePlayerIds } from '../../../hooks/usePlayerId';
8
+ import { gameContext } from '../../GameProvider/GameContext';
8
9
  const GP_PRIMARY = '#28B8CE';
9
10
  const GP_DARK = '#002448';
10
11
  const GP_SURFACE = '#0a1929';
11
12
  const GP_ACCENT = '#9fe2f7';
13
+ // ═══════════════════════════════════════
14
+ // JSON syntax highlighting (pure React)
15
+ // ═══════════════════════════════════════
16
+ const highlightJson = (json) => {
17
+ const parts = [];
18
+ // Regex to match JSON tokens
19
+ const tokenRegex = /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}[\],])/g;
20
+ let lastIndex = 0;
21
+ let match;
22
+ while ((match = tokenRegex.exec(json)) !== null) {
23
+ // Add whitespace between tokens
24
+ if (match.index > lastIndex) {
25
+ parts.push(json.slice(lastIndex, match.index));
26
+ }
27
+ const [full, key, str, bool, num, punct] = match;
28
+ if (key) {
29
+ // key: "xxx":
30
+ const colonIdx = key.lastIndexOf(':');
31
+ parts.push(_jsx("span", { css: jsonKeyCss, children: key.slice(0, colonIdx) }, `k${match.index}`));
32
+ parts.push(key.slice(colonIdx));
33
+ }
34
+ else if (str) {
35
+ parts.push(_jsx("span", { css: jsonStringCss, children: full }, `s${match.index}`));
36
+ }
37
+ else if (bool) {
38
+ parts.push(_jsx("span", { css: jsonBoolCss, children: full }, `b${match.index}`));
39
+ }
40
+ else if (num) {
41
+ parts.push(_jsx("span", { css: jsonNumCss, children: full }, `n${match.index}`));
42
+ }
43
+ else if (punct) {
44
+ parts.push(_jsx("span", { css: jsonPunctCss, children: full }, `p${match.index}`));
45
+ }
46
+ lastIndex = match.index + full.length;
47
+ }
48
+ if (lastIndex < json.length) {
49
+ parts.push(json.slice(lastIndex));
50
+ }
51
+ return parts;
52
+ };
12
53
  /**
13
54
  * A single entry in the DevToolsHub panel.
14
55
  * Use this instead of raw `<button>` when adding custom tools via `children`.
@@ -19,17 +60,47 @@ const GP_ACCENT = '#9fe2f7';
19
60
  * </DevToolsHub>
20
61
  */
21
62
  export const DevToolEntry = ({ icon, label, desc, onClick }) => (_jsxs("button", { css: devToolBtnCss, onClick: onClick, children: [_jsx("span", { css: devToolIconCss, children: icon }), _jsx("span", { css: devToolLabelCss, children: label }), desc && _jsx("span", { css: devToolDescCss, children: desc })] }));
63
+ // ═══════════════════════════════════════
64
+ // Save/Load helpers
65
+ // ═══════════════════════════════════════
66
+ const SAVE_PREFIX = ':save:';
67
+ const getSaveKeys = (gameName) => {
68
+ const prefix = gameName + SAVE_PREFIX;
69
+ const keys = [];
70
+ for (let i = 0; i < localStorage.length; i++) {
71
+ const key = localStorage.key(i);
72
+ if (key?.startsWith(prefix))
73
+ keys.push(key);
74
+ }
75
+ return keys.sort();
76
+ };
77
+ const getSaveLabel = (key, gameName) => {
78
+ return key.slice((gameName + SAVE_PREFIX).length);
79
+ };
80
+ const menuItems = [
81
+ { id: 'game', icon: '\u2699', label: 'Game' },
82
+ { id: 'save', icon: '\u2B73', label: 'Save / Load' },
83
+ { id: 'export', icon: '\u2398', label: 'Export' },
84
+ ];
22
85
  export const DevToolsHub = ({ children, fabBottom, gameOptions }) => {
23
86
  const [isOpen, setIsOpen] = useState(false);
87
+ const [activeMenu, setActiveMenu] = useState(null);
24
88
  const [newGamePlayers, setNewGamePlayers] = useState(2);
25
89
  const [options, setOptions] = useState({});
26
90
  const [undoCount, setUndoCount] = useState(1);
27
91
  const [botActive, setBotActive] = useState(false);
28
92
  const [flash, setFlash] = useState(null);
93
+ const [saveLabel, setSaveLabel] = useState('');
94
+ const [importText, setImportText] = useState('');
95
+ const [pasteError, setPasteError] = useState(null);
96
+ const [showPasteModal, setShowPasteModal] = useState(false);
97
+ const [saveRefresh, setSaveRefresh] = useState(0);
29
98
  const flashTimeout = useRef(null);
99
+ const fileInputRef = useRef(null);
30
100
  const gameState = useGame();
31
101
  const currentPlayer = usePlayerId();
32
102
  const players = usePlayerIds();
103
+ const gameName = useContext(gameContext)?.game ?? 'unknown';
33
104
  const doFlash = useCallback((msg) => {
34
105
  if (flashTimeout.current)
35
106
  clearTimeout(flashTimeout.current);
@@ -54,30 +125,171 @@ export const DevToolsHub = ({ children, fabBottom, gameOptions }) => {
54
125
  action();
55
126
  doFlash(successMsg);
56
127
  }, [g, doFlash]);
128
+ // Save/Load handlers
129
+ const saveKeys = getSaveKeys(gameName);
130
+ void saveRefresh; // force re-read
131
+ const handleSave = useCallback(() => {
132
+ if (!gameState) {
133
+ doFlash('No game state');
134
+ return;
135
+ }
136
+ const label = saveLabel.trim() || new Date().toLocaleTimeString();
137
+ const key = gameName + SAVE_PREFIX + label;
138
+ localStorage.setItem(key, JSON.stringify(gameState));
139
+ setSaveLabel('');
140
+ setSaveRefresh(n => n + 1);
141
+ doFlash(`Saved: ${label}`);
142
+ }, [gameState, saveLabel, gameName, doFlash]);
143
+ const handleLoad = useCallback((key) => {
144
+ const raw = localStorage.getItem(key);
145
+ if (!raw) {
146
+ doFlash('Save not found');
147
+ return;
148
+ }
149
+ try {
150
+ const state = JSON.parse(raw);
151
+ if (g) {
152
+ g.new(state);
153
+ doFlash(`Loaded: ${getSaveLabel(key, gameName)}`);
154
+ }
155
+ else {
156
+ doFlash('game helper not available');
157
+ }
158
+ }
159
+ catch {
160
+ doFlash('Invalid save data');
161
+ }
162
+ }, [g, gameName, doFlash]);
163
+ const handleDownloadSave = useCallback((key) => {
164
+ const raw = localStorage.getItem(key);
165
+ if (!raw) {
166
+ doFlash('Save not found');
167
+ return;
168
+ }
169
+ const label = getSaveLabel(key, gameName);
170
+ const blob = new Blob([raw], { type: 'application/json' });
171
+ const url = URL.createObjectURL(blob);
172
+ const a = document.createElement('a');
173
+ a.href = url;
174
+ a.download = `${gameName}-${label.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
175
+ a.click();
176
+ URL.revokeObjectURL(url);
177
+ doFlash(`Downloaded: ${label}`);
178
+ }, [gameName, doFlash]);
179
+ const handleDelete = useCallback((key) => {
180
+ localStorage.removeItem(key);
181
+ setSaveRefresh(n => n + 1);
182
+ doFlash(`Deleted: ${getSaveLabel(key, gameName)}`);
183
+ }, [gameName, doFlash]);
184
+ const handleImportFile = useCallback((e) => {
185
+ const file = e.target.files?.[0];
186
+ if (!file)
187
+ return;
188
+ const reader = new FileReader();
189
+ reader.onload = () => {
190
+ try {
191
+ const state = JSON.parse(reader.result);
192
+ if (g) {
193
+ g.new(state);
194
+ doFlash(`Imported: ${file.name}`);
195
+ }
196
+ else {
197
+ doFlash('game helper not available');
198
+ }
199
+ }
200
+ catch {
201
+ doFlash('Invalid JSON file');
202
+ }
203
+ };
204
+ reader.readAsText(file);
205
+ e.target.value = '';
206
+ }, [g, doFlash]);
207
+ const handleImportPaste = useCallback(() => {
208
+ if (!importText.trim()) {
209
+ setPasteError('Paste a JSON state first');
210
+ return;
211
+ }
212
+ try {
213
+ const state = JSON.parse(importText);
214
+ if (g) {
215
+ g.new(state);
216
+ setImportText('');
217
+ setPasteError(null);
218
+ setShowPasteModal(false);
219
+ doFlash('State imported!');
220
+ }
221
+ else {
222
+ setPasteError('game helper not available');
223
+ }
224
+ }
225
+ catch {
226
+ setPasteError('Invalid JSON — check syntax');
227
+ }
228
+ }, [g, importText, doFlash]);
229
+ const handlePasteChange = useCallback((value) => {
230
+ setImportText(value);
231
+ setPasteError(null);
232
+ // Try to pretty-print if valid
233
+ try {
234
+ const parsed = JSON.parse(value);
235
+ setImportText(JSON.stringify(parsed, null, 2));
236
+ }
237
+ catch {
238
+ // Keep raw text if invalid
239
+ }
240
+ }, []);
241
+ const handleDownloadState = useCallback(() => {
242
+ if (!gameState) {
243
+ doFlash('No game state');
244
+ return;
245
+ }
246
+ const json = JSON.stringify(gameState, null, 2);
247
+ const blob = new Blob([json], { type: 'application/json' });
248
+ const url = URL.createObjectURL(blob);
249
+ const a = document.createElement('a');
250
+ a.href = url;
251
+ a.download = `${gameName}-state-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
252
+ a.click();
253
+ URL.revokeObjectURL(url);
254
+ doFlash('State downloaded');
255
+ }, [gameState, gameName, doFlash]);
256
+ const handleMenuClick = (id) => {
257
+ setActiveMenu(prev => prev === id ? null : id);
258
+ };
259
+ const handleMenuHover = (id) => {
260
+ setActiveMenu(id);
261
+ };
262
+ const allMenuItems = children
263
+ ? [...menuItems, { id: 'custom', icon: '\u2726', label: 'Custom' }]
264
+ : menuItems;
57
265
  const root = document.getElementById('root');
58
266
  if (!root)
59
267
  return null;
60
- return createPortal(_jsxs(_Fragment, { children: [_jsx("button", { css: fabCss, onClick: () => setIsOpen(o => !o), "data-open": isOpen, style: fabBottom ? { bottom: fabBottom } : undefined, children: _jsxs("svg", { css: logoCss, viewBox: "0 0 46 46", "data-open": isOpen, children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("circle", { cx: "35", cy: "11", r: "7" }), _jsx("circle", { cx: "11", cy: "35", r: "7" }), _jsx("circle", { cx: "35", cy: "35", r: "7" })] }) }), isOpen && (_jsxs(_Fragment, { children: [_jsx("div", { css: backdropCss, onClick: () => setIsOpen(false) }), _jsxs("div", { css: panelCss, style: fabBottom ? { bottom: `calc(${fabBottom} + 48px)` } : undefined, children: [_jsxs("div", { css: panelHeaderCss, children: [_jsxs("svg", { css: headerLogoCss, viewBox: "0 0 46 46", children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("circle", { cx: "35", cy: "11", r: "7" }), _jsx("circle", { cx: "11", cy: "35", r: "7" }), _jsx("circle", { cx: "35", cy: "35", r: "7" })] }), _jsx("span", { css: panelTitleCss, children: "Dev Tools" }), _jsx("span", { css: panelBadgeCss, children: "GP" })] }), _jsxs("div", { css: toolListCss, children: [_jsxs("div", { css: devToolBtnCss, style: { animationDelay: '0ms' }, children: [_jsx("span", { css: devToolIconCss, children: '\u21BB' }), _jsx("span", { css: devToolLabelCss, children: "New Game" }), _jsx("span", { css: devToolDescCss, children: "Reset with N players" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [_jsx("button", { css: stepBtnCss, onClick: () => setNewGamePlayers(c => Math.max(1, c - 1)), children: "-" }), _jsx("input", { type: "number", min: 1, max: 10, value: newGamePlayers, onChange: e => setNewGamePlayers(Math.max(1, parseInt(e.target.value) || 2)), css: numberInputCss }), _jsx("button", { css: stepBtnCss, onClick: () => setNewGamePlayers(c => Math.min(10, c + 1)), children: "+" }), _jsx("button", { css: goBtnCss, onClick: () => exec(() => {
61
- const hasOptions = gameOptions?.length && Object.values(options).some(Boolean);
62
- g.new(hasOptions ? { players: newGamePlayers, ...options } : newGamePlayers);
63
- }, `New game ${newGamePlayers}p`), children: "Go" })] }), gameOptions?.map(opt => (_jsxs("label", { css: toggleRowCss, onClick: e => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", checked: options[opt.key] ?? false, onChange: e => setOptions(prev => ({ ...prev, [opt.key]: e.target.checked })), css: checkboxCss }), _jsx("span", { css: toggleLabelCss, children: opt.label })] }, opt.key)))] }), _jsxs("div", { css: devToolBtnCss, style: { animationDelay: '40ms' }, children: [_jsx("span", { css: devToolIconCss, children: '\u238C' }), _jsx("span", { css: devToolLabelCss, children: "Undo" }), _jsx("span", { css: devToolDescCss, children: "Revert N moves" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [_jsx("button", { css: stepBtnCss, onClick: () => setUndoCount(c => Math.max(1, c - 1)), children: "-" }), _jsx("input", { type: "number", min: 1, max: 999, value: undoCount, onChange: e => setUndoCount(Math.max(1, parseInt(e.target.value) || 1)), css: numberInputCss }), _jsx("button", { css: stepBtnCss, onClick: () => setUndoCount(c => c + 1), children: "+" }), _jsx("button", { css: goBtnCss, onClick: () => exec(() => g.undo(undoCount), `Undo ${undoCount} move${undoCount > 1 ? 's' : ''}`), children: "Go" })] })] }), _jsxs("div", { css: devToolBtnCss, style: { animationDelay: '80ms' }, children: [_jsx("span", { css: devToolIconCss, children: '\u2194' }), _jsx("span", { css: devToolLabelCss, children: "Switch Player" }), _jsx("span", { css: devToolDescCss, children: "View as another player" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [players.map(pid => (_jsxs("button", { css: [playerBtnCss, pid === currentPlayer && playerBtnActiveCss], onClick: () => exec(() => g.changePlayer(pid), `Switched to P${pid}`), children: ["P", String(pid)] }, String(pid)))), _jsx("button", { css: [playerBtnCss, currentPlayer === undefined && playerBtnActiveCss], onClick: () => exec(() => g.changePlayer(), 'Spectator mode'), children: "Spect" })] })] }), _jsxs("button", { css: [devToolBtnCss, botActive && toolBtnActiveCss], style: { animationDelay: '120ms' }, onClick: () => {
64
- const next = !botActive;
65
- exec(() => g.bot(next), next ? 'Bots enabled' : 'Bots disabled');
66
- setBotActive(next);
67
- }, children: [_jsx("span", { css: devToolIconCss, children: '\u2699' }), _jsx("span", { css: devToolLabelCss, children: botActive ? 'Disable Bots' : 'Enable Bots' }), _jsx("span", { css: devToolDescCss, children: botActive ? 'Stop auto-play' : 'Auto-play all moves' }), botActive && _jsx("span", { css: activeIndicatorCss })] }), _jsxs("button", { css: devToolBtnCss, style: { animationDelay: '160ms' }, onClick: () => exec(() => g.tutorial(), 'Tutorial started'), children: [_jsx("span", { css: devToolIconCss, children: "?" }), _jsx("span", { css: devToolLabelCss, children: "Tutorial" }), _jsx("span", { css: devToolDescCss, children: "Start tutorial mode" })] }), children && (_jsxs(_Fragment, { children: [_jsx("div", { css: dividerCss }), children] })), _jsx("div", { css: dividerCss }), _jsxs("button", { css: devToolBtnCss, style: { animationDelay: '240ms' }, onClick: () => {
68
- if (gameState)
69
- copyToClipboard(JSON.stringify(gameState, null, 2), 'Game state');
70
- else
71
- doFlash('No game state');
72
- }, children: [_jsx("span", { css: devToolIconCss, children: '\u2398' }), _jsx("span", { css: devToolLabelCss, children: "Copy State" }), _jsx("span", { css: devToolDescCss, children: "Copy game state to clipboard" })] }), _jsxs("button", { css: devToolBtnCss, style: { animationDelay: '280ms' }, onClick: () => {
73
- const data = {};
74
- for (let i = 0; i < localStorage.length; i++) {
75
- const key = localStorage.key(i);
76
- if (key)
77
- data[key] = localStorage.getItem(key) ?? '';
78
- }
79
- copyToClipboard(JSON.stringify(data, null, 2), 'localStorage');
80
- }, children: [_jsx("span", { css: devToolIconCss, children: '\u29C9' }), _jsx("span", { css: devToolLabelCss, children: "Copy LocalStorage" }), _jsx("span", { css: devToolDescCss, children: "Copy localStorage to clipboard" })] })] }), flash && _jsx("div", { css: flashCss, children: flash }, flash)] })] }))] }), root);
268
+ return createPortal(_jsxs(_Fragment, { children: [_jsx("button", { css: fabCss, onClick: () => { setIsOpen(o => !o); if (isOpen)
269
+ setActiveMenu(null); }, "data-open": isOpen, style: fabBottom ? { bottom: fabBottom } : undefined, children: _jsxs("svg", { css: logoCss, viewBox: "0 0 46 46", "data-open": isOpen, children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("circle", { cx: "35", cy: "11", r: "7" }), _jsx("circle", { cx: "11", cy: "35", r: "7" }), _jsx("circle", { cx: "35", cy: "35", r: "7" })] }) }), isOpen && (_jsxs(_Fragment, { children: [_jsx("div", { css: backdropCss, onClick: () => { setIsOpen(false); setActiveMenu(null); } }), _jsxs("div", { css: hubContainerCss, style: fabBottom ? { bottom: `calc(${fabBottom} + 48px)` } : undefined, children: [_jsxs("div", { css: mainMenuCss, children: [_jsxs("div", { css: panelHeaderCss, children: [_jsxs("svg", { css: headerLogoCss, viewBox: "0 0 46 46", children: [_jsx("circle", { cx: "11", cy: "11", r: "7" }), _jsx("circle", { cx: "35", cy: "11", r: "7" }), _jsx("circle", { cx: "11", cy: "35", r: "7" }), _jsx("circle", { cx: "35", cy: "35", r: "7" })] }), _jsx("span", { css: panelTitleCss, children: "Dev Tools" }), _jsx("span", { css: panelBadgeCss, children: "GP" })] }), _jsx("div", { css: menuListCss, children: allMenuItems.map(item => (_jsxs("button", { css: [menuItemCss, activeMenu === item.id && menuItemActiveCss], onClick: () => handleMenuClick(item.id), onMouseEnter: () => handleMenuHover(item.id), children: [_jsx("span", { css: menuItemIconCss, children: item.icon }), _jsx("span", { css: menuItemLabelCss, children: item.label }), _jsx("span", { css: menuChevronCss, children: '\u25B8' })] }, item.id))) }), flash && _jsx("div", { css: flashCss, children: flash }, flash)] }), activeMenu && (_jsxs("div", { css: subPanelCss, children: [_jsx("div", { css: subPanelHeaderCss, children: _jsx("span", { css: subPanelTitleCss, children: allMenuItems.find(m => m.id === activeMenu)?.label }) }), _jsxs("div", { css: subPanelContentCss, children: [activeMenu === 'game' && (_jsxs(_Fragment, { children: [_jsxs("div", { css: devToolBtnCss, children: [_jsx("span", { css: devToolIconCss, children: '\u21BB' }), _jsx("span", { css: devToolLabelCss, children: "New Game" }), _jsx("span", { css: devToolDescCss, children: "Reset with N players" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [_jsx("button", { css: stepBtnCss, onClick: () => setNewGamePlayers(c => Math.max(1, c - 1)), children: "-" }), _jsx("input", { type: "number", min: 1, max: 10, value: newGamePlayers, onChange: e => setNewGamePlayers(Math.max(1, parseInt(e.target.value) || 2)), css: numberInputCss }), _jsx("button", { css: stepBtnCss, onClick: () => setNewGamePlayers(c => Math.min(10, c + 1)), children: "+" }), _jsx("button", { css: goBtnCss, onClick: () => exec(() => {
270
+ const hasOptions = gameOptions?.length && Object.values(options).some(Boolean);
271
+ g.new(hasOptions ? { players: newGamePlayers, ...options } : newGamePlayers);
272
+ }, `New game ${newGamePlayers}p`), children: "Go" })] }), gameOptions?.map(opt => (_jsxs("label", { css: toggleRowCss, onClick: e => e.stopPropagation(), children: [_jsx("input", { type: "checkbox", checked: options[opt.key] ?? false, onChange: e => setOptions(prev => ({ ...prev, [opt.key]: e.target.checked })), css: checkboxCss }), _jsx("span", { css: toggleLabelCss, children: opt.label })] }, opt.key)))] }), _jsxs("div", { css: devToolBtnCss, children: [_jsx("span", { css: devToolIconCss, children: '\u238C' }), _jsx("span", { css: devToolLabelCss, children: "Undo" }), _jsx("span", { css: devToolDescCss, children: "Revert N moves" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [_jsx("button", { css: stepBtnCss, onClick: () => setUndoCount(c => Math.max(1, c - 1)), children: "-" }), _jsx("input", { type: "number", min: 1, max: 999, value: undoCount, onChange: e => setUndoCount(Math.max(1, parseInt(e.target.value) || 1)), css: numberInputCss }), _jsx("button", { css: stepBtnCss, onClick: () => setUndoCount(c => c + 1), children: "+" }), _jsx("button", { css: goBtnCss, onClick: () => exec(() => g.undo(undoCount), `Undo ${undoCount} move${undoCount > 1 ? 's' : ''}`), children: "Go" })] })] }), _jsxs("div", { css: devToolBtnCss, children: [_jsx("span", { css: devToolIconCss, children: '\u2194' }), _jsx("span", { css: devToolLabelCss, children: "Switch Player" }), _jsx("span", { css: devToolDescCss, children: "View as another player" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [players.map(pid => (_jsxs("button", { css: [playerBtnCss, pid === currentPlayer && playerBtnActiveCss], onClick: () => exec(() => g.changePlayer(pid), `Switched to P${pid}`), children: ["P", String(pid)] }, String(pid)))), _jsx("button", { css: [playerBtnCss, currentPlayer === undefined && playerBtnActiveCss], onClick: () => exec(() => g.changePlayer(), 'Spectator mode'), children: "Spect" })] })] }), _jsxs("button", { css: [devToolBtnCss, botActive && toolBtnActiveCss], onClick: () => {
273
+ const next = !botActive;
274
+ exec(() => g.bot(next), next ? 'Bots enabled' : 'Bots disabled');
275
+ setBotActive(next);
276
+ }, children: [_jsx("span", { css: devToolIconCss, children: '\u2699' }), _jsx("span", { css: devToolLabelCss, children: botActive ? 'Disable Bots' : 'Enable Bots' }), _jsx("span", { css: devToolDescCss, children: botActive ? 'Stop auto-play' : 'Auto-play all moves' }), botActive && _jsx("span", { css: activeIndicatorCss })] }), _jsxs("button", { css: devToolBtnCss, onClick: () => exec(() => g.tutorial(), 'Tutorial started'), children: [_jsx("span", { css: devToolIconCss, children: "?" }), _jsx("span", { css: devToolLabelCss, children: "Tutorial" }), _jsx("span", { css: devToolDescCss, children: "Start tutorial mode" })] })] })), activeMenu === 'save' && (_jsxs(_Fragment, { children: [_jsxs("div", { css: devToolBtnCss, children: [_jsx("span", { css: devToolIconCss, children: '\u2B73' }), _jsx("span", { css: devToolLabelCss, children: "Save State" }), _jsx("span", { css: devToolDescCss, children: "Save current game state" }), _jsxs("div", { css: inlineRowCss, onClick: e => e.stopPropagation(), children: [_jsx("input", { type: "text", placeholder: "label (optional)", value: saveLabel, onChange: e => setSaveLabel(e.target.value), onKeyDown: e => { if (e.key === 'Enter')
277
+ handleSave(); }, css: textInputCss }), _jsx("button", { css: goBtnCss, onClick: handleSave, children: "Save" })] })] }), saveKeys.length > 0 && (_jsx("div", { css: savedListCss, children: saveKeys.map(key => (_jsxs("div", { css: savedEntryCss, children: [_jsx("span", { css: savedLabelCss, children: getSaveLabel(key, gameName) }), _jsxs("div", { css: savedActionsCss, children: [_jsx("button", { css: smallBtnCss, onClick: () => handleLoad(key), title: "Load", children: '\u25B6' }), _jsx("button", { css: smallBtnCss, onClick: () => handleDownloadSave(key), title: "Download", children: '\u2913' }), _jsx("button", { css: [smallBtnCss, smallBtnDangerCss], onClick: () => handleDelete(key), title: "Delete", children: '\u2715' })] })] }, key))) })), _jsx("div", { css: dividerCss }), _jsxs("button", { css: devToolBtnCss, onClick: () => fileInputRef.current?.click(), children: [_jsx("span", { css: devToolIconCss, children: '\u2912' }), _jsx("span", { css: devToolLabelCss, children: "Import File" }), _jsx("span", { css: devToolDescCss, children: "Load state from JSON file" })] }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".json,application/json", onChange: handleImportFile, style: { display: 'none' } }), _jsxs("button", { css: devToolBtnCss, onClick: () => { setShowPasteModal(true); setPasteError(null); }, children: [_jsx("span", { css: devToolIconCss, children: '\u2398' }), _jsx("span", { css: devToolLabelCss, children: "Paste State" }), _jsx("span", { css: devToolDescCss, children: "Open editor to paste JSON" })] })] })), activeMenu === 'export' && (_jsxs(_Fragment, { children: [_jsxs("button", { css: devToolBtnCss, onClick: handleDownloadState, children: [_jsx("span", { css: devToolIconCss, children: '\u2913' }), _jsx("span", { css: devToolLabelCss, children: "Download State" }), _jsx("span", { css: devToolDescCss, children: "Save state as .json file" })] }), _jsxs("button", { css: devToolBtnCss, onClick: () => {
278
+ const raw = localStorage.getItem(gameName);
279
+ if (raw) {
280
+ copyToClipboard(raw, `${gameName} state`);
281
+ }
282
+ else {
283
+ doFlash(`No "${gameName}" key in localStorage`);
284
+ }
285
+ }, children: [_jsx("span", { css: devToolIconCss, children: '\u29C9' }), _jsx("span", { css: devToolLabelCss, children: "Copy LocalStorage" }), _jsxs("span", { css: devToolDescCss, children: ["Copy \"", gameName, "\" key to clipboard"] })] })] })), activeMenu === 'custom' && children] })] }))] })] })), showPasteModal && (_jsxs(_Fragment, { children: [_jsx("div", { css: modalBackdropCss, onClick: () => { setShowPasteModal(false); setPasteError(null); } }), _jsxs("div", { css: modalCss, children: [_jsxs("div", { css: modalHeaderCss, children: [_jsx("span", { css: modalTitleCss, children: "Paste Game State" }), _jsx("button", { css: modalCloseBtnCss, onClick: () => { setShowPasteModal(false); setPasteError(null); }, children: '\u2715' })] }), _jsxs("div", { css: modalBodyCss, children: [_jsx("textarea", { css: modalTextareaCss, placeholder: 'Paste your JSON game state here...', value: importText, onChange: e => handlePasteChange(e.target.value), spellCheck: false }), importText.trim() && (_jsxs("div", { css: jsonPreviewCss, children: [_jsx("div", { css: jsonPreviewLabelCss, children: "Preview" }), _jsx("pre", { css: jsonPreviewCodeCss, children: (() => {
286
+ try {
287
+ return highlightJson(JSON.stringify(JSON.parse(importText), null, 2));
288
+ }
289
+ catch {
290
+ return _jsx("span", { css: jsonErrorTextCss, children: importText });
291
+ }
292
+ })() })] })), pasteError && _jsx("div", { css: pasteErrorCss, children: pasteError })] }), _jsxs("div", { css: modalFooterCss, children: [_jsx("button", { css: modalCancelBtnCss, onClick: () => { setShowPasteModal(false); setPasteError(null); }, children: "Cancel" }), _jsx("button", { css: modalLoadBtnCss, onClick: handleImportPaste, children: "Load State" })] })] })] }))] }), root);
81
293
  };
82
294
  // ═══════════════════════════════════════
83
295
  // Styles — GamePark branded
@@ -126,23 +338,26 @@ const logoCss = css `
126
338
  transform: rotate(90deg);
127
339
  }
128
340
  `;
129
- const backdropCss = css `
130
- position: fixed;
131
- inset: 0;
132
- z-index: 899;
133
- background: rgba(0, 0, 0, 0.3);
134
- backdrop-filter: blur(2px);
135
- `;
136
341
  const slideUp = keyframes `
137
342
  from { opacity: 0; transform: translateY(8px) scale(0.97); }
138
343
  to { opacity: 1; transform: translateY(0) scale(1); }
139
344
  `;
140
- const panelCss = css `
345
+ const slideRight = keyframes `
346
+ from { opacity: 0; transform: translateX(-8px) scale(0.97); }
347
+ to { opacity: 1; transform: translateX(0) scale(1); }
348
+ `;
349
+ const hubContainerCss = css `
141
350
  position: fixed;
142
351
  bottom: 64px;
143
352
  left: 16px;
144
353
  z-index: 900;
145
- width: 320px;
354
+ display: flex;
355
+ align-items: flex-end;
356
+ gap: 6px;
357
+ `;
358
+ const mainMenuCss = css `
359
+ position: relative;
360
+ width: 200px;
146
361
  background: linear-gradient(170deg, #0f2035 0%, ${GP_SURFACE} 100%);
147
362
  border: 1px solid rgba(40, 184, 206, 0.25);
148
363
  border-radius: 12px;
@@ -150,9 +365,47 @@ const panelCss = css `
150
365
  0 12px 40px rgba(0, 0, 0, 0.5),
151
366
  0 0 0 1px rgba(0, 0, 0, 0.3),
152
367
  inset 0 1px 0 rgba(159, 226, 247, 0.05);
153
- overflow: hidden;
154
368
  animation: ${slideUp} 0.2s ease-out;
155
369
  font-family: 'Mulish', sans-serif;
370
+ overflow: hidden;
371
+ `;
372
+ const subPanelCss = css `
373
+ width: 320px;
374
+ max-height: calc(100vh - 80px);
375
+ display: flex;
376
+ flex-direction: column;
377
+ background: linear-gradient(170deg, #0f2035 0%, ${GP_SURFACE} 100%);
378
+ border: 1px solid rgba(40, 184, 206, 0.25);
379
+ border-radius: 12px;
380
+ box-shadow:
381
+ 0 12px 40px rgba(0, 0, 0, 0.5),
382
+ 0 0 0 1px rgba(0, 0, 0, 0.3),
383
+ inset 0 1px 0 rgba(159, 226, 247, 0.05);
384
+ animation: ${slideRight} 0.15s ease-out;
385
+ font-family: 'Mulish', sans-serif;
386
+ overflow: hidden;
387
+ `;
388
+ const subPanelHeaderCss = css `
389
+ padding: 10px 16px;
390
+ border-bottom: 1px solid rgba(40, 184, 206, 0.15);
391
+ background: rgba(40, 184, 206, 0.04);
392
+ flex-shrink: 0;
393
+ `;
394
+ const subPanelTitleCss = css `
395
+ font-size: 12px;
396
+ font-weight: 800;
397
+ color: #5a8a98;
398
+ text-transform: uppercase;
399
+ letter-spacing: 0.1em;
400
+ `;
401
+ const subPanelContentCss = css `
402
+ display: flex;
403
+ flex-direction: column;
404
+ padding: 6px;
405
+ gap: 2px;
406
+ overflow-y: auto;
407
+ flex: 1;
408
+ min-height: 0;
156
409
  `;
157
410
  const panelHeaderCss = css `
158
411
  display: flex;
@@ -185,16 +438,71 @@ const panelBadgeCss = css `
185
438
  color: ${GP_PRIMARY};
186
439
  letter-spacing: 0.1em;
187
440
  `;
188
- const toolReveal = keyframes `
189
- from { opacity: 0; transform: translateX(-6px); }
190
- to { opacity: 1; transform: translateX(0); }
191
- `;
192
- const toolListCss = css `
441
+ const menuListCss = css `
193
442
  display: flex;
194
443
  flex-direction: column;
195
444
  padding: 6px;
196
445
  gap: 2px;
197
446
  `;
447
+ const menuItemCss = css `
448
+ position: relative;
449
+ display: flex;
450
+ align-items: center;
451
+ gap: 10px;
452
+ padding: 10px 12px;
453
+ border: none;
454
+ border-radius: 8px;
455
+ background: transparent;
456
+ cursor: pointer;
457
+ text-align: left;
458
+ transition: background 0.15s;
459
+ font-family: inherit;
460
+
461
+ &:hover {
462
+ background: rgba(40, 184, 206, 0.08);
463
+ }
464
+ `;
465
+ const menuItemActiveCss = css `
466
+ background: rgba(40, 184, 206, 0.12);
467
+
468
+ &::before {
469
+ content: '';
470
+ position: absolute;
471
+ left: 0;
472
+ top: 50%;
473
+ transform: translateY(-50%);
474
+ width: 3px;
475
+ height: 18px;
476
+ border-radius: 0 3px 3px 0;
477
+ background: ${GP_PRIMARY};
478
+ }
479
+ `;
480
+ const menuItemIconCss = css `
481
+ font-size: 15px;
482
+ color: ${GP_PRIMARY};
483
+ display: flex;
484
+ align-items: center;
485
+ justify-content: center;
486
+ width: 28px;
487
+ height: 28px;
488
+ border-radius: 6px;
489
+ background: rgba(40, 184, 206, 0.08);
490
+ flex-shrink: 0;
491
+ `;
492
+ const menuItemLabelCss = css `
493
+ font-size: 13px;
494
+ font-weight: 700;
495
+ color: #e0f0f4;
496
+ flex: 1;
497
+ `;
498
+ const menuChevronCss = css `
499
+ font-size: 10px;
500
+ color: #3a6070;
501
+ `;
502
+ const toolReveal = keyframes `
503
+ from { opacity: 0; transform: translateX(-6px); }
504
+ to { opacity: 1; transform: translateX(0); }
505
+ `;
198
506
  export const devToolBtnCss = css `
199
507
  position: relative;
200
508
  display: grid;
@@ -324,6 +632,28 @@ const numberInputCss = css `
324
632
  margin: 0;
325
633
  }
326
634
  `;
635
+ const textInputCss = css `
636
+ flex: 1;
637
+ height: 26px;
638
+ border-radius: 5px;
639
+ border: 1px solid rgba(40, 184, 206, 0.25);
640
+ background: rgba(0, 0, 0, 0.3);
641
+ color: #e0f0f4;
642
+ font-size: 12px;
643
+ font-weight: 600;
644
+ padding: 0 8px;
645
+ font-family: inherit;
646
+
647
+ &::placeholder {
648
+ color: #3a6070;
649
+ }
650
+
651
+ &:focus {
652
+ outline: none;
653
+ border-color: ${GP_PRIMARY};
654
+ box-shadow: 0 0 0 2px rgba(40, 184, 206, 0.15);
655
+ }
656
+ `;
327
657
  const goBtnCss = css `
328
658
  height: 26px;
329
659
  padding: 0 12px;
@@ -339,6 +669,7 @@ const goBtnCss = css `
339
669
  margin-left: auto;
340
670
  font-family: inherit;
341
671
  transition: all 0.15s;
672
+ flex-shrink: 0;
342
673
 
343
674
  &:hover {
344
675
  background: rgba(40, 184, 206, 0.25);
@@ -369,21 +700,291 @@ const playerBtnActiveCss = css `
369
700
  border-color: ${GP_PRIMARY};
370
701
  color: ${GP_PRIMARY};
371
702
  `;
703
+ // ── Save/Load styles ──
704
+ const savedListCss = css `
705
+ display: flex;
706
+ flex-direction: column;
707
+ gap: 2px;
708
+ padding: 0 8px;
709
+ `;
710
+ const savedEntryCss = css `
711
+ display: flex;
712
+ align-items: center;
713
+ justify-content: space-between;
714
+ padding: 5px 8px;
715
+ border-radius: 5px;
716
+ background: rgba(40, 184, 206, 0.04);
717
+ border: 1px solid rgba(40, 184, 206, 0.08);
718
+ `;
719
+ const savedLabelCss = css `
720
+ font-size: 12px;
721
+ font-weight: 600;
722
+ color: #8ab8c8;
723
+ overflow: hidden;
724
+ text-overflow: ellipsis;
725
+ white-space: nowrap;
726
+ flex: 1;
727
+ margin-right: 8px;
728
+ `;
729
+ const savedActionsCss = css `
730
+ display: flex;
731
+ gap: 4px;
732
+ flex-shrink: 0;
733
+ `;
734
+ const smallBtnCss = css `
735
+ width: 22px;
736
+ height: 22px;
737
+ border-radius: 4px;
738
+ border: 1px solid rgba(40, 184, 206, 0.25);
739
+ background: rgba(40, 184, 206, 0.08);
740
+ color: ${GP_PRIMARY};
741
+ font-size: 10px;
742
+ cursor: pointer;
743
+ display: flex;
744
+ align-items: center;
745
+ justify-content: center;
746
+ font-family: inherit;
747
+ transition: all 0.15s;
748
+
749
+ &:hover {
750
+ background: rgba(40, 184, 206, 0.2);
751
+ border-color: rgba(40, 184, 206, 0.4);
752
+ }
753
+ `;
754
+ const smallBtnDangerCss = css `
755
+ &:hover {
756
+ background: rgba(220, 60, 60, 0.2);
757
+ border-color: rgba(220, 60, 60, 0.4);
758
+ color: #dc3c3c;
759
+ }
760
+ `;
761
+ // ── Backdrop (click-outside to close) ──
762
+ const backdropCss = css `
763
+ position: fixed;
764
+ inset: 0;
765
+ z-index: 899;
766
+ `;
767
+ // ── Paste Modal ──
768
+ const modalBackdropCss = css `
769
+ position: fixed;
770
+ inset: 0;
771
+ z-index: 1000;
772
+ background: rgba(0, 0, 0, 0.6);
773
+ backdrop-filter: blur(2px);
774
+ `;
775
+ const modalFadeIn = keyframes `
776
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); }
777
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
778
+ `;
779
+ const modalCss = css `
780
+ position: fixed;
781
+ top: 50%;
782
+ left: 50%;
783
+ transform: translate(-50%, -50%);
784
+ z-index: 1001;
785
+ width: min(700px, 90vw);
786
+ max-height: 80vh;
787
+ display: flex;
788
+ flex-direction: column;
789
+ background: linear-gradient(170deg, #0f2035 0%, ${GP_SURFACE} 100%);
790
+ border: 1px solid rgba(40, 184, 206, 0.3);
791
+ border-radius: 12px;
792
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7);
793
+ font-family: 'Mulish', sans-serif;
794
+ animation: ${modalFadeIn} 0.2s ease-out;
795
+ `;
796
+ const modalHeaderCss = css `
797
+ display: flex;
798
+ align-items: center;
799
+ justify-content: space-between;
800
+ padding: 14px 20px;
801
+ border-bottom: 1px solid rgba(40, 184, 206, 0.15);
802
+ background: rgba(40, 184, 206, 0.04);
803
+ border-radius: 12px 12px 0 0;
804
+ `;
805
+ const modalTitleCss = css `
806
+ font-size: 14px;
807
+ font-weight: 800;
808
+ color: #e0f0f4;
809
+ text-transform: uppercase;
810
+ letter-spacing: 0.08em;
811
+ `;
812
+ const modalCloseBtnCss = css `
813
+ width: 28px;
814
+ height: 28px;
815
+ border: none;
816
+ border-radius: 6px;
817
+ background: rgba(40, 184, 206, 0.08);
818
+ color: #5a8a98;
819
+ font-size: 12px;
820
+ cursor: pointer;
821
+ display: flex;
822
+ align-items: center;
823
+ justify-content: center;
824
+ transition: all 0.15s;
825
+
826
+ &:hover {
827
+ background: rgba(220, 60, 60, 0.2);
828
+ color: #dc3c3c;
829
+ }
830
+ `;
831
+ const modalBodyCss = css `
832
+ flex: 1;
833
+ overflow-y: auto;
834
+ padding: 16px 20px;
835
+ display: flex;
836
+ flex-direction: column;
837
+ gap: 12px;
838
+ min-height: 0;
839
+ `;
840
+ const modalTextareaCss = css `
841
+ width: 100%;
842
+ min-height: 100px;
843
+ border-radius: 8px;
844
+ border: 1px solid rgba(40, 184, 206, 0.25);
845
+ background: rgba(0, 0, 0, 0.4);
846
+ color: #e0f0f4;
847
+ font-size: 13px;
848
+ font-weight: 500;
849
+ padding: 12px;
850
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
851
+ resize: vertical;
852
+ line-height: 1.5;
853
+
854
+ &::placeholder {
855
+ color: #3a6070;
856
+ }
857
+
858
+ &:focus {
859
+ outline: none;
860
+ border-color: ${GP_PRIMARY};
861
+ box-shadow: 0 0 0 2px rgba(40, 184, 206, 0.15);
862
+ }
863
+ `;
864
+ const jsonPreviewCss = css `
865
+ border-radius: 8px;
866
+ border: 1px solid rgba(40, 184, 206, 0.15);
867
+ background: rgba(0, 0, 0, 0.3);
868
+ overflow: hidden;
869
+ `;
870
+ const jsonPreviewLabelCss = css `
871
+ font-size: 10px;
872
+ font-weight: 800;
873
+ color: #3a6070;
874
+ text-transform: uppercase;
875
+ letter-spacing: 0.1em;
876
+ padding: 6px 12px;
877
+ border-bottom: 1px solid rgba(40, 184, 206, 0.08);
878
+ `;
879
+ const jsonPreviewCodeCss = css `
880
+ padding: 12px;
881
+ margin: 0;
882
+ font-size: 12px;
883
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
884
+ line-height: 1.6;
885
+ color: #8ab8c8;
886
+ max-height: 300px;
887
+ overflow-y: auto;
888
+ white-space: pre;
889
+ tab-size: 2;
890
+ `;
891
+ const pasteErrorCss = css `
892
+ font-size: 12px;
893
+ font-weight: 700;
894
+ color: #dc3c3c;
895
+ padding: 6px 10px;
896
+ border-radius: 6px;
897
+ background: rgba(220, 60, 60, 0.1);
898
+ border: 1px solid rgba(220, 60, 60, 0.2);
899
+ `;
900
+ const modalFooterCss = css `
901
+ display: flex;
902
+ justify-content: flex-end;
903
+ gap: 8px;
904
+ padding: 12px 20px;
905
+ border-top: 1px solid rgba(40, 184, 206, 0.15);
906
+ `;
907
+ const modalCancelBtnCss = css `
908
+ height: 32px;
909
+ padding: 0 16px;
910
+ border-radius: 6px;
911
+ border: 1px solid rgba(40, 184, 206, 0.25);
912
+ background: transparent;
913
+ color: #5a8a98;
914
+ font-size: 13px;
915
+ font-weight: 700;
916
+ cursor: pointer;
917
+ font-family: inherit;
918
+ transition: all 0.15s;
919
+
920
+ &:hover {
921
+ background: rgba(40, 184, 206, 0.08);
922
+ color: #e0f0f4;
923
+ }
924
+ `;
925
+ const modalLoadBtnCss = css `
926
+ height: 32px;
927
+ padding: 0 20px;
928
+ border-radius: 6px;
929
+ border: 1px solid rgba(40, 184, 206, 0.4);
930
+ background: rgba(40, 184, 206, 0.2);
931
+ color: ${GP_PRIMARY};
932
+ font-size: 13px;
933
+ font-weight: 800;
934
+ text-transform: uppercase;
935
+ letter-spacing: 0.05em;
936
+ cursor: pointer;
937
+ font-family: inherit;
938
+ transition: all 0.15s;
939
+
940
+ &:hover {
941
+ background: rgba(40, 184, 206, 0.35);
942
+ border-color: ${GP_PRIMARY};
943
+ }
944
+ `;
945
+ // ── JSON syntax colors ──
946
+ const jsonKeyCss = css `
947
+ color: ${GP_ACCENT};
948
+ `;
949
+ const jsonStringCss = css `
950
+ color: #8bbb6a;
951
+ `;
952
+ const jsonBoolCss = css `
953
+ color: #d4872a;
954
+ `;
955
+ const jsonNumCss = css `
956
+ color: #c4a0e8;
957
+ `;
958
+ const jsonPunctCss = css `
959
+ color: #5a6a78;
960
+ `;
961
+ const jsonErrorTextCss = css `
962
+ color: #dc3c3c;
963
+ `;
372
964
  // ── Flash ──
373
965
  const flashFade = keyframes `
374
- 0% { opacity: 0; transform: translateY(4px); }
966
+ 0% { opacity: 0; transform: translateY(6px); }
375
967
  15% { opacity: 1; transform: translateY(0); }
376
968
  85% { opacity: 1; transform: translateY(0); }
377
- 100% { opacity: 0; transform: translateY(-4px); }
969
+ 100% { opacity: 0; transform: translateY(-6px); }
378
970
  `;
379
971
  const flashCss = css `
380
- padding: 8px 16px;
972
+ position: absolute;
973
+ bottom: 100%;
974
+ left: 0;
975
+ right: 0;
976
+ margin-bottom: 8px;
977
+ padding: 6px 14px;
381
978
  font-size: 12px;
382
979
  font-weight: 700;
383
980
  color: ${GP_PRIMARY};
384
981
  text-align: center;
385
- border-top: 1px solid rgba(40, 184, 206, 0.1);
386
- background: rgba(40, 184, 206, 0.04);
982
+ background: linear-gradient(145deg, ${GP_SURFACE}, ${GP_DARK});
983
+ border: 1px solid rgba(40, 184, 206, 0.3);
984
+ border-radius: 8px;
985
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
986
+ white-space: nowrap;
987
+ pointer-events: none;
387
988
  animation: ${flashFade} 1.5s ease-out forwards;
388
989
  `;
389
990
  // ── Game Options toggles ──
@@ -414,7 +1015,7 @@ const checkboxCss = css `
414
1015
  }
415
1016
 
416
1017
  &:checked::after {
417
- content: '';
1018
+ content: '\u2713';
418
1019
  position: absolute;
419
1020
  top: 50%;
420
1021
  left: 50%;