castle-web-cli 0.4.73 → 0.4.75

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 +1 @@
1
- export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\n- **Build for touch from the start.** Every deck must be fully playable on a touchscreen with no physical keyboard \u2014 design the touch controls first, not as an afterthought. Keyboard input is still fine on top, for shortcuts or typing. A touch control that only mirrors keys (e.g. a d-pad over the arrow keys) may hide itself when a hardware keyboard is present. Use good judgement.\n";
1
+ export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\n- **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch \u2014 direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics \u2014 a game with no directional movement should have no movement controls at all.\n";
@@ -4,5 +4,5 @@
4
4
  // (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
5
5
  export const COMMON_INSTRUCTIONS = `## Touch controls (every deck)
6
6
 
7
- - **Build for touch from the start.** Every deck must be fully playable on a touchscreen with no physical keyboarddesign the touch controls first, not as an afterthought. Keyboard input is still fine on top, for shortcuts or typing. A touch control that only mirrors keys (e.g. a d-pad over the arrow keys) may hide itself when a hardware keyboard is present. Use good judgement.
7
+ - **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics a game with no directional movement should have no movement controls at all.
8
8
  `;
@@ -144,7 +144,7 @@ if (scene.keys.has('KeyX')) /* launch ball */ ;
144
144
 
145
145
  **Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
146
146
 
147
- For HUD text use a behavior's `ui` hook (returns React); for in-world text or shapes, draw with `ctx` from `draw`. The deck has TouchControls overlay support out of the box for mobile play (arrow keys + a button); no setup needed.
147
+ For HUD text use a behavior's `ui` hook (returns React); for in-world text or shapes, draw with `ctx` from `draw`.
148
148
 
149
149
  ## Common breakout-shaped recipe (sketch)
150
150
 
@@ -1,7 +1,6 @@
1
1
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { renderSpriteFrame } from '../engine/pxart';
3
3
  import { basename, formatJson, parseJsonFile } from '../engine/files';
4
- import { TouchControls } from '../engine/TouchControls';
5
4
  import {
6
5
  addActor,
7
6
  arrangeActors,
@@ -43,6 +42,27 @@ const EDIT_VIEWPORT = {
43
42
  originX: cardSize.width,
44
43
  originY: cardSize.height,
45
44
  };
45
+ // Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
46
+ // zoom < 1 pulls back to show more around the card.
47
+ const MIN_EDIT_ZOOM = 0.4;
48
+ const MAX_EDIT_ZOOM = 6;
49
+ // Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
50
+ const ZOOM_WHEEL_SPEED = 0.01;
51
+ const clampZoom = (zoom) =>
52
+ Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
53
+ // Derive the effective edit viewport for a zoom level. Both extent and origin
54
+ // scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
55
+ // SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
56
+ // just multiplies its scale by the same zoom).
57
+ function editViewportForZoom(zoom) {
58
+ const z = clampZoom(zoom);
59
+ return {
60
+ width: EDIT_VIEWPORT.width / z,
61
+ height: EDIT_VIEWPORT.height / z,
62
+ originX: EDIT_VIEWPORT.originX / z,
63
+ originY: EDIT_VIEWPORT.originY / z,
64
+ };
65
+ }
46
66
  export function SceneEditor({
47
67
  path,
48
68
  text,
@@ -60,7 +80,7 @@ export function SceneEditor({
60
80
  const canvasRef = useRef(null);
61
81
  const runtimeRef = useRef(null);
62
82
  const marqueeRef = useRef(null);
63
- const editCameraRef = useRef({ x: 0, y: 0 });
83
+ const editCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
64
84
  const selectedActorIdsRef = useRef(selectedActorIds);
65
85
  selectedActorIdsRef.current = selectedActorIds;
66
86
  const [isPlaying, setIsPlaying] = useState(false);
@@ -69,7 +89,6 @@ export function SceneEditor({
69
89
  const showMulti = selectedActorIds.length > 1 || multiSelectMode;
70
90
  const inspectorSheet = useSelectionInspectorSheet(true);
71
91
  const { value: sceneData, error } = parseJsonFile(path, text);
72
- const getRuntimeKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
73
92
  const getRuntime = useCallback(() => runtimeRef.current, []);
74
93
  useScenePlayLoop({
75
94
  sceneData,
@@ -215,7 +234,6 @@ export function SceneEditor({
215
234
  ? panGesture.onPointerUp(event)
216
235
  : gesture.onPointerUp(event)
217
236
  }
218
- onWheel={panGesture.onWheel}
219
237
  />
220
238
  {isPlaying && <SceneUI getRuntime={getRuntime} />}
221
239
  </div>
@@ -268,7 +286,6 @@ export function SceneEditor({
268
286
  </aside>
269
287
  </div>
270
288
  </EditorBody>
271
- <TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
272
289
  </>
273
290
  );
274
291
  }
@@ -923,6 +940,10 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
923
940
  function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
924
941
  const dragRef = useRef(null);
925
942
  const spaceDownRef = useRef(false);
943
+ // Native wheel/gesture listeners (below) read play state through a ref so they
944
+ // never need to detach/reattach when play toggles.
945
+ const isPlayingRef = useRef(isPlaying);
946
+ isPlayingRef.current = isPlaying;
926
947
  useEffect(() => {
927
948
  function onKeyDown(event) {
928
949
  if (event.key !== ' ' || isEditableTarget(event.target)) return;
@@ -950,12 +971,104 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
950
971
  const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
951
972
  const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
952
973
  editCameraRef.current = {
974
+ ...editCameraRef.current,
953
975
  x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
954
976
  y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
955
977
  };
956
978
  },
957
979
  [canvasRef, editCameraRef]
958
980
  );
981
+ // Zoom about the pointer by a multiplicative factor: keep the world point
982
+ // under the cursor pinned while the zoom level changes. Because the effective
983
+ // origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
984
+ // cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
985
+ const zoomByFactorAtPointer = useCallback(
986
+ (clientX, clientY, factor) => {
987
+ const canvas = canvasRef.current;
988
+ if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
989
+ const cam = editCameraRef.current;
990
+ const z0 = cam.zoom ?? 1;
991
+ const z1 = clampZoom(z0 * factor);
992
+ if (z1 === z0) return;
993
+ const rect = canvas.getBoundingClientRect();
994
+ const fracX = (clientX - rect.left) / rect.width;
995
+ const fracY = (clientY - rect.top) / rect.height;
996
+ const k = 1 / z0 - 1 / z1;
997
+ editCameraRef.current = {
998
+ x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
999
+ y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
1000
+ zoom: z1,
1001
+ };
1002
+ },
1003
+ [canvasRef, editCameraRef]
1004
+ );
1005
+ // Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
1006
+ // React attaches `wheel` passively at its root, so a React onWheel can't call
1007
+ // preventDefault and the browser's own page-zoom wins. We also need Safari's
1008
+ // proprietary `gesture*` events, which is how it reports trackpad pinch (it
1009
+ // does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
1010
+ // the browser from zooming the page underneath the editor.
1011
+ useEffect(() => {
1012
+ const canvas = canvasRef.current;
1013
+ if (!canvas) return undefined;
1014
+ let gestureScale = 1;
1015
+ // Set while a Safari pinch gesture is in flight, so we don't double-apply
1016
+ // zoom if Safari also emits ctrl+wheel for the same pinch.
1017
+ let gestureActive = false;
1018
+ const onWheel = (event) => {
1019
+ if (isPlayingRef.current) return;
1020
+ event.preventDefault();
1021
+ // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1022
+ // in Chromium; route those to zoom and leave plain scroll as pan.
1023
+ if (event.ctrlKey) {
1024
+ if (gestureActive) return;
1025
+ zoomByFactorAtPointer(
1026
+ event.clientX,
1027
+ event.clientY,
1028
+ Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1029
+ );
1030
+ } else {
1031
+ panByScreenDelta(event.deltaX, event.deltaY);
1032
+ }
1033
+ };
1034
+ const onGestureStart = (event) => {
1035
+ if (isPlayingRef.current) return;
1036
+ event.preventDefault();
1037
+ gestureActive = true;
1038
+ gestureScale = event.scale || 1;
1039
+ };
1040
+ const onGestureChange = (event) => {
1041
+ if (isPlayingRef.current) return;
1042
+ event.preventDefault();
1043
+ // Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
1044
+ // zoom by the ratio against the last reading.
1045
+ const prev = gestureScale || 1;
1046
+ const next = event.scale || prev;
1047
+ gestureScale = next;
1048
+ // GestureEvent carries clientX/clientY (the pinch centroid); fall back to
1049
+ // the canvas center if a browser omits them.
1050
+ const rect = canvas.getBoundingClientRect();
1051
+ const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
1052
+ const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
1053
+ zoomByFactorAtPointer(px, py, next / prev);
1054
+ };
1055
+ const onGestureEnd = (event) => {
1056
+ if (isPlayingRef.current) return;
1057
+ event.preventDefault();
1058
+ gestureActive = false;
1059
+ };
1060
+ const opts = { passive: false };
1061
+ canvas.addEventListener('wheel', onWheel, opts);
1062
+ canvas.addEventListener('gesturestart', onGestureStart, opts);
1063
+ canvas.addEventListener('gesturechange', onGestureChange, opts);
1064
+ canvas.addEventListener('gestureend', onGestureEnd, opts);
1065
+ return () => {
1066
+ canvas.removeEventListener('wheel', onWheel, opts);
1067
+ canvas.removeEventListener('gesturestart', onGestureStart, opts);
1068
+ canvas.removeEventListener('gesturechange', onGestureChange, opts);
1069
+ canvas.removeEventListener('gestureend', onGestureEnd, opts);
1070
+ };
1071
+ }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
959
1072
  const onPointerDown = useCallback((event) => {
960
1073
  if (isPlaying || !spaceDownRef.current) return;
961
1074
  event.preventDefault();
@@ -984,17 +1097,9 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
984
1097
  }
985
1098
  dragRef.current = null;
986
1099
  }, []);
987
- const onWheel = useCallback(
988
- (event) => {
989
- if (isPlaying) return;
990
- event.preventDefault();
991
- panByScreenDelta(event.deltaX, event.deltaY);
992
- },
993
- [isPlaying, panByScreenDelta]
994
- );
995
1100
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
996
1101
  const isActive = useCallback(() => Boolean(dragRef.current), []);
997
- return { onPointerDown, onPointerMove, onPointerUp, onWheel, isSpacePanning, isActive };
1102
+ return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
998
1103
  }
999
1104
  function handleShiftPointerDown(drag, actor, current) {
1000
1105
  if (actor) {
@@ -1149,13 +1254,20 @@ function useScenePlayLoop({
1149
1254
  const canvas = canvasRef.current;
1150
1255
  const ctx = canvas.getContext('2d');
1151
1256
  if (!ctx) return undefined;
1152
- const viewport = isPlaying ? undefined : EDIT_VIEWPORT;
1257
+ const viewport = isPlaying
1258
+ ? undefined
1259
+ : editViewportForZoom(editCameraRef.current.zoom);
1153
1260
  configureSceneCanvas(canvas, ctx, viewport);
1154
1261
  let raf = 0;
1155
1262
  let last = performance.now();
1156
1263
  const frame = (now) => {
1157
1264
  const dt = Math.min(0.033, (now - last) / 1000);
1158
1265
  last = now;
1266
+ // Recompute each frame so the edit zoom (mutated on the camera ref by the
1267
+ // pinch gesture) takes effect without restarting the loop.
1268
+ const frameViewport = isPlaying
1269
+ ? undefined
1270
+ : editViewportForZoom(editCameraRef.current.zoom);
1159
1271
  const scene = isPlaying
1160
1272
  ? runtimeRef.current
1161
1273
  : makeScene(sceneData, behaviorClasses, sprites);
@@ -1163,7 +1275,7 @@ function useScenePlayLoop({
1163
1275
  const snap = getSnapSettings(sceneData);
1164
1276
  if (isPlaying) scene.update(dt);
1165
1277
  if (!isPlaying) scene.camera = { ...editCameraRef.current };
1166
- configureSceneCanvas(canvas, ctx, viewport);
1278
+ configureSceneCanvas(canvas, ctx, frameViewport);
1167
1279
  // Read selection from the ref each frame so the grid/box gating tracks
1168
1280
  // the current selection without resetting this loop.
1169
1281
  const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
@@ -1174,7 +1286,7 @@ function useScenePlayLoop({
1174
1286
  gridSize: snap.gridSize,
1175
1287
  showDebugColliders: false,
1176
1288
  showCropOutline: !isPlaying,
1177
- viewport,
1289
+ viewport: frameViewport,
1178
1290
  useCamera: true,
1179
1291
  editPlaceholders: !isPlaying,
1180
1292
  editSelectedActorIds,
@@ -63,7 +63,7 @@ export function SelectionOverlay({
63
63
  const rootRef = useRef(null);
64
64
  // Track the canvas-sized overlay box so the card-unit layer scales onto it.
65
65
  const box = useElementSize(rootRef);
66
- const [camera, setCamera] = useState({ x: 0, y: 0 });
66
+ const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
67
67
  const [groupFrameRotation, setGroupFrameRotation] = useState(null);
68
68
  const [arrangeOpen, setArrangeOpen] = useState(false);
69
69
  const selectionKey = [...selectedActorIds].sort().join('|');
@@ -73,8 +73,13 @@ export function SelectionOverlay({
73
73
  useEffect(() => {
74
74
  let raf = 0;
75
75
  const tick = () => {
76
- const cam = editCameraRef.current ?? { x: 0, y: 0 };
77
- setCamera((prev) => (prev.x === cam.x && prev.y === cam.y ? prev : { x: cam.x, y: cam.y }));
76
+ const cam = editCameraRef.current ?? { x: 0, y: 0, zoom: 1 };
77
+ const zoom = cam.zoom ?? 1;
78
+ setCamera((prev) =>
79
+ prev.x === cam.x && prev.y === cam.y && prev.zoom === zoom
80
+ ? prev
81
+ : { x: cam.x, y: cam.y, zoom }
82
+ );
78
83
  raf = requestAnimationFrame(tick);
79
84
  };
80
85
  raf = requestAnimationFrame(tick);
@@ -334,8 +339,13 @@ function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
334
339
  }
335
340
 
336
341
  function getOverlayGeometry(frame, box, camera) {
337
- const sx = box.width / cardSize.width;
338
- const sy = box.height / cardSize.height;
342
+ // Fold the edit zoom into the card-unit -> screen-px scale. The canvas draws
343
+ // the card at (box / card) * zoom px-per-unit (the effective viewport extent
344
+ // and origin both scale by 1/zoom), and the chrome layer's `transform-origin:
345
+ // 0 0` projection from world (0,0) matches once sx/sy carry the same zoom.
346
+ const zoom = camera.zoom ?? 1;
347
+ const sx = (box.width / cardSize.width) * zoom;
348
+ const sy = (box.height / cardSize.height) * zoom;
339
349
  const camX = Math.round(camera.x);
340
350
  const camY = Math.round(camera.y);
341
351
  const rotation = frame.rotation;
@@ -1,7 +1,9 @@
1
- import React, { useCallback, useEffect, useRef } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { configureSceneCanvas, makeScene } from './scene';
3
4
  import { SceneUI } from './SceneUI';
4
- import { TouchControls } from './TouchControls';
5
+ import { cx, Icon, styles } from './ui';
6
+ import { usePlayLogs } from './playConsole';
5
7
  // Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
6
8
  // keyboard / pointer input, run the update+draw loop, and render the
7
9
  // behavior-driven UI overlay. No game logic lives here -- behaviors and
@@ -9,8 +11,11 @@ import { TouchControls } from './TouchControls';
9
11
  export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
10
12
  const canvasRef = useRef(null);
11
13
  const runtimeRef = useRef(null);
12
- const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
14
+ // Bumping this re-runs the mount effect below, which tears down the running
15
+ // loop/runtime and rebuilds a fresh scene from the same data -- a clean restart.
16
+ const [runToken, setRunToken] = useState(0);
13
17
  const getRuntime = useCallback(() => runtimeRef.current, []);
18
+ const restart = useCallback(() => setRunToken((token) => token + 1), []);
14
19
  useEffect(() => {
15
20
  const canvas = canvasRef.current;
16
21
  if (!canvas) return undefined;
@@ -56,7 +61,9 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
56
61
  canvas.addEventListener('pointerup', onPointerUp);
57
62
  canvas.addEventListener('pointercancel', onPointerUp);
58
63
  canvas.focus();
59
- const stopLoop = startPlayerLoop(canvas, ctx, runtime, onFirstFrame);
64
+ // Only signal first-frame readiness on the initial run; a restart shouldn't
65
+ // re-fire the host launcher reveal.
66
+ const stopLoop = startPlayerLoop(canvas, ctx, runtime, runToken === 0 ? onFirstFrame : undefined);
60
67
  return () => {
61
68
  stopLoop();
62
69
  window.removeEventListener('keydown', onKeyDown);
@@ -68,16 +75,156 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
68
75
  runtimeRef.current = null;
69
76
  };
70
77
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, [runToken]);
79
+ return (
80
+ <>
81
+ <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
82
+ <canvas
83
+ ref={canvasRef}
84
+ tabIndex={0}
85
+ style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
86
+ />
87
+ <SceneUI getRuntime={getRuntime} />
88
+ </div>
89
+ {/* Portal to <body> so the console drawer escapes the SDK's card-sized,
90
+ transformed `#root > *` wrapper and pins to the panel, outside the
91
+ play preview. */}
92
+ {createPortal(<PlayConsole onRestart={restart} />, document.body)}
93
+ </>
94
+ );
95
+ }
96
+ function PlayConsole({ onRestart }) {
97
+ const { logs, clearLogs } = usePlayLogs();
98
+ const [open, setOpen] = useState(false);
99
+ const rootRef = useRef(null);
100
+ const bodyRef = useRef(null);
101
+ // Reserve the drawer's footprint by shrinking + lifting the SDK deck card so it
102
+ // sits fully above the drawer (visible + interactive) instead of behind it.
103
+ // Driven by the live drawer height; recomputed on toggle (height change via the
104
+ // ResizeObserver) and on window/card resize.
105
+ useEffect(() => {
106
+ const el = rootRef.current;
107
+ if (!el) return undefined;
108
+ const docEl = document.documentElement;
109
+ docEl.dataset.castleLogs = '';
110
+ const TOP_MARGIN = 16;
111
+ const MIN_CARD_SCALE = 0.2;
112
+ const apply = () => {
113
+ const drawerHeight = el.offsetHeight;
114
+ const cs = getComputedStyle(docEl);
115
+ const fullW = parseFloat(cs.getPropertyValue('--castle-card-w'));
116
+ const fullH = parseFloat(cs.getPropertyValue('--castle-card-h'));
117
+ docEl.style.setProperty('--castle-logs-shift', `${drawerHeight / 2}px`);
118
+ // Only resize the card when we know its natural size; otherwise leave it to
119
+ // the SDK fallback (the rule's var() defaults handle this).
120
+ if (fullW > 0 && fullH > 0) {
121
+ const available = window.innerHeight - drawerHeight - TOP_MARGIN;
122
+ // Floor the scale so a very short panel (drawer taller than the area)
123
+ // can't drive the card to 0/negative and make the deck disappear.
124
+ const scale = Math.min(1, Math.max(MIN_CARD_SCALE, available / fullH));
125
+ docEl.style.setProperty('--castle-logs-card-w', `${fullW * scale}px`);
126
+ docEl.style.setProperty('--castle-logs-card-h', `${fullH * scale}px`);
127
+ } else {
128
+ docEl.style.removeProperty('--castle-logs-card-w');
129
+ docEl.style.removeProperty('--castle-logs-card-h');
130
+ }
131
+ };
132
+ apply();
133
+ const observer = new ResizeObserver(apply);
134
+ observer.observe(el);
135
+ window.addEventListener('resize', apply);
136
+ return () => {
137
+ observer.disconnect();
138
+ window.removeEventListener('resize', apply);
139
+ delete docEl.dataset.castleLogs;
140
+ docEl.style.removeProperty('--castle-logs-card-w');
141
+ docEl.style.removeProperty('--castle-logs-card-h');
142
+ docEl.style.removeProperty('--castle-logs-shift');
143
+ };
71
144
  }, []);
145
+ // Keep the newest line in view while expanded.
146
+ useEffect(() => {
147
+ if (open && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
148
+ }, [logs, open]);
149
+ const copy = useCallback(() => {
150
+ const text = logs.map((line) => line.text).join('\n');
151
+ navigator.clipboard?.writeText(text).catch(() => {});
152
+ }, [logs]);
153
+ const hasLogs = logs.length > 0;
72
154
  return (
73
- <div style={{ position: 'fixed', inset: 0, background: '#000' }}>
74
- <canvas
75
- ref={canvasRef}
76
- tabIndex={0}
77
- style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
78
- />
79
- <SceneUI getRuntime={getRuntime} />
80
- <TouchControls getKeys={getKeys} />
155
+ <div ref={rootRef} className={styles.playConsole}>
156
+ {/* The whole header row toggles; the action buttons stopPropagation so
157
+ they don't also collapse/expand. */}
158
+ <div
159
+ className={styles.playConsoleHeader}
160
+ role="button"
161
+ tabIndex={-1}
162
+ aria-expanded={open}
163
+ aria-label={open ? 'Collapse logs' : 'Expand logs'}
164
+ onClick={() => setOpen((value) => !value)}>
165
+ <span className={styles.playConsoleToggle}>
166
+ <span className={styles.playConsoleCaret}>
167
+ <Icon name={open ? 'chevron-down' : 'chevron-right'} />
168
+ </span>
169
+ <span className={styles.playConsoleTitle}>Logs</span>
170
+ </span>
171
+ <div
172
+ className={styles.playConsoleActions}
173
+ onClick={(event) => event.stopPropagation()}>
174
+ {open ? (
175
+ <>
176
+ <button
177
+ type="button"
178
+ tabIndex={-1}
179
+ className={styles.playConsoleBtn}
180
+ aria-label="Copy logs"
181
+ title="Copy logs"
182
+ disabled={!hasLogs}
183
+ onClick={copy}>
184
+ <Icon name="clone" />
185
+ </button>
186
+ <button
187
+ type="button"
188
+ tabIndex={-1}
189
+ className={styles.playConsoleBtn}
190
+ aria-label="Clear logs"
191
+ title="Clear logs"
192
+ disabled={!hasLogs}
193
+ onClick={clearLogs}>
194
+ <Icon name="trash" />
195
+ </button>
196
+ </>
197
+ ) : null}
198
+ <button
199
+ type="button"
200
+ tabIndex={-1}
201
+ className={styles.playConsoleBtn}
202
+ aria-label="Restart"
203
+ title="Restart"
204
+ onClick={onRestart}>
205
+ <Icon name="rotate" />
206
+ </button>
207
+ </div>
208
+ </div>
209
+ {open ? (
210
+ <div ref={bodyRef} className={styles.playConsoleBody}>
211
+ {hasLogs ? (
212
+ logs.map((line) => (
213
+ <div
214
+ key={line.id}
215
+ className={cx(
216
+ styles.playConsoleLine,
217
+ line.level === 'warn' && styles.playConsoleLineWarn,
218
+ line.level === 'error' && styles.playConsoleLineError
219
+ )}>
220
+ {line.text}
221
+ </div>
222
+ ))
223
+ ) : (
224
+ <div className={styles.playConsoleEmpty}>no output</div>
225
+ )}
226
+ </div>
227
+ ) : null}
81
228
  </div>
82
229
  );
83
230
  }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState } from 'react';
2
+ // Captures console output from the running deck into an in-memory ring buffer and
3
+ // notifies subscribers, so the on-panel play console can display it.
4
+ //
5
+ // The SDK already wraps console (to forward logs to the dev server over its
6
+ // websocket); this wraps ON TOP and always calls through, so that forwarding and
7
+ // the real devtools output are preserved. Install is idempotent.
8
+ const MAX_LINES = 500;
9
+ let buffer = [];
10
+ let nextId = 1;
11
+ let installed = false;
12
+ const subscribers = new Set();
13
+ function formatArgs(args) {
14
+ return args
15
+ .map((arg) => {
16
+ if (typeof arg === 'string') return arg;
17
+ if (arg instanceof Error) return arg.stack || arg.message;
18
+ try {
19
+ const json = JSON.stringify(arg);
20
+ return json ?? String(arg);
21
+ } catch {
22
+ return String(arg);
23
+ }
24
+ })
25
+ .join(' ');
26
+ }
27
+ function emit(level, args) {
28
+ const entry = { id: nextId++, level, text: formatArgs(args) };
29
+ const next = buffer.concat(entry);
30
+ buffer = next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
31
+ for (const cb of subscribers) cb(buffer);
32
+ }
33
+ export function installConsoleCapture() {
34
+ if (installed || typeof console === 'undefined') return;
35
+ installed = true;
36
+ for (const level of ['log', 'warn', 'error']) {
37
+ const original = typeof console[level] === 'function' ? console[level].bind(console) : null;
38
+ console[level] = (...args) => {
39
+ if (original) original(...args);
40
+ // Never let capture throw into the caller's logging path.
41
+ try {
42
+ emit(level, args);
43
+ } catch {
44
+ // ignore
45
+ }
46
+ };
47
+ }
48
+ }
49
+ export function subscribeLogs(callback) {
50
+ subscribers.add(callback);
51
+ callback(buffer);
52
+ return () => subscribers.delete(callback);
53
+ }
54
+ export function clearLogs() {
55
+ buffer = [];
56
+ for (const cb of subscribers) cb(buffer);
57
+ }
58
+ // React hook: install capture (idempotent) and track the live log buffer.
59
+ export function usePlayLogs() {
60
+ const [logs, setLogs] = useState(buffer);
61
+ useEffect(() => {
62
+ installConsoleCapture();
63
+ return subscribeLogs(setLogs);
64
+ }, []);
65
+ return { logs, clearLogs };
66
+ }
@@ -1901,3 +1901,120 @@
1901
1901
  display: flex;
1902
1902
  }
1903
1903
  }
1904
+
1905
+ /* On-panel play console: a collapsible log drawer pinned to the bottom of the
1906
+ play panel (portaled to <body> so it sits outside the SDK's card wrapper).
1907
+ Houses the restart control plus copy/clear log affordances. */
1908
+ .playConsole {
1909
+ position: fixed;
1910
+ left: 0;
1911
+ right: 0;
1912
+ bottom: 0;
1913
+ z-index: 60;
1914
+ display: flex;
1915
+ flex-direction: column;
1916
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
1917
+ background: rgba(13, 13, 13, 0.94);
1918
+ color: #e6e6e6;
1919
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
1920
+ 'Courier New', monospace;
1921
+ padding-bottom: env(safe-area-inset-bottom, 0px);
1922
+ overflow: hidden;
1923
+ }
1924
+ .playConsoleHeader {
1925
+ display: flex;
1926
+ align-items: center;
1927
+ justify-content: space-between;
1928
+ gap: 8px;
1929
+ padding: 7px 8px 7px 10px;
1930
+ cursor: pointer;
1931
+ }
1932
+ .playConsoleHeader:hover {
1933
+ background: rgba(255, 255, 255, 0.04);
1934
+ }
1935
+ .playConsoleToggle {
1936
+ display: flex;
1937
+ align-items: center;
1938
+ gap: 8px;
1939
+ }
1940
+ .playConsoleCaret {
1941
+ display: inline-flex;
1942
+ font-size: 11px;
1943
+ color: #8a8a8a;
1944
+ }
1945
+ .playConsoleTitle {
1946
+ font-size: 11px;
1947
+ font-weight: 700;
1948
+ letter-spacing: 1px;
1949
+ text-transform: uppercase;
1950
+ color: #b3b3b3;
1951
+ }
1952
+ .playConsoleActions {
1953
+ display: flex;
1954
+ align-items: center;
1955
+ gap: 6px;
1956
+ }
1957
+ .playConsoleBtn {
1958
+ display: inline-flex;
1959
+ align-items: center;
1960
+ justify-content: center;
1961
+ width: 30px;
1962
+ height: 28px;
1963
+ padding: 0;
1964
+ font-size: 13px;
1965
+ color: #cfcfcf;
1966
+ background: transparent;
1967
+ border: 1px solid rgba(255, 255, 255, 0.14);
1968
+ border-radius: 6px;
1969
+ cursor: pointer;
1970
+ }
1971
+ .playConsoleBtn:hover {
1972
+ background: rgba(255, 255, 255, 0.08);
1973
+ color: #fff;
1974
+ }
1975
+ .playConsoleBtn:disabled {
1976
+ opacity: 0.38;
1977
+ cursor: default;
1978
+ }
1979
+ .playConsoleBtn:disabled:hover {
1980
+ background: transparent;
1981
+ color: #cfcfcf;
1982
+ }
1983
+ .playConsoleBody {
1984
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
1985
+ height: 165px;
1986
+ overflow-y: auto;
1987
+ padding: 8px 10px;
1988
+ font-size: 12px;
1989
+ line-height: 1.5;
1990
+ }
1991
+ .playConsoleEmpty {
1992
+ color: #6a6a6a;
1993
+ }
1994
+ .playConsoleLine {
1995
+ white-space: pre-wrap;
1996
+ word-break: break-word;
1997
+ color: #c8c8c8;
1998
+ }
1999
+ .playConsoleLineWarn {
2000
+ color: #e0af68;
2001
+ }
2002
+ .playConsoleLineError {
2003
+ color: #ff7a93;
2004
+ }
2005
+
2006
+ /* While the logs drawer is mounted, scale the deck card to fit ABOVE the drawer
2007
+ (rather than sitting behind it) so play stays fully visible and interactive.
2008
+ The SDK's initPlayCard() pins `#root > *` as a viewport-centered card with
2009
+ !important; this higher-specificity (attribute + id) rule overrides its size
2010
+ and vertical offset. The three custom properties are driven from JS in
2011
+ PlayConsole using the live drawer height + the SDK's natural card size. */
2012
+ :global(html[data-castle-logs] #root > *) {
2013
+ width: var(--castle-logs-card-w, var(--castle-card-w, 100vw)) !important;
2014
+ height: var(--castle-logs-card-h, var(--castle-card-h, 100vh)) !important;
2015
+ transform: translate(-50%, calc(-50% - var(--castle-logs-shift, 0px))) !important;
2016
+ transition:
2017
+ transform 0.18s ease,
2018
+ width 0.18s ease,
2019
+ height 0.18s ease;
2020
+ }
@@ -0,0 +1,3 @@
1
+ allowBuilds:
2
+ '@fortawesome/fontawesome-common-types': set this to true or false
3
+ '@fortawesome/free-solid-svg-icons': set this to true or false
@@ -142,7 +142,7 @@ if (scene.keys.has('KeyJ')) /* jump */ ;
142
142
 
143
143
  **Space is reserved** — the editor binds it to the play/stop toggle, so don't bind Space to a gameplay action (jump / shoot / launch / ...). Use arrows, WASD, letter keys, or on-screen buttons instead.
144
144
 
145
- For HUD text use a behavior's `ui` hook (returns React, 500x700 design space); for in-world visuals, use Mesh/Model components or a custom `sync`. The deck has TouchControls overlay support out of the box for mobile play (arrow keys d-pad); no setup needed.
145
+ For HUD text use a behavior's `ui` hook (returns React, 500x700 design space); for in-world visuals, use Mesh/Model components or a custom `sync`.
146
146
 
147
147
  ## Common exploration-shaped recipe (sketch)
148
148
 
@@ -1,7 +1,6 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import * as THREE from 'three';
3
3
  import { basename, formatJson, parseJsonFile } from '../engine/files';
4
- import { TouchControls } from '../engine/TouchControls';
5
4
  import {
6
5
  addActor,
7
6
  defaultCameraSpec,
@@ -83,10 +82,6 @@ export function SceneEditor({
83
82
  applyScene,
84
83
  recordSnapshot: history.recordSnapshot,
85
84
  });
86
- const getRuntimeKeys = useCallback(
87
- () => viewport.playRuntimeRef.current?.keys ?? null,
88
- [viewport.playRuntimeRef]
89
- );
90
85
  const getPlayRuntime = useCallback(
91
86
  () => viewport.playRuntimeRef.current,
92
87
  [viewport.playRuntimeRef]
@@ -240,7 +235,6 @@ export function SceneEditor({
240
235
  </aside>
241
236
  </div>
242
237
  </EditorBody>
243
- <TouchControls getKeys={getRuntimeKeys} visible={isPlaying} />
244
238
  </>
245
239
  );
246
240
  }
@@ -4,7 +4,6 @@ import { makeScene, defaultCameraSpec } from './scene';
4
4
  import { applyCameraSpec, fitRendererToCanvas } from './threeUtil';
5
5
  import { attachSceneKeys, makePlayPointerHandlers, ThreeCanvas } from './SceneViewport';
6
6
  import { SceneUI } from './SceneUI';
7
- import { TouchControls } from './TouchControls';
8
7
  // Engine-level scene player: mount a `SceneRuntime` against a WebGL canvas,
9
8
  // wire keyboard / pointer input, run the update+sync+render loop, and render
10
9
  // the behavior-driven UI overlay. No game logic lives here -- behaviors and
@@ -13,7 +12,6 @@ export function ScenePlayer({ sceneData, models, behaviorClasses, onFirstFrame }
13
12
  const runtimeRef = useRef(null);
14
13
  const cameraRef = useRef(null);
15
14
  const firstFrameRef = useRef(false);
16
- const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
17
15
  const getRuntime = useCallback(() => runtimeRef.current, []);
18
16
  const pointerHandlers = useMemo(() => makePlayPointerHandlers(getRuntime), [getRuntime]);
19
17
  useEffect(() => {
@@ -55,7 +53,6 @@ export function ScenePlayer({ sceneData, models, behaviorClasses, onFirstFrame }
55
53
  {...pointerHandlers}
56
54
  />
57
55
  <SceneUI getRuntime={getRuntime} />
58
- <TouchControls getKeys={getKeys} />
59
56
  </div>
60
57
  );
61
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -1,136 +0,0 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- // On-screen movement controls: a 4-way d-pad feeds arrow keys onto
3
- // `scene.keys` -- the exact same keyboard keys behaviors consume, no separate
4
- // input path.
5
- const KEYS = {
6
- up: 'ArrowUp',
7
- down: 'ArrowDown',
8
- left: 'ArrowLeft',
9
- right: 'ArrowRight',
10
- };
11
- export function TouchControls({ getKeys, visible = true }) {
12
- const [isTouch, setIsTouch] = useState(false);
13
- const heldRef = useRef(new Set());
14
- useEffect(() => {
15
- const params = new URLSearchParams(window.location.search);
16
- const override = params.get('touch');
17
- if (override === '1' || override === 'true') {
18
- setIsTouch(true);
19
- return undefined;
20
- }
21
- if (override === '0' || override === 'false') {
22
- setIsTouch(false);
23
- return undefined;
24
- }
25
- const mq = window.matchMedia('(pointer: coarse)');
26
- const update = () => {
27
- setIsTouch(mq.matches || 'ontouchstart' in window || navigator.maxTouchPoints > 0);
28
- };
29
- update();
30
- mq.addEventListener?.('change', update);
31
- return () => mq.removeEventListener?.('change', update);
32
- }, []);
33
- const release = useCallback(
34
- (id) => {
35
- if (!heldRef.current.has(id)) return;
36
- heldRef.current.delete(id);
37
- getKeys()?.delete(KEYS[id]);
38
- },
39
- [getKeys]
40
- );
41
- useEffect(() => {
42
- if (!visible || !isTouch) {
43
- // Drop any keys we were holding when controls hide.
44
- const keys = getKeys();
45
- if (keys) for (const id of heldRef.current) keys.delete(KEYS[id]);
46
- heldRef.current.clear();
47
- }
48
- }, [visible, isTouch, getKeys]);
49
- if (!visible || !isTouch) return null;
50
- const press = (id) => {
51
- heldRef.current.add(id);
52
- getKeys()?.add(KEYS[id]);
53
- };
54
- const button = (id, label, extra) => (
55
- <button
56
- key={id}
57
- type="button"
58
- tabIndex={-1}
59
- aria-label={id}
60
- onTouchStart={(event) => {
61
- event.preventDefault();
62
- press(id);
63
- }}
64
- onTouchEnd={(event) => {
65
- event.preventDefault();
66
- release(id);
67
- }}
68
- onTouchCancel={(event) => {
69
- event.preventDefault();
70
- release(id);
71
- }}
72
- onMouseDown={(event) => {
73
- event.preventDefault();
74
- press(id);
75
- }}
76
- onMouseUp={() => release(id)}
77
- onMouseLeave={() => release(id)}
78
- onContextMenu={(event) => event.preventDefault()}
79
- style={{ ...buttonStyle, ...extra }}>
80
- {label}
81
- </button>
82
- );
83
- return (
84
- <div style={containerStyle}>
85
- <div style={dpadStyle}>
86
- {button('up', '▲', dpadUpStyle)}
87
- {button('left', '◀', dpadLeftStyle)}
88
- {button('right', '▶', dpadRightStyle)}
89
- {button('down', '▼', dpadDownStyle)}
90
- </div>
91
- </div>
92
- );
93
- }
94
- const containerStyle = {
95
- position: 'fixed',
96
- left: 0,
97
- right: 0,
98
- bottom: 0,
99
- display: 'flex',
100
- justifyContent: 'flex-start',
101
- alignItems: 'flex-end',
102
- padding: '12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px)',
103
- pointerEvents: 'none',
104
- zIndex: 50,
105
- };
106
- // 3x3 grid -- the d-pad arms occupy the cross cells, corners stay empty.
107
- const dpadStyle = {
108
- display: 'grid',
109
- gridTemplateColumns: 'repeat(3, 64px)',
110
- gridTemplateRows: 'repeat(3, 64px)',
111
- gap: 6,
112
- pointerEvents: 'auto',
113
- };
114
- const dpadUpStyle = { gridColumn: 2, gridRow: 1 };
115
- const dpadLeftStyle = { gridColumn: 1, gridRow: 2 };
116
- const dpadRightStyle = { gridColumn: 3, gridRow: 2 };
117
- const dpadDownStyle = { gridColumn: 2, gridRow: 3 };
118
- const buttonStyle = {
119
- width: 64,
120
- height: 64,
121
- borderRadius: 12,
122
- border: '1px solid rgba(255, 255, 255, 0.35)',
123
- background: 'rgba(0, 0, 0, 0.35)',
124
- color: 'rgba(255, 255, 255, 0.92)',
125
- fontSize: 24,
126
- fontWeight: 600,
127
- display: 'flex',
128
- alignItems: 'center',
129
- justifyContent: 'center',
130
- userSelect: 'none',
131
- WebkitUserSelect: 'none',
132
- WebkitTouchCallout: 'none',
133
- WebkitTapHighlightColor: 'transparent',
134
- touchAction: 'none',
135
- cursor: 'pointer',
136
- };
@@ -1,136 +0,0 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
2
- // On-screen movement controls: a 4-way d-pad feeds arrow keys onto
3
- // `scene.keys` -- the exact same keyboard keys behaviors consume, no separate
4
- // input path.
5
- const KEYS = {
6
- up: 'ArrowUp',
7
- down: 'ArrowDown',
8
- left: 'ArrowLeft',
9
- right: 'ArrowRight',
10
- };
11
- export function TouchControls({ getKeys, visible = true }) {
12
- const [isTouch, setIsTouch] = useState(false);
13
- const heldRef = useRef(new Set());
14
- useEffect(() => {
15
- const params = new URLSearchParams(window.location.search);
16
- const override = params.get('touch');
17
- if (override === '1' || override === 'true') {
18
- setIsTouch(true);
19
- return undefined;
20
- }
21
- if (override === '0' || override === 'false') {
22
- setIsTouch(false);
23
- return undefined;
24
- }
25
- const mq = window.matchMedia('(pointer: coarse)');
26
- const update = () => {
27
- setIsTouch(mq.matches || 'ontouchstart' in window || navigator.maxTouchPoints > 0);
28
- };
29
- update();
30
- mq.addEventListener?.('change', update);
31
- return () => mq.removeEventListener?.('change', update);
32
- }, []);
33
- const release = useCallback(
34
- (id) => {
35
- if (!heldRef.current.has(id)) return;
36
- heldRef.current.delete(id);
37
- getKeys()?.delete(KEYS[id]);
38
- },
39
- [getKeys]
40
- );
41
- useEffect(() => {
42
- if (!visible || !isTouch) {
43
- // Drop any keys we were holding when controls hide.
44
- const keys = getKeys();
45
- if (keys) for (const id of heldRef.current) keys.delete(KEYS[id]);
46
- heldRef.current.clear();
47
- }
48
- }, [visible, isTouch, getKeys]);
49
- if (!visible || !isTouch) return null;
50
- const press = (id) => {
51
- heldRef.current.add(id);
52
- getKeys()?.add(KEYS[id]);
53
- };
54
- const button = (id, label, extra) => (
55
- <button
56
- key={id}
57
- type="button"
58
- tabIndex={-1}
59
- aria-label={id}
60
- onTouchStart={(event) => {
61
- event.preventDefault();
62
- press(id);
63
- }}
64
- onTouchEnd={(event) => {
65
- event.preventDefault();
66
- release(id);
67
- }}
68
- onTouchCancel={(event) => {
69
- event.preventDefault();
70
- release(id);
71
- }}
72
- onMouseDown={(event) => {
73
- event.preventDefault();
74
- press(id);
75
- }}
76
- onMouseUp={() => release(id)}
77
- onMouseLeave={() => release(id)}
78
- onContextMenu={(event) => event.preventDefault()}
79
- style={{ ...buttonStyle, ...extra }}>
80
- {label}
81
- </button>
82
- );
83
- return (
84
- <div style={containerStyle}>
85
- <div style={dpadStyle}>
86
- {button('up', '▲', dpadUpStyle)}
87
- {button('left', '◀', dpadLeftStyle)}
88
- {button('right', '▶', dpadRightStyle)}
89
- {button('down', '▼', dpadDownStyle)}
90
- </div>
91
- </div>
92
- );
93
- }
94
- const containerStyle = {
95
- position: 'fixed',
96
- left: 0,
97
- right: 0,
98
- bottom: 0,
99
- display: 'flex',
100
- justifyContent: 'flex-start',
101
- alignItems: 'flex-end',
102
- padding: '12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px)',
103
- pointerEvents: 'none',
104
- zIndex: 50,
105
- };
106
- // 3x3 grid -- the d-pad arms occupy the cross cells, corners stay empty.
107
- const dpadStyle = {
108
- display: 'grid',
109
- gridTemplateColumns: 'repeat(3, 64px)',
110
- gridTemplateRows: 'repeat(3, 64px)',
111
- gap: 6,
112
- pointerEvents: 'auto',
113
- };
114
- const dpadUpStyle = { gridColumn: 2, gridRow: 1 };
115
- const dpadLeftStyle = { gridColumn: 1, gridRow: 2 };
116
- const dpadRightStyle = { gridColumn: 3, gridRow: 2 };
117
- const dpadDownStyle = { gridColumn: 2, gridRow: 3 };
118
- const buttonStyle = {
119
- width: 64,
120
- height: 64,
121
- borderRadius: 12,
122
- border: '1px solid rgba(255, 255, 255, 0.35)',
123
- background: 'rgba(0, 0, 0, 0.35)',
124
- color: 'rgba(255, 255, 255, 0.92)',
125
- fontSize: 24,
126
- fontWeight: 600,
127
- display: 'flex',
128
- alignItems: 'center',
129
- justifyContent: 'center',
130
- userSelect: 'none',
131
- WebkitUserSelect: 'none',
132
- WebkitTouchCallout: 'none',
133
- WebkitTapHighlightColor: 'transparent',
134
- touchAction: 'none',
135
- cursor: 'pointer',
136
- };