@umicat/three-sdk 0.14.0 → 0.16.0

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/README.md CHANGED
@@ -9,8 +9,9 @@ is what new 3D projects start from.
9
9
 
10
10
  What it has: the scene3d format and loader, physics wiring, a character
11
11
  controller and animator, an input layer that mounts on-screen controls on touch
12
- devices and binds keys everywhere, bone sockets, hit tints, and audio. What it
13
- still does not have: an editor.
12
+ devices and binds keys everywhere, bone sockets, hit tints, audio, and the
13
+ editor's screenshot/video-capture protocol. What it still does not have: an
14
+ editor.
14
15
 
15
16
  > This paragraph said "seed, not a product — no input system, no character
16
17
  > controller, no audio, and no published package" for ten minor versions after
@@ -23,7 +24,7 @@ still does not have: an editor.
23
24
  │
24
25
  @umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
25
26
  CharacterController3D · CharacterAnimator · GameAudio
26
- sockets · tints
27
+ sockets · tints · setupScreenshotListener/setupRecordingListener
27
28
  ▲
28
29
  your game gameplay
29
30
  ```
package/dist/GameAudio.js CHANGED
@@ -230,6 +230,19 @@ export class GameAudio {
230
230
  const ctx = this.ctx;
231
231
  if (this.muted)
232
232
  return;
233
+ // The music may still be waiting for a gesture even though the context is
234
+ // not. `start()` is the only other place that starts it, and it early-exits
235
+ // when the context is ALREADY running — which is what happens in a
236
+ // cross-origin iframe, where the context comes up running and the first
237
+ // `play()` therefore never takes the unlock path. Measured: a game
238
+ // embedded that way had a live context, decoded buffers, working effects
239
+ // and no music at all, because `startMusic()` had never been reached.
240
+ //
241
+ // `play()` is the right place for the second attempt: it is called from
242
+ // inside a real gesture (a button, a move), which is exactly the
243
+ // permission an element needs, and `startMusic` is idempotent.
244
+ if (this.ready && this.musicName && !this.musicEl)
245
+ this.startMusic();
233
246
  if (!this.ready || !ctx || !this.master) {
234
247
  // Not "drop it": this is the unlocking gesture, and the sound it asked
235
248
  // for is the one the player is waiting to hear. `start()` plays it.
@@ -0,0 +1,19 @@
1
+ import type * as THREE from 'three';
2
+ /**
3
+ * Sets up postMessage listeners for video recording of the renderer's
4
+ * canvas. Same wire protocol as `@umicat/phaser-sdk`'s
5
+ * `recording/RecordingManager.ts` — the host doesn't need to know which
6
+ * engine is running.
7
+ *
8
+ * Parent sends: 'startRecording'
9
+ * Game responds: { type: 'recordingStarted' }
10
+ *
11
+ * Parent sends: 'stopRecording'
12
+ * Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
13
+ *
14
+ * `canvas.captureStream()` samples the compositor's output directly, so
15
+ * unlike {@link "./ScreenshotManager.js".takeScreenshot}, it works whether
16
+ * or not the renderer was constructed with `preserveDrawingBuffer` — but the
17
+ * template sets it anyway, for the screenshot path.
18
+ */
19
+ export declare function setupRecordingListener(renderer: THREE.WebGLRenderer): void;
@@ -0,0 +1,67 @@
1
+ let mediaRecorder = null;
2
+ let recordedChunks = [];
3
+ /**
4
+ * Sets up postMessage listeners for video recording of the renderer's
5
+ * canvas. Same wire protocol as `@umicat/phaser-sdk`'s
6
+ * `recording/RecordingManager.ts` — the host doesn't need to know which
7
+ * engine is running.
8
+ *
9
+ * Parent sends: 'startRecording'
10
+ * Game responds: { type: 'recordingStarted' }
11
+ *
12
+ * Parent sends: 'stopRecording'
13
+ * Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
14
+ *
15
+ * `canvas.captureStream()` samples the compositor's output directly, so
16
+ * unlike {@link "./ScreenshotManager.js".takeScreenshot}, it works whether
17
+ * or not the renderer was constructed with `preserveDrawingBuffer` — but the
18
+ * template sets it anyway, for the screenshot path.
19
+ */
20
+ export function setupRecordingListener(renderer) {
21
+ window.addEventListener('message', (event) => {
22
+ if (event.data === 'startRecording') {
23
+ try {
24
+ const canvas = renderer.domElement;
25
+ if (!canvas)
26
+ return;
27
+ recordedChunks = [];
28
+ const stream = canvas.captureStream(30);
29
+ // Pick best available codec.
30
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
31
+ ? 'video/webm;codecs=vp9'
32
+ : 'video/webm';
33
+ mediaRecorder = new MediaRecorder(stream, {
34
+ mimeType,
35
+ videoBitsPerSecond: 1500000, // 1.5 Mbps
36
+ });
37
+ mediaRecorder.ondataavailable = (e) => {
38
+ if (e.data.size > 0)
39
+ recordedChunks.push(e.data);
40
+ };
41
+ mediaRecorder.onstop = () => {
42
+ const blob = new Blob(recordedChunks, { type: 'video/webm' });
43
+ const reader = new FileReader();
44
+ reader.onloadend = () => {
45
+ window.parent.postMessage({
46
+ type: 'recordingComplete',
47
+ data: reader.result,
48
+ mimeType: 'video/webm',
49
+ }, '*');
50
+ };
51
+ reader.readAsDataURL(blob);
52
+ };
53
+ mediaRecorder.start(1000); // collect data every 1 second
54
+ window.parent.postMessage({ type: 'recordingStarted' }, '*');
55
+ }
56
+ catch (e) {
57
+ console.error('[UmicatSDK] Recording failed to start:', e);
58
+ window.parent.postMessage({ type: 'recordingError', error: String(e) }, '*');
59
+ }
60
+ }
61
+ if (event.data === 'stopRecording') {
62
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
63
+ mediaRecorder.stop();
64
+ }
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,22 @@
1
+ import type * as THREE from 'three';
2
+ /**
3
+ * Sets up a postMessage listener that captures the renderer's canvas when the
4
+ * parent window requests a screenshot. Same wire protocol as
5
+ * `@umicat/phaser-sdk`'s `screenshot/ScreenshotManager.ts` — the host
6
+ * (umicat-home-ui) speaks one protocol to both engines and never needs to
7
+ * know which one is running.
8
+ *
9
+ * Parent sends: { type: 'screenshot' }
10
+ * Game responds: { type: 'screenshot_result', dataUrl: string }
11
+ *
12
+ * Requires the renderer to have been constructed with
13
+ * `preserveDrawingBuffer: true` — without it, `canvas.toDataURL()` on a
14
+ * WebGL context can return a blank image depending on exactly when the
15
+ * browser clears the drawing buffer after compositing. This is NOT
16
+ * something the SDK can fix after the fact (it's a renderer-construction-time
17
+ * option), so a game that wants captures to work must pass it when creating
18
+ * its `THREE.WebGLRenderer`.
19
+ */
20
+ export declare function setupScreenshotListener(renderer: THREE.WebGLRenderer): void;
21
+ /** Take a screenshot programmatically. Returns a base64 PNG data URL. */
22
+ export declare function takeScreenshot(renderer: THREE.WebGLRenderer): string | null;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Sets up a postMessage listener that captures the renderer's canvas when the
3
+ * parent window requests a screenshot. Same wire protocol as
4
+ * `@umicat/phaser-sdk`'s `screenshot/ScreenshotManager.ts` — the host
5
+ * (umicat-home-ui) speaks one protocol to both engines and never needs to
6
+ * know which one is running.
7
+ *
8
+ * Parent sends: { type: 'screenshot' }
9
+ * Game responds: { type: 'screenshot_result', dataUrl: string }
10
+ *
11
+ * Requires the renderer to have been constructed with
12
+ * `preserveDrawingBuffer: true` — without it, `canvas.toDataURL()` on a
13
+ * WebGL context can return a blank image depending on exactly when the
14
+ * browser clears the drawing buffer after compositing. This is NOT
15
+ * something the SDK can fix after the fact (it's a renderer-construction-time
16
+ * option), so a game that wants captures to work must pass it when creating
17
+ * its `THREE.WebGLRenderer`.
18
+ */
19
+ export function setupScreenshotListener(renderer) {
20
+ window.addEventListener('message', (event) => {
21
+ if (event.data?.type === 'screenshot') {
22
+ try {
23
+ const dataUrl = renderer.domElement.toDataURL('image/png');
24
+ window.parent.postMessage({ type: 'screenshot_result', dataUrl }, '*');
25
+ }
26
+ catch (e) {
27
+ console.error('[UmicatSDK] Screenshot failed:', e);
28
+ }
29
+ }
30
+ else if (event.data?.type === 'screenshotRegion') {
31
+ // Marquee-to-chat: the host drew a selection rect over the iframe (in
32
+ // iframe-relative CSS px); crop the canvas to it here, where the
33
+ // canvas's on-screen position + intrinsic resolution are known
34
+ // (handles devicePixelRatio scaling — a 3D game always runs 'resize'
35
+ // scale mode, never letterboxed, but pixel ratio still differs from
36
+ // CSS size).
37
+ try {
38
+ const r = event.data.rect;
39
+ const dataUrl = cropCanvasRegion(renderer.domElement, r);
40
+ window.parent.postMessage({ type: 'screenshot_region_result', dataUrl, requestId: event.data.requestId }, '*');
41
+ }
42
+ catch (e) {
43
+ console.error('[UmicatSDK] Region screenshot failed:', e);
44
+ window.parent.postMessage({ type: 'screenshot_region_result', dataUrl: null, requestId: event.data?.requestId }, '*');
45
+ }
46
+ }
47
+ });
48
+ }
49
+ /**
50
+ * Crop the canvas to a rect given in iframe-relative CSS pixels (the host's
51
+ * marquee). Maps display px → intrinsic canvas px via the canvas's bounding
52
+ * rect + resolution, clamps to bounds. Same math as phaser-sdk's
53
+ * `cropCanvasRegion` — deliberately engine-agnostic, it only looks at the
54
+ * canvas element.
55
+ */
56
+ function cropCanvasRegion(canvas, rect) {
57
+ const cr = canvas.getBoundingClientRect();
58
+ if (cr.width <= 0 || cr.height <= 0)
59
+ return null;
60
+ const scale = canvas.width / cr.width; // intrinsic px per displayed px
61
+ let sx = Math.round((rect.x - cr.x) * scale);
62
+ let sy = Math.round((rect.y - cr.y) * scale);
63
+ let sw = Math.round(rect.width * scale);
64
+ let sh = Math.round(rect.height * scale);
65
+ // Clamp to the canvas (the marquee can run past the edges).
66
+ sx = Math.max(0, Math.min(sx, canvas.width - 1));
67
+ sy = Math.max(0, Math.min(sy, canvas.height - 1));
68
+ sw = Math.max(1, Math.min(sw, canvas.width - sx));
69
+ sh = Math.max(1, Math.min(sh, canvas.height - sy));
70
+ const out = document.createElement('canvas');
71
+ out.width = sw;
72
+ out.height = sh;
73
+ const ctx = out.getContext('2d');
74
+ if (!ctx)
75
+ return null;
76
+ ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
77
+ return out.toDataURL('image/png');
78
+ }
79
+ /** Take a screenshot programmatically. Returns a base64 PNG data URL. */
80
+ export function takeScreenshot(renderer) {
81
+ try {
82
+ return renderer.domElement.toDataURL('image/png');
83
+ }
84
+ catch (e) {
85
+ console.error('[UmicatSDK] Screenshot failed:', e);
86
+ return null;
87
+ }
88
+ }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
12
  export { flashTint, updateTints, isTinted } from './Tint.js';
13
13
  export { GameAudio } from './GameAudio.js';
14
14
  export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
15
+ export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
16
+ export { setupRecordingListener } from './capture/RecordingManager.js';
15
17
  export type { Attachment } from './Sockets.js';
16
18
  export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
17
19
  export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ export { Input3D } from './Input3D.js';
12
12
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
13
  export { flashTint, updateTints, isTinted } from './Tint.js';
14
14
  export { GameAudio } from './GameAudio.js';
15
+ export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
16
+ export { setupRecordingListener } from './capture/RecordingManager.js';
15
17
  // Re-exported so a game imports one package for the common case. A game should
16
18
  // not have to know that identity and saves come from a different package than
17
19
  // the renderer.
package/dist/scene3d.d.ts CHANGED
@@ -176,6 +176,14 @@ export interface Manifest3D {
176
176
  /** Identity quaternion, spelled out so callers don't have to remember the order. */
177
177
  export declare const IDENTITY_QUAT: Quat;
178
178
  export declare function vec3(x?: number, y?: number, z?: number): Vec3;
179
+ /**
180
+ * Wrap a single model as a `Manifest3D` so `loadModelAsset` can load it
181
+ * without a caller hand-rolling the boilerplate every time. `scenes`/
182
+ * `initialScene` are unused by `loadModelAsset` (only `loadScene3D` reads
183
+ * them) — left empty rather than optional so the type stays exactly
184
+ * `Manifest3D`, not a new shape callers have to learn.
185
+ */
186
+ export declare function singleModelManifest(model: ModelAsset3D): Manifest3D;
179
187
  /**
180
188
  * Validate a scene enough to fail loudly at author time rather than as a blank
181
189
  * screen at play time. Returns the problems; empty means usable.
package/dist/scene3d.js CHANGED
@@ -26,6 +26,16 @@
26
26
  /** Identity quaternion, spelled out so callers don't have to remember the order. */
27
27
  export const IDENTITY_QUAT = [0, 0, 0, 1];
28
28
  export function vec3(x = 0, y = 0, z = 0) { return { x, y, z }; }
29
+ /**
30
+ * Wrap a single model as a `Manifest3D` so `loadModelAsset` can load it
31
+ * without a caller hand-rolling the boilerplate every time. `scenes`/
32
+ * `initialScene` are unused by `loadModelAsset` (only `loadScene3D` reads
33
+ * them) — left empty rather than optional so the type stays exactly
34
+ * `Manifest3D`, not a new shape callers have to learn.
35
+ */
36
+ export function singleModelManifest(model) {
37
+ return { schemaVersion: 1, initialScene: '', scenes: [], models: [model] };
38
+ }
29
39
  /**
30
40
  * Validate a scene enough to fail loudly at author time rather than as a blank
31
41
  * screen at play time. Returns the problems; empty means usable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",