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.
- package/dist/commonInstructions.d.ts +1 -1
- package/dist/commonInstructions.js +1 -1
- package/kits/basic-2d/CLAUDE.md +1 -1
- package/kits/basic-2d/editors/SceneEditor.jsx +129 -17
- package/kits/basic-2d/editors/SelectionOverlay.jsx +15 -5
- package/kits/basic-2d/engine/ScenePlayer.jsx +159 -12
- package/kits/basic-2d/engine/playConsole.js +66 -0
- package/kits/basic-2d/engine/ui.module.css +117 -0
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-3d/CLAUDE.md +1 -1
- package/kits/basic-3d/editors/SceneEditor.jsx +0 -6
- package/kits/basic-3d/engine/ScenePlayer.jsx +0 -3
- package/package.json +1 -1
- package/kits/basic-2d/engine/TouchControls.jsx +0 -136
- package/kits/basic-3d/engine/TouchControls.jsx +0 -136
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\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
|
-
- **
|
|
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
|
`;
|
package/kits/basic-2d/CLAUDE.md
CHANGED
|
@@ -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`.
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/kits/basic-3d/CLAUDE.md
CHANGED
|
@@ -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`.
|
|
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,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
|
-
};
|