@descix/frqtl-node 0.0.0-stage → 0.6.0-p0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +87 -0
- package/README.md +84 -2
- package/dist/dist-manifest.json +218 -0
- package/dist/eyes/index.js +109 -0
- package/dist/fat/pkg-node/egpt_wasm.js +1277 -0
- package/dist/fat/pkg-node/egpt_wasm_bg.wasm +0 -0
- package/dist/fat/pkg-node/package.json +12 -0
- package/dist/frqtl/assets/vendor/chroma.mjs +64 -0
- package/dist/frqtl/compat/boot-node.js +34 -0
- package/dist/frqtl/compat/engine.js +127 -0
- package/dist/frqtl/compat/package-root.js +24 -0
- package/dist/frqtl/compat/providers/wasm-engine.js +2501 -0
- package/dist/frqtl/compat/providers/wasm-load-browser.js +134 -0
- package/dist/frqtl/compat/providers/wasm-load-node.js +59 -0
- package/dist/frqtl/compat/renderer/FieldBuilder.js +145 -0
- package/dist/frqtl/compat/renderer/Filters.js +163 -0
- package/dist/frqtl/compat/renderer/RenderFamily.js +85 -0
- package/dist/frqtl/compat/renderer/RenderMesh.js +100 -0
- package/dist/frqtl/compat/renderer/RenderShape.js +154 -0
- package/dist/frqtl/compat/renderer/camera2d-geometry.js +183 -0
- package/dist/frqtl/compat/renderer/captureStill-common.js +259 -0
- package/dist/frqtl/compat/renderer/display-slot.js +96 -0
- package/dist/frqtl/compat/renderer/lo-drawn-center.js +231 -0
- package/dist/frqtl/compat/renderer/orbit-bag.js +216 -0
- package/dist/frqtl/compat/renderer/providers/godrays-layer.js +213 -0
- package/dist/frqtl/compat/renderer/providers/index.js +22 -0
- package/dist/frqtl/compat/renderer/providers/p5-provider.js +861 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-contract.js +160 -0
- package/dist/frqtl/compat/renderer/providers/three-camera-geometry.js +571 -0
- package/dist/frqtl/compat/renderer/providers/three-provider.js +2474 -0
- package/dist/frqtl/compat/renderer/renderCapabilities-common.js +186 -0
- package/dist/frqtl/compat/renderer/renderer-provider-contract.js +238 -0
- package/dist/frqtl/compat/renderer/unprojectStill.js +172 -0
- package/dist/frqtl/compat/vendor.js +196 -0
- package/dist/frqtl/node-entry.js +17 -0
- package/dist/frqtl/objects/FrqtlApp.js +2922 -0
- package/dist/frqtl/objects/ObjectFactory.js +297 -0
- package/dist/frqtl/objects/RecipeContext.js +322 -0
- package/dist/frqtl/objects/collectors/DataCollector.js +116 -0
- package/dist/frqtl/objects/constants/WallBehaviors.js +178 -0
- package/dist/frqtl/objects/geometry/SlabFace.js +258 -0
- package/dist/frqtl/objects/instruments/capture-host.js +453 -0
- package/dist/frqtl/objects/instruments/capture.js +711 -0
- package/dist/frqtl/objects/instruments/hosted-eyes.js +985 -0
- package/dist/frqtl/objects/kinds-entry.js +14 -0
- package/dist/frqtl/objects/kinds.js +190 -0
- package/dist/frqtl/objects/objects/Box.js +273 -0
- package/dist/frqtl/objects/objects/Bullet.js +155 -0
- package/dist/frqtl/objects/objects/CircuitGate.js +380 -0
- package/dist/frqtl/objects/objects/CircuitProbe.js +214 -0
- package/dist/frqtl/objects/objects/DirectedTunnel.js +412 -0
- package/dist/frqtl/objects/objects/EgptFractal.js +164 -0
- package/dist/frqtl/objects/objects/LightSource.js +354 -0
- package/dist/frqtl/objects/objects/QuantumEmitter.js +760 -0
- package/dist/frqtl/objects/objects/Tube.js +471 -0
- package/dist/frqtl/objects/objects/absorbRadiation.js +310 -0
- package/dist/frqtl/objects/objects/actionCompiler.js +1747 -0
- package/dist/frqtl/objects/objects/controlAction.js +89 -0
- package/dist/frqtl/objects/objects/controlBind.js +278 -0
- package/dist/frqtl/objects/objects/directedRedirectProgram.js +120 -0
- package/dist/frqtl/objects/objects/emitterProgram.js +209 -0
- package/dist/frqtl/objects/objects/frqtl-control-slider.js +197 -0
- package/dist/frqtl/objects/objects/frqtl-control-sync.js +272 -0
- package/dist/frqtl/objects/objects/frqtl-control-widgets.js +219 -0
- package/dist/frqtl/objects/objects/frqtl-control-write-queue.js +174 -0
- package/dist/frqtl/objects/objects/makeControlsForObject.js +824 -0
- package/dist/frqtl/objects/objects/makeControlsForRenderSurface.js +161 -0
- package/dist/frqtl/objects/objects/slitLinerScatter.js +263 -0
- package/dist/frqtl/objects/objects/sustainedSource.js +299 -0
- package/dist/frqtl/objects/objects/tagger.js +228 -0
- package/dist/frqtl/objects/schema/engine-scopes.js +469 -0
- package/dist/frqtl/objects/schema/index.js +256 -0
- package/dist/frqtl/objects/schema/scopes.js +457 -0
- package/dist/frqtl/objects/schema/validate-descriptor.js +49 -0
- package/dist/frqtl/objects/schema/valueNormalizer.js +366 -0
- package/dist/frqtl/objects/setup/_collisionMatrixLanes.js +66 -0
- package/dist/frqtl/objects/setup/_setupOptions.js +118 -0
- package/dist/frqtl/objects/setup/_worldInit.js +162 -0
- package/dist/frqtl/objects/setup/setupBlackbodySubstrate.js +242 -0
- package/dist/frqtl/objects/setup/setupCircuitBasic.js +447 -0
- package/dist/frqtl/objects/setup/setupCollisionMatrix.js +188 -0
- package/dist/frqtl/objects/setup/setupConeEmitter3D.js +251 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit.js +327 -0
- package/dist/frqtl/objects/setup/setupDoubleSlit_Laser.js +229 -0
- package/dist/frqtl/objects/setup/setupEmpty.js +59 -0
- package/dist/frqtl/objects/setup/setupFxSmoke.js +209 -0
- package/dist/frqtl/objects/setup/setupG1Race.js +80 -0
- package/dist/frqtl/objects/setup/setupG2Sweep.js +89 -0
- package/dist/frqtl/objects/setup/setupG3Collide.js +177 -0
- package/dist/frqtl/objects/setup/setupLightSourceLab.js +280 -0
- package/dist/frqtl/objects/setup/setupOscillator.js +68 -0
- package/dist/frqtl/objects/setup/setupParticleWalk.js +93 -0
- package/dist/frqtl/objects/setup/setupParticleWalk3D.js +133 -0
- package/dist/frqtl/objects/setup/setupSphereEmitter3D.js +1259 -0
- package/dist/frqtl/objects/setup/setupTraceCircuit.js +239 -0
- package/dist/frqtl/objects/setup/setupTunnelLab.js +242 -0
- package/dist/frqtl/objects/setup/setupTunnelPointExit.js +306 -0
- package/dist/frqtl/objects/setup/setupWaveInterference.js +178 -0
- package/dist/frqtl/objects/v1-replacements.js +30 -0
- package/dist/frqtl/package-core.js +23 -0
- package/dist/frqtl/parts/CameraControls.js +643 -0
- package/dist/frqtl/parts/CameraPath.js +575 -0
- package/dist/frqtl/parts/CollisionActions.js +35 -0
- package/dist/frqtl/parts/CollisionTypes.js +31 -0
- package/dist/frqtl/parts/DetectorWall.js +496 -0
- package/dist/frqtl/parts/FrqtlControlOverlay.js +227 -0
- package/dist/frqtl/parts/FrqtlControlsPanel.js +415 -0
- package/dist/frqtl/parts/FrqtlVision.js +818 -0
- package/dist/frqtl/parts/GraphingDetectorWall.js +490 -0
- package/dist/frqtl/parts/LargeObject.js +406 -0
- package/dist/frqtl/parts/LightColor.js +255 -0
- package/dist/frqtl/parts/LiveChart.js +285 -0
- package/dist/frqtl/parts/LogicGate.js +720 -0
- package/dist/frqtl/parts/OvenBox.js +612 -0
- package/dist/frqtl/parts/PointSource.js +763 -0
- package/dist/frqtl/parts/Rectangle.js +200 -0
- package/dist/frqtl/parts/SlitLightSource.js +234 -0
- package/dist/frqtl/parts/SlitScreen.js +628 -0
- package/dist/frqtl/parts/SphereEmitter.js +946 -0
- package/dist/frqtl/parts/Trace.js +508 -0
- package/dist/frqtl/parts/Tunnel.js +1057 -0
- package/dist/frqtl/parts/Wall.js +1211 -0
- package/dist/frqtl/parts/index.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall-3d.js +33 -0
- package/dist/frqtl/scenes/absorbing-wall.js +32 -0
- package/dist/frqtl/scenes/atomic-model.js +88 -0
- package/dist/frqtl/scenes/big-bang.js +46 -0
- package/dist/frqtl/scenes/blackbody.js +81 -0
- package/dist/frqtl/scenes/circuit-sat.js +127 -0
- package/dist/frqtl/scenes/collision-matrix-3d.js +107 -0
- package/dist/frqtl/scenes/counting-wall-3d.js +36 -0
- package/dist/frqtl/scenes/counting-wall.js +38 -0
- package/dist/frqtl/scenes/double-slit.js +31 -0
- package/dist/frqtl/scenes/dse-one-emitter.js +196 -0
- package/dist/frqtl/scenes/dse-one-slit.js +114 -0
- package/dist/frqtl/scenes/dse-photoelectric.js +70 -0
- package/dist/frqtl/scenes/dse-tunnel-rebuild.js +244 -0
- package/dist/frqtl/scenes/dse-two-emitters.js +196 -0
- package/dist/frqtl/scenes/dse-two-slits.js +132 -0
- package/dist/frqtl/scenes/empty.js +14 -0
- package/dist/frqtl/scenes/laser-buildup.js +49 -0
- package/dist/frqtl/scenes/mirror-3d.js +56 -0
- package/dist/frqtl/scenes/mirror-route.js +140 -0
- package/dist/frqtl/scenes/mirror.js +38 -0
- package/dist/frqtl/scenes/omni-3d.js +27 -0
- package/dist/frqtl/scenes/omni-source.js +22 -0
- package/dist/frqtl/scenes/self-interference.js +39 -0
- package/dist/frqtl/scenes/smoking-gun.js +122 -0
- package/dist/frqtl/scenes/straight-walk.js +22 -0
- package/dist/frqtl/scenes/sun.js +39 -0
- package/dist/frqtl/scenes/two-source.js +37 -0
- package/dist/frqtl/scenes/wave.js +26 -0
- package/dist/shim/bundle/frqnb.js +112 -0
- package/dist/shim/bundle/index.js +121 -0
- package/dist/shim/bundle/indexHtml.js +107 -0
- package/dist/shim/bundle/manifest.js +95 -0
- package/dist/shim/bundle/zip.js +243 -0
- package/dist/shim/bus.js +67 -0
- package/dist/shim/controls/index.js +627 -0
- package/dist/shim/controls/registry.js +274 -0
- package/dist/shim/engine/artifact-shell.js +212 -0
- package/dist/shim/engine/boot-phases.js +44 -0
- package/dist/shim/engine/engine-stack-boot.js +59 -0
- package/dist/shim/engine/router.js +692 -0
- package/dist/shim/experiment-frame/index.js +296 -0
- package/dist/shim/expr/ast-schema.js +369 -0
- package/dist/shim/field-descriptor/index.js +326 -0
- package/dist/shim/frame-data/index.js +224 -0
- package/dist/shim/index.js +67 -0
- package/dist/shim/notebook/canvas-guard.js +82 -0
- package/dist/shim/notebook/catalog.js +35 -0
- package/dist/shim/notebook/format.js +736 -0
- package/dist/shim/notebook/frqtl-setups-importmap.js +81 -0
- package/dist/shim/notebook/kernel.js +672 -0
- package/dist/shim/notebook/knob.js +151 -0
- package/dist/shim/notebook/module-resolver.js +260 -0
- package/dist/shim/notebook/notebook-knobs.js +65 -0
- package/dist/shim/notebook/produced-bindings.js +67 -0
- package/dist/shim/notebook/realm-registry.js +121 -0
- package/dist/shim/notebook/realm-transport.js +73 -0
- package/dist/shim/notebook/setup-builder.js +510 -0
- package/dist/shim/notebook/store.js +159 -0
- package/dist/shim/notebook/vendor-importmap.js +134 -0
- package/dist/shim/notebook/view-builder.js +189 -0
- package/dist/shim/notebook/view-ctx.js +100 -0
- package/dist/shim/notebook/view-handle.js +202 -0
- package/dist/shim/notebook/warmup.js +102 -0
- package/dist/shim/package.json +4 -0
- package/dist/shim/page-params/index.js +214 -0
- package/dist/shim/project/identity.js +112 -0
- package/dist/shim/project/index.js +19 -0
- package/dist/shim/project/link.js +402 -0
- package/dist/shim/project/node-specifier-hook.mjs +35 -0
- package/dist/shim/project/register-node.mjs +17 -0
- package/dist/shim/project/specifiers.js +106 -0
- package/dist/shim/registry/copy-gate.js +110 -0
- package/dist/shim/registry/exhibits.js +455 -0
- package/dist/shim/registry/experiments.js +215 -0
- package/dist/shim/registry/index.js +19 -0
- package/dist/shim/registry/scene-source.js +157 -0
- package/dist/shim/registry/walkthrough.js +807 -0
- package/dist/shim/render/capture-envelope.js +279 -0
- package/dist/shim/render/heat-colormap.js +92 -0
- package/dist/shim/render/render-stages.js +855 -0
- package/dist/shim/render/sun-look.js +33 -0
- package/dist/shim/render-state/apply.js +262 -0
- package/dist/shim/render-state/index.js +74 -0
- package/dist/shim/rpc/index.js +207 -0
- package/dist/shim/runtime/index.js +1023 -0
- package/dist/shim/storage/boot.js +69 -0
- package/dist/shim/storage/index.js +169 -0
- package/dist/shim/storage/local-storage-provider.js +230 -0
- package/dist/shim/storage/make-namespace-store.js +132 -0
- package/dist/shim/storage/modules.js +249 -0
- package/dist/shim/storage/projects.js +150 -0
- package/llms.txt +12 -0
- package/package.json +62 -4
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/* Copyright 2023-2026 by Essam Abadir */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CameraControls.js — a REUSABLE first-person-game-style camera-controls sugar
|
|
5
|
+
* object (ws-emitter-wave Bite 3B, CEO 2026-07-20). ANY 3D experiment page drops
|
|
6
|
+
* it in with ONE call; it drives the existing `runner.cameraControl` capability
|
|
7
|
+
* (the camera-as-sugar / BYO-renderer pattern — `getCameraControl()` →
|
|
8
|
+
* `look({spin,pitch})` ROTATES, `move({forward,strafe})` TRANSLATES) with two
|
|
9
|
+
* on-screen joysticks + arrow/WASD keys.
|
|
10
|
+
*
|
|
11
|
+
* ── Layer 1: API contract ────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* `CameraControls.attach(runner, mountEl, opts) → handle`
|
|
14
|
+
*
|
|
15
|
+
* @param {object} runner — an FrqtlApp. The ONLY coupling: it reads
|
|
16
|
+
* `runner.cameraControl` (the OPTIONAL camera sugar handle the active
|
|
17
|
+
* renderer provider exposes via `getCameraControl()`). The pads mount only
|
|
18
|
+
* for a camera that DECLARES an angular axis — `OrbitBag.hasAngularPose(cam)`
|
|
19
|
+
* (compat/renderer/orbit-bag.js), asked of the handle's own `poseFields`. The
|
|
20
|
+
* 2D p5 camera is a real handle too (`{distance, target}`, no look/move), so
|
|
21
|
+
* handle presence is never the test. NO renderer-sniffing, NO engine, NO
|
|
22
|
+
* per-page coupling — the object knows nothing about which experiment mounts it.
|
|
23
|
+
* @param {HTMLElement} mountEl — the container the two joystick pads are
|
|
24
|
+
* absolutely positioned within (typically the experiment's canvas wrapper).
|
|
25
|
+
* Its `position` is set to `relative` while attached if it is `static`
|
|
26
|
+
* (restored on dispose) so the pads anchor to it.
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {{azimuth?:number,elevation?:number,distance?:number}} [opts.initialOrbit]
|
|
29
|
+
* — applied to the camera at attach (via setOrbit) so the page keeps its
|
|
30
|
+
* default framing. Omitted ⇒ the camera's CURRENT `getOrbit()` is kept.
|
|
31
|
+
* CALLER CONTRACT: name ONLY axes the TARGET camera declares. Every camera
|
|
32
|
+
* publishes its own axes as `cameraControl.poseFields` (the CameraPath idiom:
|
|
33
|
+
* ask what axes EXIST, never what mode the camera is in); an axis the camera
|
|
34
|
+
* does not declare FAILS LOUD inside `setOrbit` (by design, at the provider
|
|
35
|
+
* that owns the axes). A flat camera never gets this far — attach() mounts
|
|
36
|
+
* nothing for it (see the handle below). This
|
|
37
|
+
* widget deliberately does not re-validate or filter the bag: a second
|
|
38
|
+
* enumeration of the pose vocabulary here would be a schema mirror, and
|
|
39
|
+
* silently dropping an axis the caller asked for is the failure mode
|
|
40
|
+
* CameraPath's own check exists to prevent. Derive the bag from
|
|
41
|
+
* `poseFields`; do not hand the same literal to every camera.
|
|
42
|
+
* @param {number} [opts.lookSpeed=90] — deg/sec of screen-relative ROTATION
|
|
43
|
+
* at full LOOK-stick deflection (and full arrow-key hold): x spins (roll),
|
|
44
|
+
* y pitches. Forwarded to `cameraControl.look()`; this widget integrates no
|
|
45
|
+
* angles of its own.
|
|
46
|
+
* @param {number} [opts.moveRate=0.6] — TRANSLATION rate at full MOVE-stick
|
|
47
|
+
* deflection (and full WASD hold), on both the walk and strafe axes, as a
|
|
48
|
+
* FRACTION OF THE CURRENT ORBIT DISTANCE per second. Scale-invariant on
|
|
49
|
+
* purpose — the same pad has to feel right in a 600-px scene and a 3000-px
|
|
50
|
+
* one, exactly like the camera presets scale distance to the scene.
|
|
51
|
+
* @param {number} [opts.dollySpeed=900] — world-px/sec of distance change used
|
|
52
|
+
* ONLY as the fallback walk for a provider whose cameraControl exposes no
|
|
53
|
+
* `move()` translate primitive (see the mapping note below).
|
|
54
|
+
* @param {number} [opts.wheelStep=60] — world-px of distance per wheel notch
|
|
55
|
+
* (wheel/pinch zoom on mountEl). Set 0 to disable wheel zoom.
|
|
56
|
+
* @param {number} [opts.minDistance=20] — dolly/zoom clamp (near).
|
|
57
|
+
* @param {number} [opts.maxDistance=12000] — dolly/zoom clamp (far).
|
|
58
|
+
* @param {boolean} [opts.keyboard=true] — bind arrow + WASD keys. When on,
|
|
59
|
+
* `mountEl` is made focusable (tabindex -1) and focused on attach + on any
|
|
60
|
+
* pointer interaction with the canvas, so REAL key presses (which are only
|
|
61
|
+
* delivered to this iframe while it holds focus) reach the handler — the
|
|
62
|
+
* natural "click the canvas, then the keys drive it" behavior. (tabindex is
|
|
63
|
+
* restored on dispose.)
|
|
64
|
+
* @param {Window|HTMLElement} [opts.keyboardTarget=window] — where key
|
|
65
|
+
* listeners attach. Default `window` receives the bubbled keydown once the
|
|
66
|
+
* focused mount (inside the iframe) delivers it; also lets a headless test
|
|
67
|
+
* dispatch synthetic events straight at window.
|
|
68
|
+
* @param {boolean} [opts.joysticks=true] — render the two on-screen pads.
|
|
69
|
+
* The camera's WHOLE `getOrbit()` bag is published continuously: every NUMERIC field
|
|
70
|
+
* of it is stamped as `data-cam-<field>` on the control container
|
|
71
|
+
* (`data-role="camera-controls"`). The bag is FERRIED, never re-listed: a
|
|
72
|
+
* hand-written {azimuth,elevation,distance} triple here would have silently
|
|
73
|
+
* dropped `roll` the moment the camera declared it.
|
|
74
|
+
* @returns {{
|
|
75
|
+
* dispose():void, // remove pads + listeners + rAF, restore mountEl
|
|
76
|
+
* getState():object, // the camera's whole orbit bag
|
|
77
|
+
* setState(spec):void, // apply an orbit-bag PATCH to the camera + publish
|
|
78
|
+
* sync():void, // re-read the camera's orbit into the hook (e.g. after an external setOrbit)
|
|
79
|
+
* mounted:boolean // false when the camera declares no angular axis (FAIL-SOFT no-op)
|
|
80
|
+
* }} handle. When the camera declares no angular axis (the 2D p5 camera) or there
|
|
81
|
+
* is no camera handle at all, NOTHING mounts and a no-op handle (`mounted:false`,
|
|
82
|
+
* all methods safe) is returned — every caller, including an unconditional
|
|
83
|
+
* walkthrough-rung attach, is right in 2D by construction.
|
|
84
|
+
*
|
|
85
|
+
* ── Input mapping — the STANDARD FPS SPLIT (CEO-ruled 2026-07-26/07-28) ──────
|
|
86
|
+
* The two pads are the two halves of a first-person camera and they do NOT
|
|
87
|
+
* overlap: LOOK rotates, MOVE translates. MOVE never rotates.
|
|
88
|
+
* LOOK pad (bottom-RIGHT) — ROTATION ONLY, and it is SCREEN-RELATIVE.
|
|
89
|
+
* x → SPIN about the view axis (the picture ROLLS — the horizon tilts).
|
|
90
|
+
* y → PITCH about screen-right (the view tilts up/down at a FIXED radius).
|
|
91
|
+
* Both are forwarded as DELTAS to `cameraControl.look({spin, pitch})`.
|
|
92
|
+
* Azimuth is no longer driven directly: spin + pitch generate the whole
|
|
93
|
+
* rotation group (roll 90° then pitch IS yaw), so azimuth is DERIVED by the
|
|
94
|
+
* provider's decomposition. There is no elevation clamp — pitching over the
|
|
95
|
+
* pole is legal, which is what makes any view reachable.
|
|
96
|
+
* MOVE pad (bottom-LEFT) — TRANSLATION ONLY, via `cameraControl.move()`.
|
|
97
|
+
* y → walk forward/back along the view direction (up = into the picture,
|
|
98
|
+
* so objects grow) · x → strafe left/right along screen-right. MOVE is
|
|
99
|
+
* ALWAYS screen-relative: walking forward while pitched down DESCENDS
|
|
100
|
+
* (fly, not FPS ground-walk).
|
|
101
|
+
* Arrow keys → mirror the LOOK pad (←/→ spin/roll, ↑/↓ pitch).
|
|
102
|
+
* W/A/S/D → mirror the MOVE pad (W/S walk, A/D strafe).
|
|
103
|
+
* Wheel/pinch → dolly (distance), independent of the pads.
|
|
104
|
+
* THE STICKS ONLY EVER FLY THE CAMERA — in every state, with nothing selected, an
|
|
105
|
+
* object aimed at, or an object grabbed. There is no object-drive branch: a
|
|
106
|
+
* grabbed object follows the camera because the runner carries it on the camera's
|
|
107
|
+
* own pose notice (`addPoseListener`), not because a stick is routed to it.
|
|
108
|
+
* Space → `runner.grabToggle()` — the aim's grab/place key (STUDIO's
|
|
109
|
+
* stationary reticle; docs/internal/engine/AIM_GRAB_FEEDBACK_CONTRACT.md).
|
|
110
|
+
* Ignored while typing (INPUT/TEXTAREA/SELECT/contenteditable) and
|
|
111
|
+
* on key auto-repeat; a no-op while the runner's aim is off.
|
|
112
|
+
* A state label above the pads (`data-role="aim-state"`) reads the runner's aim
|
|
113
|
+
* state (roaming / grab-ready / grabbed) while the aim is armed.
|
|
114
|
+
* All the geometry lives at the ONE camera authority (the provider's
|
|
115
|
+
* `getCameraControl()` handle: `look` rotates, `move` translates) — this object
|
|
116
|
+
* re-derives NO orbit math and integrates NO angles. A BYO provider whose handle
|
|
117
|
+
* omits `look()` / `move()` is capability-checked by that absence: the affected
|
|
118
|
+
* axis becomes a defined NO-OP (walk falls back to a dolly), never a substituted
|
|
119
|
+
* different rotation.
|
|
120
|
+
* Drive is CONTINUOUS while a pad is held or a key is down (a single rAF loop
|
|
121
|
+
* integrates the current input each frame; it idles — no rAF scheduled — when
|
|
122
|
+
* no input is active, and is torn down on dispose).
|
|
123
|
+
*
|
|
124
|
+
* ── Layer 2: physics behavior ────────────────────────────────────────────────
|
|
125
|
+
* ZERO physics (canonical-bench SU1). This is an OUTPUT-BOUNDARY view control:
|
|
126
|
+
* it only re-points the camera through the renderer's `setOrbit` sugar; it never
|
|
127
|
+
* touches the engine, a frame, a dimension, or emission. Toggling / driving it
|
|
128
|
+
* leaves every physics golden byte-identical — the same guarantee the camera
|
|
129
|
+
* preset dropdown it replaces already had (rendering is downstream of doTick()).
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
import * as OrbitBag from '../compat/renderer/orbit-bag.js';
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// No-op handle returned when the camera declares no angular axis (the 2D p5
|
|
140
|
+
// camera) or there is no camera. All methods are safe to call.
|
|
141
|
+
function noopHandle() {
|
|
142
|
+
return {
|
|
143
|
+
mounted: false,
|
|
144
|
+
dispose: function () {},
|
|
145
|
+
getState: function () { return null; },
|
|
146
|
+
setState: function () {},
|
|
147
|
+
sync: function () {}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function attach(runner, mountEl, opts) {
|
|
152
|
+
opts = opts || {};
|
|
153
|
+
|
|
154
|
+
// ── CAPABILITY CHECK (FAIL-SOFT) ─────────────────────────────────────
|
|
155
|
+
// The pads are the ANGULAR affordance: they mount only for a camera whose own
|
|
156
|
+
// poseFields declare an angular axis (OrbitBag.hasAngularPose — the ONE 3D-ness
|
|
157
|
+
// predicate). A flat camera (2D p5: {distance, target}, no look/move) would get
|
|
158
|
+
// a LOOK pad with no live axis. Never a renderer sniff, never handle presence.
|
|
159
|
+
// That predicate is the ONLY no-op exit: an angular camera that cannot set or read
|
|
160
|
+
// its pose breaks the camera contract and throws.
|
|
161
|
+
var cam = (runner && runner.cameraControl) ? runner.cameraControl : null;
|
|
162
|
+
if (!OrbitBag.hasAngularPose(cam)) { return noopHandle(); }
|
|
163
|
+
if (typeof cam.setOrbit !== 'function' || typeof cam.getOrbit !== 'function') {
|
|
164
|
+
throw new Error('[CameraControls] the camera declares angular axes but its handle has no ' +
|
|
165
|
+
'setOrbit()/getOrbit() — every getCameraControl() handle sets and reads its pose ' +
|
|
166
|
+
'(RENDERER_CONTRACT §1a.2). (FAIL-LOUD.)');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── IDEMPOTENT per mountEl (F4, CEO 2026-07-23) ──────────────────────
|
|
170
|
+
// A second attach() on the SAME container returns the LIVE instance instead of
|
|
171
|
+
// stacking a second pad set + a second window keydown listener. This kills the
|
|
172
|
+
// double-mount race for EVERY caller — a page that wires its own joysticks
|
|
173
|
+
// (a walkthrough rung) AND STUDIO's host mount (F2) both target the same
|
|
174
|
+
// #canvas-wrapper, and whichever runs second must be a no-op. Ordering-
|
|
175
|
+
// independent: no reliance on the host's DOM-timing guard. The marker is cleared
|
|
176
|
+
// in dispose(), so a re-attach after dispose mounts fresh.
|
|
177
|
+
if (mountEl && mountEl.__frqtlCameraHandle && mountEl.__frqtlCameraHandle.mounted) {
|
|
178
|
+
return mountEl.__frqtlCameraHandle;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
var doc = (mountEl && mountEl.ownerDocument) ? mountEl.ownerDocument
|
|
182
|
+
: (typeof document !== 'undefined' ? document : null);
|
|
183
|
+
|
|
184
|
+
var lookSpeed = (typeof opts.lookSpeed === 'number') ? opts.lookSpeed : 90;
|
|
185
|
+
var moveRate = (typeof opts.moveRate === 'number') ? opts.moveRate : 0.6;
|
|
186
|
+
var dollySpeed = (typeof opts.dollySpeed === 'number') ? opts.dollySpeed : 900;
|
|
187
|
+
// Capability check for TRANSLATION (the getCameraControl precedent — the
|
|
188
|
+
// presence of the method IS the check, never a renderer sniff). A provider
|
|
189
|
+
// without move() still gets forward/back as a dolly; its strafe axis is a
|
|
190
|
+
// no-op, because MOVE must never rotate.
|
|
191
|
+
var canTranslate = (typeof cam.move === 'function');
|
|
192
|
+
// Capability check for screen-relative ROTATION, the same idiom: the
|
|
193
|
+
// presence of the method IS the check. A provider without look() gets a
|
|
194
|
+
// defined NO-OP on the LOOK pad — never a substituted azimuth/elevation
|
|
195
|
+
// integration here, which would put orbit geometry back in the widget.
|
|
196
|
+
var canLook = (typeof cam.look === 'function');
|
|
197
|
+
var wheelStep = (typeof opts.wheelStep === 'number') ? opts.wheelStep : 60;
|
|
198
|
+
var minDist = (typeof opts.minDistance === 'number') ? opts.minDistance : 20;
|
|
199
|
+
var maxDist = (typeof opts.maxDistance === 'number') ? opts.maxDistance : 12000;
|
|
200
|
+
var useKeyboard = opts.keyboard !== false;
|
|
201
|
+
var kbTarget = opts.keyboardTarget || (doc ? doc.defaultView : null);
|
|
202
|
+
var useJoysticks = opts.joysticks !== false;
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
// ── Seed the camera framing ──────────────────────────────────────────
|
|
206
|
+
if (opts.initialOrbit) { cam.setOrbit(opts.initialOrbit); }
|
|
207
|
+
|
|
208
|
+
// ── Realm hook + data-attr publishing ────────────────────────────────
|
|
209
|
+
var container = null;
|
|
210
|
+
function publishState() {
|
|
211
|
+
// FERRY the whole bag — never a hand-listed {azimuth,elevation,distance}
|
|
212
|
+
// triple. The retired hand list would have silently omitted `roll` from
|
|
213
|
+
// both the realm hook and the data attrs the moment the camera declared
|
|
214
|
+
// it: the schema-mirror failure mode in miniature — the datum exists,
|
|
215
|
+
// one consumer sees it, another does not.
|
|
216
|
+
var o = cam.getOrbit();
|
|
217
|
+
|
|
218
|
+
if (container) {
|
|
219
|
+
for (var k in o) {
|
|
220
|
+
if (!Object.prototype.hasOwnProperty.call(o, k)) { continue; }
|
|
221
|
+
// Numeric pose axes become data-cam-<axis>; `target` is an object
|
|
222
|
+
// (the walked centre), read off the hook instead.
|
|
223
|
+
if (typeof o[k] !== 'number') { continue; }
|
|
224
|
+
container.setAttribute('data-cam-' + k.toLowerCase(),
|
|
225
|
+
String(Math.round(o[k] * 100) / 100));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return o;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Live input state (joystick vectors + held keys), integrated by rAF ─
|
|
232
|
+
// Each vector component is in [-1, 1]; keyboard contributes ±1 additively.
|
|
233
|
+
var jMove = { x: 0, y: 0 }; // MOVE pad
|
|
234
|
+
var jLook = { x: 0, y: 0 }; // LOOK pad
|
|
235
|
+
var keys = {}; // keyCode → true while held
|
|
236
|
+
|
|
237
|
+
function keyMove() {
|
|
238
|
+
var x = 0, y = 0;
|
|
239
|
+
if (keys.KeyA) { x -= 1; } if (keys.KeyD) { x += 1; } // strafe left/right
|
|
240
|
+
if (keys.KeyW) { y += 1; } if (keys.KeyS) { y -= 1; } // walk forward/back
|
|
241
|
+
return { x: x, y: y };
|
|
242
|
+
}
|
|
243
|
+
function keyLook() {
|
|
244
|
+
var x = 0, y = 0;
|
|
245
|
+
if (keys.ArrowLeft) { x -= 1; } if (keys.ArrowRight) { x += 1; } // spin (roll)
|
|
246
|
+
if (keys.ArrowUp) { y += 1; } if (keys.ArrowDown) { y -= 1; } // pitch
|
|
247
|
+
return { x: x, y: y };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function anyInputActive() {
|
|
251
|
+
if (jMove.x || jMove.y || jLook.x || jLook.y) { return true; }
|
|
252
|
+
for (var k in keys) { if (keys[k]) { return true; } }
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── The single rAF integration loop ──────────────────────────────────
|
|
257
|
+
var rafId = null;
|
|
258
|
+
var lastT = 0;
|
|
259
|
+
var nowFn = (typeof performance !== 'undefined' && performance.now)
|
|
260
|
+
? function () { return performance.now(); } : function () { return Date.now(); };
|
|
261
|
+
var rAF = (typeof requestAnimationFrame === 'function')
|
|
262
|
+
? requestAnimationFrame : function (cb) { return setTimeout(function () { cb(nowFn()); }, 16); };
|
|
263
|
+
var cAF = (typeof cancelAnimationFrame === 'function')
|
|
264
|
+
? cancelAnimationFrame : clearTimeout;
|
|
265
|
+
|
|
266
|
+
// A stick/key/wheel step changes what the frame SHOWS — the camera pose, and with it
|
|
267
|
+
// a grabbed object the runner carries. A running loop paints every frame; a STOPPED one
|
|
268
|
+
// (STUDIO Design) paints only when asked, so ask the runner for one repaint of the
|
|
269
|
+
// CURRENT state. repaint() never advances the tick clock — no physics.
|
|
270
|
+
function paintIfStopped() {
|
|
271
|
+
if (runner && !runner.isRunning && typeof runner.repaint === 'function') { runner.repaint(); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function frame(t) {
|
|
275
|
+
rafId = null;
|
|
276
|
+
var dt = (t - lastT) / 1000;
|
|
277
|
+
lastT = t;
|
|
278
|
+
if (dt <= 0) { dt = 0.016; }
|
|
279
|
+
if (dt > 0.1) { dt = 0.1; } // clamp long gaps (tab switch)
|
|
280
|
+
|
|
281
|
+
var km = keyMove(), kl = keyLook();
|
|
282
|
+
// Combine pad + keyboard, clamped to a unit deflection per channel.
|
|
283
|
+
var lookX = clamp(jLook.x + kl.x, -1, 1); // spin (roll, about the view axis)
|
|
284
|
+
var lookY = clamp(jLook.y + kl.y, -1, 1); // pitch (about screen-right)
|
|
285
|
+
var moveX = clamp(jMove.x + km.x, -1, 1); // strafe (translation, screen-right)
|
|
286
|
+
var moveY = clamp(jMove.y + km.y, -1, 1); // walk (translation, into the picture)
|
|
287
|
+
|
|
288
|
+
var o = cam.getOrbit();
|
|
289
|
+
// ── THE FPS SPLIT (CEO 2026-07-26, screen-relative 2026-07-28) ───
|
|
290
|
+
// LOOK = rotation ONLY, and the widget COMPOSES NOTHING: it hands the
|
|
291
|
+
// provider a screen-relative DELTA (spin about the view axis,
|
|
292
|
+
// pitch about screen-right) and the ONE camera authority does
|
|
293
|
+
// the compose -> rotate -> decompose. The old widget-side
|
|
294
|
+
// `az/el += rate*dt` integration is DELETED: three Euler angles
|
|
295
|
+
// applied in a fixed order do NOT compose the way successive
|
|
296
|
+
// screen-relative rotations do, so integrating them here could
|
|
297
|
+
// not express a rolled pitch at all.
|
|
298
|
+
// MOVE = translation ONLY (y = walk along the view direction, x =
|
|
299
|
+
// strafe along screen-right). MOVE never contributes rotation —
|
|
300
|
+
// that was the mis-wire where BOTH pads orbited when grabbed
|
|
301
|
+
// sideways.
|
|
302
|
+
if (canLook && (lookX || lookY)) {
|
|
303
|
+
cam.look({ spin: lookX * lookSpeed * dt, pitch: lookY * lookSpeed * dt });
|
|
304
|
+
}
|
|
305
|
+
if (moveX || moveY) {
|
|
306
|
+
if (canTranslate) {
|
|
307
|
+
// SCALE-INVARIANT walk: a step is a FRACTION of how far away the
|
|
308
|
+
// camera currently is, the same way the presets scale distance to
|
|
309
|
+
// the scene. A fixed px/sec felt right in one scene and walked
|
|
310
|
+
// clean out of a smaller one in under two seconds.
|
|
311
|
+
var step = Math.max(o.distance, 1) * moveRate * dt;
|
|
312
|
+
cam.move({ forward: moveY * step, strafe: moveX * step });
|
|
313
|
+
} else {
|
|
314
|
+
// FAIL-SOFT for a provider with no translate primitive: forward/back
|
|
315
|
+
// still reads as into/out of the picture (a dolly); strafe is a
|
|
316
|
+
// defined NO-OP rather than a rotation. ONLY distance is written —
|
|
317
|
+
// the LOOK axes are look()'s business, never smuggled in here.
|
|
318
|
+
if (moveY) {
|
|
319
|
+
cam.setOrbit({ distance: clamp(o.distance - moveY * dollySpeed * dt,
|
|
320
|
+
minDist, maxDist) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (lookX || lookY || moveX || moveY) { paintIfStopped(); }
|
|
325
|
+
publishState();
|
|
326
|
+
|
|
327
|
+
if (anyInputActive()) { scheduleFrame(); }
|
|
328
|
+
}
|
|
329
|
+
function scheduleFrame() {
|
|
330
|
+
if (rafId === null) { lastT = nowFn(); rafId = rAF(frame); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Keyboard ─────────────────────────────────────────────────────────
|
|
334
|
+
var HANDLED_KEYS = {
|
|
335
|
+
ArrowLeft: 1, ArrowRight: 1, ArrowUp: 1, ArrowDown: 1,
|
|
336
|
+
KeyW: 1, KeyA: 1, KeyS: 1, KeyD: 1
|
|
337
|
+
};
|
|
338
|
+
function onKeyDown(e) {
|
|
339
|
+
var code = e.code || e.key;
|
|
340
|
+
// Typing never drives: no grab, no camera key, while an editable has the key.
|
|
341
|
+
if ((code === 'Space' || HANDLED_KEYS[code]) && isTypingTarget(e, doc)) { return; }
|
|
342
|
+
if (code === 'Space') {
|
|
343
|
+
if (!runner || typeof runner.grabToggle !== 'function' || !runner.isAimArmed()) { return; }
|
|
344
|
+
if (e.preventDefault) { e.preventDefault(); } // Space is the page-scroll key
|
|
345
|
+
if (e.repeat) { return; } // a held key toggles once
|
|
346
|
+
runner.grabToggle();
|
|
347
|
+
paintIfStopped();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!HANDLED_KEYS[code]) { return; }
|
|
351
|
+
keys[code] = true;
|
|
352
|
+
if (e.preventDefault) { e.preventDefault(); } // stop arrow-key page scroll
|
|
353
|
+
scheduleFrame();
|
|
354
|
+
}
|
|
355
|
+
function onKeyUp(e) {
|
|
356
|
+
var code = e.code || e.key;
|
|
357
|
+
if (!HANDLED_KEYS[code]) { return; }
|
|
358
|
+
keys[code] = false;
|
|
359
|
+
}
|
|
360
|
+
if (useKeyboard && kbTarget && kbTarget.addEventListener) {
|
|
361
|
+
kbTarget.addEventListener('keydown', onKeyDown);
|
|
362
|
+
kbTarget.addEventListener('keyup', onKeyUp);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Canvas focus — so REAL keyboard input reaches this iframe ─────────
|
|
366
|
+
// The experiment page runs INSIDE an iframe. A real, human keydown is only
|
|
367
|
+
// delivered to this window when the iframe holds focus; if the iframe body
|
|
368
|
+
// never receives focus, real arrow/WASD presses fire in the PARENT document
|
|
369
|
+
// and never reach onKeyDown. (The old smoke test passed only because it
|
|
370
|
+
// DISPATCHED synthetic KeyboardEvents straight at window, bypassing focus.)
|
|
371
|
+
// Fix: make the mount focusable and grab focus on attach + on any pointer
|
|
372
|
+
// interaction with the canvas — the natural "click the game canvas, then
|
|
373
|
+
// the keys drive it" behavior. Key delivery still routes through kbTarget
|
|
374
|
+
// (window) via event bubbling once the mount (inside the iframe) is focused.
|
|
375
|
+
var restoreTabIndex = null;
|
|
376
|
+
var onMountPointerDown = null;
|
|
377
|
+
function focusMount() {
|
|
378
|
+
if (mountEl && typeof mountEl.focus === 'function') {
|
|
379
|
+
try { mountEl.focus({ preventScroll: true }); }
|
|
380
|
+
catch (e) { try { mountEl.focus(); } catch (e2) {} }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (useKeyboard && mountEl && mountEl.addEventListener) {
|
|
384
|
+
if (typeof mountEl.tabIndex !== 'number' || mountEl.tabIndex < 0) {
|
|
385
|
+
restoreTabIndex = (mountEl.getAttribute && mountEl.hasAttribute && mountEl.hasAttribute('tabindex'))
|
|
386
|
+
? mountEl.getAttribute('tabindex') : false; // false ⇒ no attr to restore
|
|
387
|
+
mountEl.tabIndex = -1; // focusable, out of tab order
|
|
388
|
+
if (mountEl.style) { mountEl.style.outline = 'none'; }
|
|
389
|
+
}
|
|
390
|
+
// Capture phase so this fires even when a joystick pad handles the same down.
|
|
391
|
+
onMountPointerDown = function () { focusMount(); };
|
|
392
|
+
mountEl.addEventListener('pointerdown', onMountPointerDown, true);
|
|
393
|
+
focusMount(); // best-effort focus at attach
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Aim state label — what the reticle is doing, read off the runner ──────
|
|
397
|
+
// Rendered from runner.getAimState() on the runner's aim notice, so it cannot disagree
|
|
398
|
+
// with what SPACE does. Shown only while the aim is armed (STUDIO Design). Carries
|
|
399
|
+
// data-aim-* attributes so a non-human reader (the VLA loop) reads the same state.
|
|
400
|
+
var aimLabelEl = null;
|
|
401
|
+
var unsubscribeAim = null;
|
|
402
|
+
var AIM_TEXT = {
|
|
403
|
+
'roaming': function () { return 'ROAMING \u2014 aim the reticle at an object'; },
|
|
404
|
+
'grab-ready': function (st) { return 'GRAB-READY ' + (st.hover || 'object') + ' \u2014 press SPACE to grab'; },
|
|
405
|
+
'grabbed': function (st) { return 'GRABBED ' + (st.grabbed || 'object') + ' \u2014 move the world to place it, SPACE to release'; }
|
|
406
|
+
};
|
|
407
|
+
function makeAimLabel() {
|
|
408
|
+
var el = doc.createElement('div');
|
|
409
|
+
el.setAttribute('data-role', 'aim-state');
|
|
410
|
+
el.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);bottom:118px;display:none;' +
|
|
411
|
+
'max-width:70%;padding:4px 8px;border-radius:4px;background:rgba(20,30,38,0.8);' +
|
|
412
|
+
'border:1px solid rgba(255,170,0,0.7);color:rgba(240,240,240,0.95);' +
|
|
413
|
+
'pointer-events:none;z-index:40;font:11px sans-serif;line-height:1.45;text-align:center;';
|
|
414
|
+
return el;
|
|
415
|
+
}
|
|
416
|
+
function renderAimLabel() {
|
|
417
|
+
if (!aimLabelEl) { return; }
|
|
418
|
+
var st = runner.getAimState();
|
|
419
|
+
aimLabelEl.setAttribute('data-aim-state', st.state);
|
|
420
|
+
aimLabelEl.setAttribute('data-aim-target', st.grabbed || st.hover || '');
|
|
421
|
+
aimLabelEl.setAttribute('data-aim-edge', st.atEdge ? 'true' : 'false');
|
|
422
|
+
aimLabelEl.setAttribute('data-aim-heading-held', st.headingHeld ? 'true' : 'false');
|
|
423
|
+
aimLabelEl.setAttribute('data-aim-z-held', st.zHeld ? 'true' : 'false');
|
|
424
|
+
if (st.state === 'off') { aimLabelEl.style.display = 'none'; aimLabelEl.textContent = ''; return; }
|
|
425
|
+
var lines = [AIM_TEXT[st.state](st)];
|
|
426
|
+
if (st.atEdge) { lines.push('AT WORLD EDGE \u2014 held at the universe boundary'); }
|
|
427
|
+
if (st.headingHeld) { lines.push('HEADING HELD \u2014 it points along the depth axis'); }
|
|
428
|
+
if (st.zHeld) { lines.push('Z HELD \u2014 a flat (z-less) wall stays on the z = 0 plane'); }
|
|
429
|
+
// The runner's message names what happened: a carry write that failed (the grab was
|
|
430
|
+
// released) or a grab the object refused (it was never grabbed).
|
|
431
|
+
if (st.error) { lines.push('NOT HELD: ' + st.error); }
|
|
432
|
+
aimLabelEl.textContent = lines.join('\n');
|
|
433
|
+
aimLabelEl.style.whiteSpace = 'pre-line';
|
|
434
|
+
aimLabelEl.style.color = (st.state === 'grabbed') ? '#ffcc66' : 'rgba(240,240,240,0.95)';
|
|
435
|
+
aimLabelEl.style.display = 'block';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── On-screen joystick pads ──────────────────────────────────────────
|
|
439
|
+
var padEls = [];
|
|
440
|
+
var padCleanups = []; // per-pad force-release (used on dispose mid-drag)
|
|
441
|
+
var restoreMountPosition = null;
|
|
442
|
+
var onWheel = null;
|
|
443
|
+
|
|
444
|
+
function makePad(side, label, vec) {
|
|
445
|
+
var pad = doc.createElement('div');
|
|
446
|
+
pad.setAttribute('data-role', 'camera-joystick');
|
|
447
|
+
pad.setAttribute('data-stick', side === 'left' ? 'move' : 'look');
|
|
448
|
+
pad.style.cssText =
|
|
449
|
+
'position:absolute;bottom:16px;' + (side === 'left' ? 'left:16px;' : 'right:16px;') +
|
|
450
|
+
'width:96px;height:96px;border-radius:50%;' +
|
|
451
|
+
'background:rgba(20,30,38,0.55);border:1px solid rgba(126,200,227,0.5);' +
|
|
452
|
+
'touch-action:none;z-index:40;box-sizing:border-box;user-select:none;' +
|
|
453
|
+
'-webkit-user-select:none;cursor:grab;';
|
|
454
|
+
var cap = doc.createElement('div');
|
|
455
|
+
cap.textContent = label;
|
|
456
|
+
cap.style.cssText =
|
|
457
|
+
'position:absolute;top:4px;left:0;right:0;text-align:center;' +
|
|
458
|
+
'color:rgba(126,200,227,0.85);font:10px sans-serif;pointer-events:none;';
|
|
459
|
+
var knob = doc.createElement('div');
|
|
460
|
+
knob.setAttribute('data-role', 'camera-joystick-knob');
|
|
461
|
+
knob.style.cssText =
|
|
462
|
+
'position:absolute;left:50%;top:50%;width:34px;height:34px;margin:-17px 0 0 -17px;' +
|
|
463
|
+
'border-radius:50%;background:rgba(126,200,227,0.85);pointer-events:none;' +
|
|
464
|
+
'transition:transform 0.06s linear;';
|
|
465
|
+
pad.appendChild(cap); pad.appendChild(knob);
|
|
466
|
+
|
|
467
|
+
var R = 48; // pad radius (px); knob travels within it
|
|
468
|
+
var activePointer = null;
|
|
469
|
+
function setFromEvent(e) {
|
|
470
|
+
var rect = pad.getBoundingClientRect();
|
|
471
|
+
var cx = rect.left + rect.width / 2;
|
|
472
|
+
var cy = rect.top + rect.height / 2;
|
|
473
|
+
var dx = (e.clientX - cx);
|
|
474
|
+
var dy = (e.clientY - cy);
|
|
475
|
+
// Normalize to [-1,1]; invert y so "up" is +1 (screen y grows down).
|
|
476
|
+
var nx = clamp(dx / R, -1, 1);
|
|
477
|
+
var ny = clamp(-dy / R, -1, 1);
|
|
478
|
+
vec.x = nx; vec.y = ny;
|
|
479
|
+
knob.style.transform = 'translate(' + (nx * (R - 17)) + 'px,' + (-ny * (R - 17)) + 'px)';
|
|
480
|
+
scheduleFrame();
|
|
481
|
+
}
|
|
482
|
+
// A REAL mouse/touch drag routinely leaves the 96 px pad. Relying on the
|
|
483
|
+
// pad's own pointermove (+ setPointerCapture) is fragile — capture can be
|
|
484
|
+
// silently lost, and without it a pointer outside the pad stops firing pad
|
|
485
|
+
// events, so the drag "dies" and the camera never moves. Instead we track
|
|
486
|
+
// the drag at the DOCUMENT level for its whole lifetime (attached on down,
|
|
487
|
+
// detached on release), which works regardless of where the pointer goes or
|
|
488
|
+
// whether capture is honored. setPointerCapture stays as a harmless bonus.
|
|
489
|
+
var onDocMove = null, onDocUp = null;
|
|
490
|
+
function detachDoc() {
|
|
491
|
+
if (doc && doc.removeEventListener) {
|
|
492
|
+
if (onDocMove) { doc.removeEventListener('pointermove', onDocMove); }
|
|
493
|
+
if (onDocUp) {
|
|
494
|
+
doc.removeEventListener('pointerup', onDocUp);
|
|
495
|
+
doc.removeEventListener('pointercancel', onDocUp);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
onDocMove = null; onDocUp = null;
|
|
499
|
+
}
|
|
500
|
+
function release() {
|
|
501
|
+
if (pad.releasePointerCapture && activePointer !== null) {
|
|
502
|
+
try { pad.releasePointerCapture(activePointer); } catch (err) {}
|
|
503
|
+
}
|
|
504
|
+
activePointer = null;
|
|
505
|
+
vec.x = 0; vec.y = 0;
|
|
506
|
+
knob.style.transform = 'translate(0px,0px)';
|
|
507
|
+
pad.style.cursor = 'grab';
|
|
508
|
+
detachDoc();
|
|
509
|
+
}
|
|
510
|
+
pad.addEventListener('pointerdown', function (e) {
|
|
511
|
+
activePointer = e.pointerId;
|
|
512
|
+
pad.style.cursor = 'grabbing';
|
|
513
|
+
focusMount(); // clicking a joystick focuses the canvas ⇒ keys work too
|
|
514
|
+
if (pad.setPointerCapture) { try { pad.setPointerCapture(e.pointerId); } catch (err) {} }
|
|
515
|
+
// Document-level drag tracking for the life of this gesture.
|
|
516
|
+
onDocMove = function (ev) { if (activePointer === ev.pointerId) { setFromEvent(ev); } };
|
|
517
|
+
onDocUp = function (ev) { if (activePointer === ev.pointerId) { release(); } };
|
|
518
|
+
if (doc && doc.addEventListener) {
|
|
519
|
+
doc.addEventListener('pointermove', onDocMove);
|
|
520
|
+
doc.addEventListener('pointerup', onDocUp);
|
|
521
|
+
doc.addEventListener('pointercancel', onDocUp);
|
|
522
|
+
}
|
|
523
|
+
setFromEvent(e);
|
|
524
|
+
if (e.preventDefault) { e.preventDefault(); }
|
|
525
|
+
});
|
|
526
|
+
padCleanups.push(function () { release(); });
|
|
527
|
+
return pad;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (useJoysticks && doc && mountEl) {
|
|
531
|
+
// Anchor pads to mountEl.
|
|
532
|
+
var computed = (doc.defaultView && doc.defaultView.getComputedStyle)
|
|
533
|
+
? doc.defaultView.getComputedStyle(mountEl).position : mountEl.style.position;
|
|
534
|
+
if (computed === 'static' || !computed) {
|
|
535
|
+
restoreMountPosition = mountEl.style.position || '';
|
|
536
|
+
mountEl.style.position = 'relative';
|
|
537
|
+
}
|
|
538
|
+
container = doc.createElement('div');
|
|
539
|
+
container.setAttribute('data-role', 'camera-controls');
|
|
540
|
+
container.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:39;';
|
|
541
|
+
// Pads themselves re-enable pointer events (container passes clicks through
|
|
542
|
+
// to the canvas everywhere else).
|
|
543
|
+
var movePad = makePad('left', 'MOVE', jMove);
|
|
544
|
+
var lookPad = makePad('right', 'LOOK', jLook);
|
|
545
|
+
movePad.style.pointerEvents = 'auto';
|
|
546
|
+
lookPad.style.pointerEvents = 'auto';
|
|
547
|
+
container.appendChild(movePad);
|
|
548
|
+
container.appendChild(lookPad);
|
|
549
|
+
mountEl.appendChild(container);
|
|
550
|
+
padEls = [movePad, lookPad];
|
|
551
|
+
if (runner && typeof runner.addAimListener === 'function') {
|
|
552
|
+
aimLabelEl = makeAimLabel();
|
|
553
|
+
container.appendChild(aimLabelEl);
|
|
554
|
+
unsubscribeAim = runner.addAimListener(renderAimLabel);
|
|
555
|
+
renderAimLabel();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Wheel/pinch zoom on the mount surface (dolly), independent of the pads.
|
|
559
|
+
if (wheelStep > 0) {
|
|
560
|
+
onWheel = function (e) {
|
|
561
|
+
var o = cam.getOrbit();
|
|
562
|
+
var di = clamp(o.distance + (e.deltaY > 0 ? 1 : -1) * wheelStep, minDist, maxDist);
|
|
563
|
+
cam.setOrbit({ distance: di });
|
|
564
|
+
paintIfStopped();
|
|
565
|
+
publishState();
|
|
566
|
+
if (e.preventDefault) { e.preventDefault(); }
|
|
567
|
+
};
|
|
568
|
+
mountEl.addEventListener('wheel', onWheel, { passive: false });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Publish once so the hook is live immediately (headless can read it
|
|
573
|
+
// before any drive).
|
|
574
|
+
publishState();
|
|
575
|
+
|
|
576
|
+
// ── Handle ───────────────────────────────────────────────────────────
|
|
577
|
+
var liveHandle = {
|
|
578
|
+
mounted: true,
|
|
579
|
+
dispose: function () {
|
|
580
|
+
// F4: release the idempotency marker so a later attach() mounts fresh.
|
|
581
|
+
if (mountEl && mountEl.__frqtlCameraHandle === liveHandle) {
|
|
582
|
+
mountEl.__frqtlCameraHandle = null;
|
|
583
|
+
}
|
|
584
|
+
if (rafId !== null) { cAF(rafId); rafId = null; }
|
|
585
|
+
// Force-release any in-flight joystick drag (detaches its doc listeners).
|
|
586
|
+
for (var i = 0; i < padCleanups.length; i++) {
|
|
587
|
+
try { padCleanups[i](); } catch (e) {}
|
|
588
|
+
}
|
|
589
|
+
padCleanups = [];
|
|
590
|
+
if (useKeyboard && kbTarget && kbTarget.removeEventListener) {
|
|
591
|
+
kbTarget.removeEventListener('keydown', onKeyDown);
|
|
592
|
+
kbTarget.removeEventListener('keyup', onKeyUp);
|
|
593
|
+
}
|
|
594
|
+
if (onMountPointerDown && mountEl && mountEl.removeEventListener) {
|
|
595
|
+
mountEl.removeEventListener('pointerdown', onMountPointerDown, true);
|
|
596
|
+
}
|
|
597
|
+
if (restoreTabIndex !== null && mountEl) {
|
|
598
|
+
if (restoreTabIndex === false) {
|
|
599
|
+
if (mountEl.removeAttribute) { mountEl.removeAttribute('tabindex'); }
|
|
600
|
+
} else if (mountEl.setAttribute) {
|
|
601
|
+
mountEl.setAttribute('tabindex', restoreTabIndex);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (onWheel && mountEl && mountEl.removeEventListener) {
|
|
605
|
+
mountEl.removeEventListener('wheel', onWheel);
|
|
606
|
+
}
|
|
607
|
+
if (unsubscribeAim) { unsubscribeAim(); unsubscribeAim = null; }
|
|
608
|
+
aimLabelEl = null;
|
|
609
|
+
if (container && container.parentNode) { container.parentNode.removeChild(container); }
|
|
610
|
+
if (restoreMountPosition !== null && mountEl) { mountEl.style.position = restoreMountPosition; }
|
|
611
|
+
padEls = [];
|
|
612
|
+
container = null;
|
|
613
|
+
},
|
|
614
|
+
getState: function () { return cam.getOrbit(); },
|
|
615
|
+
setState: function (spec) { cam.setOrbit(spec); publishState(); },
|
|
616
|
+
sync: function () { publishState(); }
|
|
617
|
+
};
|
|
618
|
+
// F4: mark the container as owning this live instance (idempotency guard above).
|
|
619
|
+
if (mountEl) { mountEl.__frqtlCameraHandle = liveHandle; }
|
|
620
|
+
return liveHandle;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── Export ──────────────────────────────────────────────────────────────
|
|
624
|
+
/**
|
|
625
|
+
* isTypingTarget(e, doc) — THE "is the user typing" predicate (one owner): true when the key
|
|
626
|
+
* event's target, or the document's focused element (Monaco types into a hidden textarea while
|
|
627
|
+
* the event lands on the body), is an INPUT / TEXTAREA / SELECT or contenteditable. Every key
|
|
628
|
+
* binding that must not fire while typing asks THIS — the camera's Space + WASD/arrows here,
|
|
629
|
+
* STUDIO's Delete/Backspace in the parent document.
|
|
630
|
+
* @param {{target?:Element}} e @param {Document|null} doc @returns {boolean}
|
|
631
|
+
*/
|
|
632
|
+
function _isEditable(el) {
|
|
633
|
+
var tag = el && el.tagName;
|
|
634
|
+
return !!el && (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true);
|
|
635
|
+
}
|
|
636
|
+
function isTypingTarget(e, doc) {
|
|
637
|
+
return _isEditable(e && e.target) || !!(doc && _isEditable(doc.activeElement));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
var CameraControls = { attach: attach, isTypingTarget: isTypingTarget };
|
|
641
|
+
|
|
642
|
+
export { CameraControls };
|
|
643
|
+
|