@umicat/platform-sdk 0.2.2 → 0.4.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.
@@ -5,6 +5,7 @@ import { RealtimeModule } from '../realtime/RealtimeModule.js';
5
5
  import { AiModule } from '../ai/AiModule.js';
6
6
  import { PlatformModule } from '../platform/PlatformModule.js';
7
7
  import { VoiceModule } from '../voice/VoiceModule.js';
8
+ import { FoldModule } from '../fold/FoldModule.js';
8
9
  import type { UmicatUser } from '../protocol.js';
9
10
  export interface UmicatInitOptions {
10
11
  /** Handshake timeout in ms when running inside a host. Default 5000. */
@@ -46,6 +47,9 @@ export declare class UmicatCore {
46
47
  /** Voice input (speech-to-text) with a live mic level for waveform UIs.
47
48
  * Browser recognition in a browser, native platform recognizer in the app. */
48
49
  readonly voice: VoiceModule;
50
+ /** Where the device's fold falls across the play area. Always present;
51
+ * reports `dualScreen: false` on every surface without a hinge. */
52
+ readonly fold: FoldModule;
49
53
  protected constructor(transport: Transport);
50
54
  /** The current authenticated user, or `null` if anonymous / standalone. */
51
55
  get user(): UmicatUser | null;
@@ -6,6 +6,7 @@ import { RealtimeModule } from '../realtime/RealtimeModule.js';
6
6
  import { AiModule } from '../ai/AiModule.js';
7
7
  import { PlatformModule } from '../platform/PlatformModule.js';
8
8
  import { VoiceModule } from '../voice/VoiceModule.js';
9
+ import { FoldModule } from '../fold/FoldModule.js';
9
10
  /**
10
11
  * Umicat platform services bound to the current (game, user) — with no
11
12
  * rendering engine anywhere in sight.
@@ -48,6 +49,7 @@ export class UmicatCore {
48
49
  this.ai = new AiModule(transport);
49
50
  this.platform = new PlatformModule(transport);
50
51
  this.voice = new VoiceModule(transport);
52
+ this.fold = new FoldModule(transport);
51
53
  }
52
54
  /** The current authenticated user, or `null` if anonymous / standalone. */
53
55
  get user() { return this.transport.user; }
@@ -0,0 +1,58 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ /** A rectangle in CSS pixels, in the game viewport's own coordinate space. */
3
+ export interface FoldRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface FoldLayout {
10
+ /** Whether the play area should present itself as two screens right now. */
11
+ dualScreen: boolean;
12
+ /** Hinge angle in degrees: 0 shut, 180 flat. Absent on a device with no hinge. */
13
+ angle?: number;
14
+ /** The crease, margins INCLUDED. A band, not a line. */
15
+ division?: FoldRect;
16
+ /** The usable panel above the crease. */
17
+ top?: FoldRect;
18
+ /** The usable panel below it. */
19
+ bottom?: FoldRect;
20
+ /**
21
+ * Cut-outs content must not sit under — the front cameras.
22
+ *
23
+ * Only the ACTIVE ones: a folding phone reports a camera on the display it is
24
+ * not currently using, and reserving space for a lens that is facing away is
25
+ * just lost screen.
26
+ */
27
+ occlusions: FoldRect[];
28
+ viewport?: {
29
+ width: number;
30
+ height: number;
31
+ };
32
+ }
33
+ /**
34
+ * Live fold state, and a subscription to changes.
35
+ *
36
+ * Reports `dualScreen: false` forever on a host that never pushes — a browser
37
+ * tab, an older native build, a phone that does not fold. Callers do not
38
+ * feature-detect; they lay out against whatever `layout` currently says.
39
+ */
40
+ export declare class FoldModule {
41
+ private readonly transport;
42
+ private current;
43
+ private readonly listeners;
44
+ constructor(transport: Transport);
45
+ /** What the fold is doing right now. Never null. */
46
+ get layout(): FoldLayout;
47
+ /**
48
+ * Calls `listener` on every change, and ONCE immediately with the current
49
+ * value.
50
+ *
51
+ * The immediate call is the point: a game that only hears about changes comes
52
+ * up laid out for a flat screen on a device that was already folded when it
53
+ * launched, and then looks correct forever after the first hinge movement —
54
+ * which is the worst possible version of this bug, because it fixes itself
55
+ * the moment anyone tries to reproduce it.
56
+ */
57
+ subscribe(listener: (layout: FoldLayout) => void): () => void;
58
+ }
@@ -0,0 +1,87 @@
1
+ const FOLD_EVENT = 'umicat:fold';
2
+ /** The flat, unfolded answer — what every surface without a hinge reports. */
3
+ const NO_FOLD = { dualScreen: false, occlusions: [] };
4
+ /**
5
+ * Live fold state, and a subscription to changes.
6
+ *
7
+ * Reports `dualScreen: false` forever on a host that never pushes — a browser
8
+ * tab, an older native build, a phone that does not fold. Callers do not
9
+ * feature-detect; they lay out against whatever `layout` currently says.
10
+ */
11
+ export class FoldModule {
12
+ constructor(transport) {
13
+ this.transport = transport;
14
+ this.current = NO_FOLD;
15
+ this.listeners = new Set();
16
+ transport.on?.(FOLD_EVENT, (payload) => {
17
+ const next = parse(payload);
18
+ if (!next)
19
+ return;
20
+ this.current = next;
21
+ for (const listener of this.listeners)
22
+ listener(next);
23
+ });
24
+ }
25
+ /** What the fold is doing right now. Never null. */
26
+ get layout() {
27
+ return this.current;
28
+ }
29
+ /**
30
+ * Calls `listener` on every change, and ONCE immediately with the current
31
+ * value.
32
+ *
33
+ * The immediate call is the point: a game that only hears about changes comes
34
+ * up laid out for a flat screen on a device that was already folded when it
35
+ * launched, and then looks correct forever after the first hinge movement —
36
+ * which is the worst possible version of this bug, because it fixes itself
37
+ * the moment anyone tries to reproduce it.
38
+ */
39
+ subscribe(listener) {
40
+ this.listeners.add(listener);
41
+ listener(this.current);
42
+ return () => { this.listeners.delete(listener); };
43
+ }
44
+ }
45
+ function rect(value) {
46
+ if (!value || typeof value !== 'object')
47
+ return undefined;
48
+ const r = value;
49
+ const nums = ['x', 'y', 'width', 'height'].map((k) => r[k]);
50
+ if (nums.some((n) => typeof n !== 'number' || !Number.isFinite(n)))
51
+ return undefined;
52
+ const [x, y, width, height] = nums;
53
+ return { x, y, width, height };
54
+ }
55
+ /**
56
+ * Host payload → `FoldLayout`, or null if it is not one.
57
+ *
58
+ * Validated rather than cast: this crosses a postMessage boundary, and a
59
+ * half-shaped payload that reaches layout code is a crash in the middle of
60
+ * someone's game rather than a line in a console.
61
+ */
62
+ function parse(payload) {
63
+ if (!payload || typeof payload !== 'object')
64
+ return null;
65
+ const p = payload;
66
+ if (typeof p.dualScreen !== 'boolean')
67
+ return null;
68
+ const occlusions = Array.isArray(p.occlusions)
69
+ ? p.occlusions.map(rect).filter((r) => !!r)
70
+ : [];
71
+ const viewport = p.viewport && typeof p.viewport === 'object'
72
+ ? p.viewport
73
+ : undefined;
74
+ const top = rect(p.top);
75
+ const bottom = rect(p.bottom);
76
+ return {
77
+ // A split with only one side is not a split. Guarding here keeps every
78
+ // consumer from having to check both panels before using either.
79
+ dualScreen: p.dualScreen && !!top && !!bottom,
80
+ angle: typeof p.angle === 'number' ? p.angle : undefined,
81
+ division: rect(p.division),
82
+ top,
83
+ bottom,
84
+ occlusions,
85
+ viewport,
86
+ };
87
+ }
@@ -0,0 +1,21 @@
1
+ import type { FoldModule } from './FoldModule.js';
2
+ export declare class ScreensModule {
3
+ /** Above the crease when folded; the whole viewport when flat. */
4
+ readonly top: HTMLElement;
5
+ /** Below the crease. Hidden — but still yours, and still populated — when
6
+ * the device is flat. */
7
+ readonly bottom: HTMLElement;
8
+ private second;
9
+ private readonly listeners;
10
+ constructor(fold: FoldModule);
11
+ /** Whether `bottom` is a real second screen right now. */
12
+ get hasSecondScreen(): boolean;
13
+ /**
14
+ * Calls `listener` on every change, and once immediately — same contract as
15
+ * `FoldModule.subscribe`, and for the same reason: a game told only about
16
+ * CHANGES lays itself out for one screen on a device that was already folded
17
+ * when it launched.
18
+ */
19
+ subscribe(listener: (screens: ScreensModule) => void): () => void;
20
+ private apply;
21
+ }
@@ -0,0 +1,101 @@
1
+ // Two screens a game can put ANYTHING on.
2
+ //
3
+ // `FoldModule` answers "where is the crease"; this answers "here are the two
4
+ // surfaces". A game that wants a map, an inventory, a text box or a second
5
+ // rendered view on the lower screen appends it to `bottom` and is done — the
6
+ // SDK keeps both boxes glued to the panels as the hinge moves.
7
+ //
8
+ // **Both elements always exist, and neither is ever replaced.** The obvious
9
+ // alternative — `bottom: HTMLElement | null`, null when the device is flat —
10
+ // is more honest about the hardware and much worse to use: every game would
11
+ // branch at startup, and anything already appended would be destroyed on the
12
+ // first unfold. Instead `bottom` is simply hidden while there is no second
13
+ // screen, so a game appends once and its content survives folding, unfolding
14
+ // and folding again. `hasSecondScreen` is there for the game that genuinely
15
+ // wants to say something different when there is only one.
16
+ //
17
+ // **`position: fixed` children land inside their screen, not the window.** The
18
+ // containers carry a `transform`, and a transformed ancestor becomes the
19
+ // containing block for fixed descendants. That is what lets an existing HUD —
20
+ // authored as fixed, anchored to "bottom-right" — move onto a panel whole,
21
+ // without touching a single widget.
22
+ /** Pointer-events are off on the container and on for what the game adds, so
23
+ * an empty top screen does not swallow taps meant for the world below it. */
24
+ const BASE_STYLE = {
25
+ position: 'fixed',
26
+ pointerEvents: 'none',
27
+ // Clips to the panel: content must not bleed across the crease, which is the
28
+ // one place on this device where "slightly too tall" is physically bent.
29
+ overflow: 'hidden',
30
+ // Establishes the containing block (see above) AND its own stacking context.
31
+ transform: 'translateZ(0)',
32
+ // Under the SDK's own touch layer (z-index 10), which shares the lower panel.
33
+ zIndex: '5',
34
+ };
35
+ export class ScreensModule {
36
+ constructor(fold) {
37
+ this.second = false;
38
+ this.listeners = new Set();
39
+ this.top = make('top');
40
+ this.bottom = make('bottom');
41
+ fold.subscribe((layout) => this.apply(layout));
42
+ }
43
+ /** Whether `bottom` is a real second screen right now. */
44
+ get hasSecondScreen() {
45
+ return this.second;
46
+ }
47
+ /**
48
+ * Calls `listener` on every change, and once immediately — same contract as
49
+ * `FoldModule.subscribe`, and for the same reason: a game told only about
50
+ * CHANGES lays itself out for one screen on a device that was already folded
51
+ * when it launched.
52
+ */
53
+ subscribe(listener) {
54
+ this.listeners.add(listener);
55
+ listener(this);
56
+ return () => { this.listeners.delete(listener); };
57
+ }
58
+ apply(layout) {
59
+ const split = layout.dualScreen && !!layout.top && !!layout.bottom;
60
+ this.second = split;
61
+ if (split) {
62
+ box(this.top, layout.top);
63
+ box(this.bottom, layout.bottom);
64
+ this.bottom.style.display = '';
65
+ }
66
+ else {
67
+ // Flat: the top screen IS the window, and there is no second one.
68
+ Object.assign(this.top.style, { left: '0', top: '0', width: '100%', height: '100%' });
69
+ this.bottom.style.display = 'none';
70
+ }
71
+ for (const listener of this.listeners)
72
+ listener(this);
73
+ }
74
+ }
75
+ function make(name) {
76
+ // Headless (tests, SSR): hand back a detached stub rather than throwing. A
77
+ // game is never rendering in that case, so the box has nothing to be glued
78
+ // to — but `screens.top` must still be an element, or every caller needs a
79
+ // null check for a situation that only exists in a test runner.
80
+ if (typeof document === 'undefined') {
81
+ return { style: {}, dataset: {}, setAttribute() { }, appendChild() { } };
82
+ }
83
+ const el = document.createElement('div');
84
+ el.dataset.umicatScreen = name;
85
+ // Survives the editor's "hide every static element" sweep, which otherwise
86
+ // takes anything it does not recognise in index.html.
87
+ el.setAttribute('data-umicat-keep', '');
88
+ Object.assign(el.style, BASE_STYLE);
89
+ if (name === 'bottom')
90
+ el.style.display = 'none';
91
+ (document.body ?? document.documentElement).appendChild(el);
92
+ return el;
93
+ }
94
+ function box(el, r) {
95
+ Object.assign(el.style, {
96
+ left: `${r.x}px`,
97
+ top: `${r.y}px`,
98
+ width: `${r.width}px`,
99
+ height: `${r.height}px`,
100
+ });
101
+ }
@@ -0,0 +1,37 @@
1
+ import type { FoldModule, FoldLayout } from './FoldModule.js';
2
+ /**
3
+ * Publishes the fold geometry as CSS custom properties, so a game's own DOM can
4
+ * follow the crease without reading the SDK at all:
5
+ *
6
+ * --umicat-top-height, --umicat-bottom-top, --umicat-bottom-height,
7
+ * --umicat-fold-top, --umicat-fold-height
8
+ *
9
+ * plus `data-umicat-dual-screen` on `<html>`, because a stylesheet cannot ask a
10
+ * module a question.
11
+ *
12
+ * **Engine-agnostic on purpose.** These NAMES are a contract between the SDK and
13
+ * a game's stylesheet — Gomoku's chat panel moves to the lower screen with no
14
+ * JavaScript at all — and a contract with two definitions drifts. It lived in
15
+ * `@umicat/three-sdk` while 3D was the only consumer; it moved here the moment
16
+ * the Phaser side needed the same names.
17
+ *
18
+ * All values are `0` when there is no fold, so a stylesheet that uses them is
19
+ * correct on a flat screen too.
20
+ *
21
+ * **Never give these a fallback in `var()`.** Without the property the whole
22
+ * `calc()` is invalid, the declaration is dropped, and the unfolded rule stands
23
+ * — which is right. A `0px` fallback computes a real but wrong position: for a
24
+ * bottom-anchored element, one off the screen.
25
+ */
26
+ export declare function installFoldVariables(fold: FoldModule): () => void;
27
+ /**
28
+ * Puts a canvas in the panel above the crease, or back where it was.
29
+ *
30
+ * The CSS BOX only — never the drawing buffer. Which half of the job that is
31
+ * depends on the engine: Phaser's scale manager sizes the buffer itself once
32
+ * the box changes, while a three.js game has to be told (`viewportSize`).
33
+ * Shared because the positioning is identical and the trap is too: a buffer
34
+ * sized to the upper panel inside a canvas still stretched across the window
35
+ * draws the picture at twice its height.
36
+ */
37
+ export declare function placeCanvasInPanel(canvas: HTMLCanvasElement, layout: FoldLayout): void;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Publishes the fold geometry as CSS custom properties, so a game's own DOM can
3
+ * follow the crease without reading the SDK at all:
4
+ *
5
+ * --umicat-top-height, --umicat-bottom-top, --umicat-bottom-height,
6
+ * --umicat-fold-top, --umicat-fold-height
7
+ *
8
+ * plus `data-umicat-dual-screen` on `<html>`, because a stylesheet cannot ask a
9
+ * module a question.
10
+ *
11
+ * **Engine-agnostic on purpose.** These NAMES are a contract between the SDK and
12
+ * a game's stylesheet — Gomoku's chat panel moves to the lower screen with no
13
+ * JavaScript at all — and a contract with two definitions drifts. It lived in
14
+ * `@umicat/three-sdk` while 3D was the only consumer; it moved here the moment
15
+ * the Phaser side needed the same names.
16
+ *
17
+ * All values are `0` when there is no fold, so a stylesheet that uses them is
18
+ * correct on a flat screen too.
19
+ *
20
+ * **Never give these a fallback in `var()`.** Without the property the whole
21
+ * `calc()` is invalid, the declaration is dropped, and the unfolded rule stands
22
+ * — which is right. A `0px` fallback computes a real but wrong position: for a
23
+ * bottom-anchored element, one off the screen.
24
+ */
25
+ export function installFoldVariables(fold) {
26
+ return fold.subscribe(publish);
27
+ }
28
+ function publish(layout) {
29
+ if (typeof document === 'undefined')
30
+ return;
31
+ const root = document.documentElement;
32
+ const px = (n) => `${Math.round(n ?? 0)}px`;
33
+ const on = layout.dualScreen;
34
+ root.style.setProperty('--umicat-top-height', px(on ? layout.top?.height : 0));
35
+ root.style.setProperty('--umicat-bottom-top', px(on ? layout.bottom?.y : 0));
36
+ root.style.setProperty('--umicat-bottom-height', px(on ? layout.bottom?.height : 0));
37
+ root.style.setProperty('--umicat-fold-top', px(on ? layout.division?.y : 0));
38
+ root.style.setProperty('--umicat-fold-height', px(on ? layout.division?.height : 0));
39
+ if (on)
40
+ root.dataset.umicatDualScreen = '';
41
+ else
42
+ delete root.dataset.umicatDualScreen;
43
+ }
44
+ /**
45
+ * Puts a canvas in the panel above the crease, or back where it was.
46
+ *
47
+ * The CSS BOX only — never the drawing buffer. Which half of the job that is
48
+ * depends on the engine: Phaser's scale manager sizes the buffer itself once
49
+ * the box changes, while a three.js game has to be told (`viewportSize`).
50
+ * Shared because the positioning is identical and the trap is too: a buffer
51
+ * sized to the upper panel inside a canvas still stretched across the window
52
+ * draws the picture at twice its height.
53
+ */
54
+ export function placeCanvasInPanel(canvas, layout) {
55
+ if (layout.dualScreen && layout.top) {
56
+ Object.assign(canvas.style, {
57
+ position: 'fixed',
58
+ left: `${layout.top.x}px`,
59
+ top: `${layout.top.y}px`,
60
+ width: `${layout.top.width}px`,
61
+ height: `${layout.top.height}px`,
62
+ });
63
+ }
64
+ else {
65
+ Object.assign(canvas.style, { position: '', left: '', top: '', width: '', height: '' });
66
+ }
67
+ }
package/dist/index.d.ts CHANGED
@@ -16,6 +16,9 @@ export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/Rea
16
16
  export { AiModule, Npc } from './ai/AiModule.js';
17
17
  export type { NpcConfig } from './ai/AiModule.js';
18
18
  export { PlatformModule } from './platform/PlatformModule.js';
19
+ export { FoldModule } from './fold/FoldModule.js';
20
+ export { installFoldVariables, placeCanvasInPanel } from './fold/foldVariables.js';
21
+ export type { FoldLayout, FoldRect } from './fold/FoldModule.js';
19
22
  export { VoiceModule, webVoiceSupported } from './voice/VoiceModule.js';
20
23
  export type { VoiceCallbacks, VoiceSession, VoiceLang } from './voice/VoiceModule.js';
21
24
  export { DialogueModule } from './dialogue/DialogueModule.js';
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export { RealtimeModule } from './realtime/RealtimeModule.js';
15
15
  export { UmicatRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UmicatRoom.js';
16
16
  export { AiModule, Npc } from './ai/AiModule.js';
17
17
  export { PlatformModule } from './platform/PlatformModule.js';
18
+ export { FoldModule } from './fold/FoldModule.js';
19
+ export { installFoldVariables, placeCanvasInPanel } from './fold/foldVariables.js';
18
20
  export { VoiceModule, webVoiceSupported } from './voice/VoiceModule.js';
19
21
  export { DialogueModule } from './dialogue/DialogueModule.js';
20
22
  export { DialogueRunner, resolveLangText } from './dialogue/runner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/platform-sdk",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Umicat's platform layer, independent of any rendering engine: identity, cloud saves, shared game data, multiplayer, runtime AI, voice and scripted dialogue. Consumed by @umicat/phaser-sdk and @umicat/three-sdk alike.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",