@remix-gg/three 0.1.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.
Files changed (67) hide show
  1. package/dist/assets.d.ts +36 -0
  2. package/dist/assets.d.ts.map +1 -0
  3. package/dist/assets.js +100 -0
  4. package/dist/audio.d.ts +42 -0
  5. package/dist/audio.d.ts.map +1 -0
  6. package/dist/audio.js +150 -0
  7. package/dist/camera.d.ts +72 -0
  8. package/dist/camera.d.ts.map +1 -0
  9. package/dist/camera.js +120 -0
  10. package/dist/collide.d.ts +111 -0
  11. package/dist/collide.d.ts.map +1 -0
  12. package/dist/collide.js +321 -0
  13. package/dist/forgiveness.d.ts +71 -0
  14. package/dist/forgiveness.d.ts.map +1 -0
  15. package/dist/forgiveness.js +85 -0
  16. package/dist/game.d.ts +69 -0
  17. package/dist/game.d.ts.map +1 -0
  18. package/dist/game.js +209 -0
  19. package/dist/hud/index.d.ts +72 -0
  20. package/dist/hud/index.d.ts.map +1 -0
  21. package/dist/hud/index.js +142 -0
  22. package/dist/hud/styles.d.ts +14 -0
  23. package/dist/hud/styles.d.ts.map +1 -0
  24. package/dist/hud/styles.js +142 -0
  25. package/dist/index.d.ts +30 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +27 -0
  28. package/dist/input/gestures.d.ts +124 -0
  29. package/dist/input/gestures.d.ts.map +1 -0
  30. package/dist/input/gestures.js +171 -0
  31. package/dist/input/index.d.ts +30 -0
  32. package/dist/input/index.d.ts.map +1 -0
  33. package/dist/input/index.js +121 -0
  34. package/dist/juice.d.ts +151 -0
  35. package/dist/juice.d.ts.map +1 -0
  36. package/dist/juice.js +237 -0
  37. package/dist/loop.d.ts +27 -0
  38. package/dist/loop.d.ts.map +1 -0
  39. package/dist/loop.js +30 -0
  40. package/dist/platform/index.d.ts +50 -0
  41. package/dist/platform/index.d.ts.map +1 -0
  42. package/dist/platform/index.js +177 -0
  43. package/dist/platform/sdk-contract.d.ts +113 -0
  44. package/dist/platform/sdk-contract.d.ts.map +1 -0
  45. package/dist/platform/sdk-contract.js +18 -0
  46. package/dist/ramp.d.ts +39 -0
  47. package/dist/ramp.d.ts.map +1 -0
  48. package/dist/ramp.js +24 -0
  49. package/dist/random.d.ts +128 -0
  50. package/dist/random.d.ts.map +1 -0
  51. package/dist/random.js +160 -0
  52. package/dist/scene/dispose.d.ts +46 -0
  53. package/dist/scene/dispose.d.ts.map +1 -0
  54. package/dist/scene/dispose.js +108 -0
  55. package/dist/scene/lighting.d.ts +42 -0
  56. package/dist/scene/lighting.d.ts.map +1 -0
  57. package/dist/scene/lighting.js +118 -0
  58. package/dist/scene/pool.d.ts +36 -0
  59. package/dist/scene/pool.d.ts.map +1 -0
  60. package/dist/scene/pool.js +70 -0
  61. package/dist/three.d.ts +2 -0
  62. package/dist/three.d.ts.map +1 -0
  63. package/dist/three.js +11 -0
  64. package/dist/viewport.d.ts +86 -0
  65. package/dist/viewport.d.ts.map +1 -0
  66. package/dist/viewport.js +174 -0
  67. package/package.json +43 -0
@@ -0,0 +1,118 @@
1
+ import { CircleGeometry, Color, DirectionalLight, Fog, HemisphereLight, Mesh, MeshBasicMaterial, PCFSoftShadowMap, Vector3, } from 'three';
2
+ const PRESETS = {
3
+ studio: {
4
+ background: 0x11161d,
5
+ hemiSky: 0xffffff,
6
+ hemiGround: 0x4a5568,
7
+ hemiIntensity: 1.1,
8
+ keyColor: 0xffffff,
9
+ keyIntensity: 2.2,
10
+ keyPosition: [5, 10, 7],
11
+ fog: null,
12
+ },
13
+ sunset: {
14
+ background: 0x2b1b33,
15
+ hemiSky: 0xffb98a,
16
+ hemiGround: 0x2a1a3a,
17
+ hemiIntensity: 1.0,
18
+ keyColor: 0xffd0a0,
19
+ keyIntensity: 2.6,
20
+ keyPosition: [-6, 6, 4],
21
+ fog: [0x2b1b33, 18, 60],
22
+ },
23
+ night: {
24
+ background: 0x060b0f,
25
+ hemiSky: 0xbfd4ff,
26
+ hemiGround: 0x0a0f14,
27
+ hemiIntensity: 0.9,
28
+ keyColor: 0xffffff,
29
+ keyIntensity: 1.2,
30
+ keyPosition: [4, 8, 6],
31
+ fog: [0x060b0f, 14, 40],
32
+ },
33
+ };
34
+ export function applyLighting(scene, preset, options = {}) {
35
+ const spec = PRESETS[preset];
36
+ scene.background = new Color(spec.background);
37
+ scene.fog = spec.fog ? new Fog(spec.fog[0], spec.fog[1], spec.fog[2]) : null;
38
+ const fill = new HemisphereLight(spec.hemiSky, spec.hemiGround, spec.hemiIntensity);
39
+ const key = new DirectionalLight(spec.keyColor, spec.keyIntensity);
40
+ key.position.set(...spec.keyPosition);
41
+ scene.add(fill, key);
42
+ if (options.shadows) {
43
+ // Exactly one shadow-casting light. A second one doubles the depth passes
44
+ // for a difference nobody sees on a 6-inch screen.
45
+ key.castShadow = true;
46
+ key.shadow.mapSize.set(1024, 1024);
47
+ if (options.renderer) {
48
+ options.renderer.shadowMap.enabled = true;
49
+ options.renderer.shadowMap.type = PCFSoftShadowMap;
50
+ }
51
+ const bounds = options.shadowBounds;
52
+ if (bounds) {
53
+ const size = bounds.getSize(new Vector3());
54
+ const radius = Math.max(size.x, size.z) / 2 + 1;
55
+ const camera = key.shadow.camera;
56
+ camera.left = -radius;
57
+ camera.right = radius;
58
+ camera.top = radius;
59
+ camera.bottom = -radius;
60
+ camera.near = 0.5;
61
+ camera.far = Math.max(size.y * 2 + radius * 4, 50);
62
+ camera.updateProjectionMatrix();
63
+ key.target.position.copy(bounds.getCenter(new Vector3()));
64
+ scene.add(key.target);
65
+ }
66
+ }
67
+ return {
68
+ key,
69
+ fill,
70
+ dispose() {
71
+ key.removeFromParent();
72
+ key.target.removeFromParent();
73
+ fill.removeFromParent();
74
+ key.dispose();
75
+ fill.dispose();
76
+ scene.fog = null;
77
+ },
78
+ };
79
+ }
80
+ /**
81
+ * A flat translucent disc that tracks an object's ground position.
82
+ *
83
+ * On a small screen this reads better than a real shadow map and costs one
84
+ * draw call instead of a depth pass — the same trick the Remix avatar already
85
+ * uses in `packages/ui/src/lib/avatar-three.ts`.
86
+ */
87
+ export function blobShadow(target, options = {}) {
88
+ const radius = options.radius ?? 0.72;
89
+ const groundY = options.groundY ?? 0;
90
+ const geometry = new CircleGeometry(radius, 24);
91
+ const material = new MeshBasicMaterial({
92
+ color: 0x000000,
93
+ transparent: true,
94
+ opacity: options.opacity ?? 0.15,
95
+ depthWrite: false,
96
+ });
97
+ const mesh = new Mesh(geometry, material);
98
+ mesh.rotation.x = -Math.PI / 2;
99
+ mesh.renderOrder = -1;
100
+ const world = new Vector3();
101
+ return {
102
+ mesh,
103
+ update() {
104
+ target.getWorldPosition(world);
105
+ mesh.position.set(world.x, groundY + 0.01, world.z);
106
+ // Fade and shrink with height so a jump reads as a jump.
107
+ const height = Math.max(world.y - groundY, 0);
108
+ const falloff = Math.max(1 - height / 8, 0.15);
109
+ mesh.scale.setScalar(falloff);
110
+ material.opacity = (options.opacity ?? 0.15) * falloff;
111
+ },
112
+ dispose() {
113
+ mesh.removeFromParent();
114
+ geometry.dispose();
115
+ material.dispose();
116
+ },
117
+ };
118
+ }
@@ -0,0 +1,36 @@
1
+ import type { Object3D } from 'three';
2
+ export type PoolOptions<T extends Object3D> = {
3
+ /** Hard capacity. `acquire()` returns null once it is reached. */
4
+ size?: number;
5
+ /** Where acquired items are parented. */
6
+ parent?: Object3D;
7
+ /** Called on release, to put an item back in a clean state. */
8
+ reset?: (item: T) => void;
9
+ };
10
+ /**
11
+ * Fixed-capacity object pool.
12
+ *
13
+ * Allocating a mesh mid-game is not the cost — the GC pause when a hundred of
14
+ * them die at once is, and on a phone that pause is a visible hitch. A pool
15
+ * built during `setup` means gameplay never allocates.
16
+ *
17
+ * The capacity is hard on purpose: `acquire()` returning null tells you the
18
+ * budget was wrong, where silent growth would just get slower.
19
+ */
20
+ export declare class Pool<T extends Object3D> {
21
+ private readonly items;
22
+ private readonly idle;
23
+ private readonly live;
24
+ private readonly capacity;
25
+ private readonly parent?;
26
+ private readonly reset?;
27
+ constructor(factory: () => T, options?: PoolOptions<T>);
28
+ get active(): readonly T[];
29
+ get free(): number;
30
+ acquire(): T | null;
31
+ release(item: T): void;
32
+ releaseAll(): void;
33
+ /** Frees every item's GPU resources. The pool is unusable afterwards. */
34
+ dispose(): void;
35
+ }
36
+ //# sourceMappingURL=pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../src/scene/pool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAGrC,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,QAAQ,IAAI;IAC5C,kEAAkE;IAClE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,yCAAyC;IACzC,MAAM,CAAC,EAAE,QAAQ,CAAA;IACjB,+DAA+D;IAC/D,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAC1B,CAAA;AAED;;;;;;;;;GASG;AACH,qBAAa,IAAI,CAAC,CAAC,SAAS,QAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAU;IAChC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAU;IAC/B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAU;IAC/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAmB;gBAE9B,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,GAAE,WAAW,CAAC,CAAC,CAAM;IAa1D,IAAI,MAAM,IAAI,SAAS,CAAC,EAAE,CAEzB;IAED,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,OAAO,IAAI,CAAC,GAAG,IAAI;IASnB,OAAO,CAAC,IAAI,EAAE,CAAC,GAAG,IAAI;IAStB,UAAU,IAAI,IAAI;IAKlB,yEAAyE;IACzE,OAAO,IAAI,IAAI;CAShB"}
@@ -0,0 +1,70 @@
1
+ import { disposeObject } from './dispose.js';
2
+ /**
3
+ * Fixed-capacity object pool.
4
+ *
5
+ * Allocating a mesh mid-game is not the cost — the GC pause when a hundred of
6
+ * them die at once is, and on a phone that pause is a visible hitch. A pool
7
+ * built during `setup` means gameplay never allocates.
8
+ *
9
+ * The capacity is hard on purpose: `acquire()` returning null tells you the
10
+ * budget was wrong, where silent growth would just get slower.
11
+ */
12
+ export class Pool {
13
+ items = [];
14
+ idle = [];
15
+ live = [];
16
+ capacity;
17
+ parent;
18
+ reset;
19
+ constructor(factory, options = {}) {
20
+ this.capacity = Math.max(options.size ?? 32, 0);
21
+ this.parent = options.parent;
22
+ this.reset = options.reset;
23
+ for (let i = 0; i < this.capacity; i++) {
24
+ const item = factory();
25
+ item.visible = false;
26
+ this.items.push(item);
27
+ this.idle.push(item);
28
+ }
29
+ }
30
+ get active() {
31
+ return this.live;
32
+ }
33
+ get free() {
34
+ return this.idle.length;
35
+ }
36
+ acquire() {
37
+ const item = this.idle.pop();
38
+ if (!item)
39
+ return null;
40
+ item.visible = true;
41
+ this.live.push(item);
42
+ if (this.parent && item.parent !== this.parent)
43
+ this.parent.add(item);
44
+ return item;
45
+ }
46
+ release(item) {
47
+ const index = this.live.indexOf(item);
48
+ if (index < 0)
49
+ return;
50
+ this.live.splice(index, 1);
51
+ item.visible = false;
52
+ this.reset?.(item);
53
+ this.idle.push(item);
54
+ }
55
+ releaseAll() {
56
+ // Iterate a copy: `release` splices the live list.
57
+ for (const item of [...this.live])
58
+ this.release(item);
59
+ }
60
+ /** Frees every item's GPU resources. The pool is unusable afterwards. */
61
+ dispose() {
62
+ this.releaseAll();
63
+ for (const item of this.items) {
64
+ item.removeFromParent();
65
+ disposeObject(item);
66
+ }
67
+ this.items.length = 0;
68
+ this.idle.length = 0;
69
+ }
70
+ }
@@ -0,0 +1,2 @@
1
+ export * from 'three';
2
+ //# sourceMappingURL=three.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"three.d.ts","sourceRoot":"","sources":["../src/three.ts"],"names":[],"mappings":"AAUA,cAAc,OAAO,CAAA"}
package/dist/three.js ADDED
@@ -0,0 +1,11 @@
1
+ // The single pinned three (0.185.1), and the ONLY way a Remix game reaches it:
2
+ //
3
+ // import * as THREE from '@remix-gg/three/three'
4
+ //
5
+ // This must stay a FLAT star re-export. `export * as THREE from 'three'` forces
6
+ // the consumer's bundler to materialize the whole module object, which kills
7
+ // tree-shaking: measured in this repo on an identical scene, the flat star
8
+ // bundles to 623,623 B and the namespace re-export to 748,893 B. Wrapping the
9
+ // same scene in `function build(THREE, ...)` is just as bad (752,384 B), which
10
+ // is why nothing in this SDK ever takes THREE as a parameter.
11
+ export * from 'three';
@@ -0,0 +1,86 @@
1
+ import { Vector2 } from 'three';
2
+ import { type SafeAreaInset } from './platform/sdk-contract.js';
3
+ /**
4
+ * Portrait model: the canvas FILLS the viewport and the frustum expands along
5
+ * whichever axis has surplus, so the 720x1080 design box is always fully
6
+ * visible and backgrounds bleed into the overscan. This is the platform's own
7
+ * documented contract — letterboxing to a fixed 2:3 canvas wastes ~30% of a
8
+ * real phone screen (the dev dashboard previews at 390x844, aspect 0.462,
9
+ * against the box's 0.667).
10
+ */
11
+ export type FitMode = 'expand' | 'crop';
12
+ export type DesignBox = {
13
+ width: number;
14
+ height: number;
15
+ };
16
+ export declare const DEFAULT_DESIGN: DesignBox;
17
+ export type Rect = {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ };
23
+ export type FrustumFit = {
24
+ /** Visible width as a multiple of the design width. >= 1 in `expand`. */
25
+ scaleX: number;
26
+ /** Visible height as a multiple of the design height. >= 1 in `expand`. */
27
+ scaleY: number;
28
+ /** Design units bleeding past each vertical edge of the design box. */
29
+ overscanX: number;
30
+ /** Design units bleeding past each horizontal edge of the design box. */
31
+ overscanY: number;
32
+ };
33
+ /**
34
+ * Pure: how far the frustum has to open to satisfy `mode` at `aspect`.
35
+ * Exactly one axis is unscaled; the other absorbs the mismatch.
36
+ */
37
+ export declare function fitFrustum(design: DesignBox, aspect: number, mode: FitMode): FrustumFit;
38
+ /**
39
+ * Pure: the rect, in design units, that is both on-screen and outside the
40
+ * platform's content safe-area inset — clipped to the guaranteed design box.
41
+ * Anything the player must see or tap belongs in here.
42
+ *
43
+ * @param inset in CSS pixels, as `contentSafeAreaInset` delivers it
44
+ */
45
+ export declare function safeRectOf(design: DesignBox, fit: FrustumFit, clientWidth: number, clientHeight: number, inset: SafeAreaInset): Rect;
46
+ export type Viewport = {
47
+ readonly design: DesignBox;
48
+ /** CSS pixels. */
49
+ readonly width: number;
50
+ /** CSS pixels. */
51
+ readonly height: number;
52
+ readonly aspect: number;
53
+ readonly pixelRatio: number;
54
+ readonly scale: {
55
+ readonly x: number;
56
+ readonly y: number;
57
+ };
58
+ readonly overscan: {
59
+ readonly x: number;
60
+ readonly y: number;
61
+ };
62
+ readonly safeArea: SafeAreaInset;
63
+ readonly safeRect: Rect;
64
+ /** Multiplier the DOM HUD uses so type tracks the rendered design box. */
65
+ readonly hudScale: number;
66
+ /** Viewport-relative CSS pixels -> design units. */
67
+ toDesign(clientX: number, clientY: number, out?: Vector2): Vector2;
68
+ /** Viewport-relative CSS pixels -> normalized device coords. */
69
+ toNdc(clientX: number, clientY: number, out?: Vector2): Vector2;
70
+ /** Design units -> normalized device coords (for picking). */
71
+ designToNdc(x: number, y: number, out?: Vector2): Vector2;
72
+ on(event: 'resize', cb: (viewport: Viewport) => void): () => void;
73
+ setSafeArea(inset: SafeAreaInset): void;
74
+ setPixelRatio(ratio: number): void;
75
+ refresh(): void;
76
+ dispose(): void;
77
+ };
78
+ export type ViewportOptions = {
79
+ design?: DesignBox;
80
+ fit?: FitMode;
81
+ maxPixelRatio?: number;
82
+ /** Element whose box the canvas fills. Defaults to the canvas's parent. */
83
+ host?: HTMLElement;
84
+ };
85
+ export declare function createViewport(canvas: HTMLCanvasElement, options?: ViewportOptions): Viewport;
86
+ //# sourceMappingURL=viewport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewport.d.ts","sourceRoot":"","sources":["../src/viewport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,174 @@
1
+ import { Vector2 } from 'three';
2
+ import { ZERO_SAFE_AREA_INSET } from './platform/sdk-contract.js';
3
+ export const DEFAULT_DESIGN = { width: 720, height: 1080 };
4
+ /**
5
+ * The reference density: the design box rendered on a 393 CSS-px-wide phone.
6
+ * The HUD scales against this so 16px reads as 16px on the target device and
7
+ * grows proportionally on a wider window.
8
+ */
9
+ const REFERENCE_PX_PER_UNIT = 393 / 720;
10
+ /**
11
+ * Pure: how far the frustum has to open to satisfy `mode` at `aspect`.
12
+ * Exactly one axis is unscaled; the other absorbs the mismatch.
13
+ */
14
+ export function fitFrustum(design, aspect, mode) {
15
+ const designAspect = design.width / design.height;
16
+ const wider = aspect > designAspect;
17
+ // `crop` shrinks the visible region inside the design box instead of growing
18
+ // past it: nothing bleeds, but the edges of the box are cut off.
19
+ const scaleX = mode === 'crop' ? (wider ? 1 : aspect / designAspect) : wider ? aspect / designAspect : 1;
20
+ const scaleY = mode === 'crop' ? (wider ? designAspect / aspect : 1) : wider ? 1 : designAspect / aspect;
21
+ return {
22
+ scaleX,
23
+ scaleY,
24
+ overscanX: (design.width * scaleX - design.width) / 2,
25
+ overscanY: (design.height * scaleY - design.height) / 2,
26
+ };
27
+ }
28
+ /**
29
+ * Pure: the rect, in design units, that is both on-screen and outside the
30
+ * platform's content safe-area inset — clipped to the guaranteed design box.
31
+ * Anything the player must see or tap belongs in here.
32
+ *
33
+ * @param inset in CSS pixels, as `contentSafeAreaInset` delivers it
34
+ */
35
+ export function safeRectOf(design, fit, clientWidth, clientHeight, inset) {
36
+ const visibleWidth = design.width * fit.scaleX;
37
+ const visibleHeight = design.height * fit.scaleY;
38
+ const originX = (design.width - visibleWidth) / 2;
39
+ const originY = (design.height - visibleHeight) / 2;
40
+ const unitsPerPxX = clientWidth > 0 ? visibleWidth / clientWidth : 0;
41
+ const unitsPerPxY = clientHeight > 0 ? visibleHeight / clientHeight : 0;
42
+ const left = Math.max(originX + inset.left * unitsPerPxX, 0);
43
+ const right = Math.min(originX + visibleWidth - inset.right * unitsPerPxX, design.width);
44
+ const top = Math.max(originY + inset.top * unitsPerPxY, 0);
45
+ const bottom = Math.min(originY + visibleHeight - inset.bottom * unitsPerPxY, design.height);
46
+ return {
47
+ x: left,
48
+ y: top,
49
+ width: Math.max(right - left, 0),
50
+ height: Math.max(bottom - top, 0),
51
+ };
52
+ }
53
+ export function createViewport(canvas, options = {}) {
54
+ const design = options.design ?? DEFAULT_DESIGN;
55
+ const mode = options.fit ?? 'expand';
56
+ const maxPixelRatio = options.maxPixelRatio ?? 2;
57
+ const host = options.host ?? canvas.parentElement;
58
+ const listeners = new Set();
59
+ let safeArea = ZERO_SAFE_AREA_INSET;
60
+ let width = 1;
61
+ let height = 1;
62
+ let fit = fitFrustum(design, 1, mode);
63
+ let safeRect = safeRectOf(design, fit, 1, 1, safeArea);
64
+ let hudScale = 1;
65
+ // DPR is capped, not honoured: an iPhone's DPR 3 means 9x the fragments of
66
+ // DPR 1 for a difference nobody can see at arm's length.
67
+ let pixelRatio = Math.min(devicePixelRatioOf(), maxPixelRatio);
68
+ function measure() {
69
+ const rect = host?.getBoundingClientRect();
70
+ width = Math.max(Math.round(rect?.width ?? window.innerWidth), 1);
71
+ height = Math.max(Math.round(rect?.height ?? window.innerHeight), 1);
72
+ fit = fitFrustum(design, width / height, mode);
73
+ safeRect = safeRectOf(design, fit, width, height, safeArea);
74
+ hudScale = width / (design.width * fit.scaleX) / REFERENCE_PX_PER_UNIT;
75
+ }
76
+ function emit() {
77
+ for (const cb of [...listeners])
78
+ cb(viewport);
79
+ }
80
+ function toFraction(clientX, clientY) {
81
+ const rect = canvas.getBoundingClientRect();
82
+ return {
83
+ fx: rect.width > 0 ? (clientX - rect.left) / rect.width : 0,
84
+ fy: rect.height > 0 ? (clientY - rect.top) / rect.height : 0,
85
+ };
86
+ }
87
+ const viewport = {
88
+ design,
89
+ get width() {
90
+ return width;
91
+ },
92
+ get height() {
93
+ return height;
94
+ },
95
+ get aspect() {
96
+ return width / height;
97
+ },
98
+ get pixelRatio() {
99
+ return pixelRatio;
100
+ },
101
+ get scale() {
102
+ return { x: fit.scaleX, y: fit.scaleY };
103
+ },
104
+ get overscan() {
105
+ return { x: fit.overscanX, y: fit.overscanY };
106
+ },
107
+ get safeArea() {
108
+ return safeArea;
109
+ },
110
+ get safeRect() {
111
+ return safeRect;
112
+ },
113
+ get hudScale() {
114
+ return hudScale;
115
+ },
116
+ toDesign(clientX, clientY, out = new Vector2()) {
117
+ const { fx, fy } = toFraction(clientX, clientY);
118
+ const visibleWidth = design.width * fit.scaleX;
119
+ const visibleHeight = design.height * fit.scaleY;
120
+ return out.set((design.width - visibleWidth) / 2 + fx * visibleWidth, (design.height - visibleHeight) / 2 + fy * visibleHeight);
121
+ },
122
+ toNdc(clientX, clientY, out = new Vector2()) {
123
+ const { fx, fy } = toFraction(clientX, clientY);
124
+ return out.set(fx * 2 - 1, -(fy * 2 - 1));
125
+ },
126
+ designToNdc(x, y, out = new Vector2()) {
127
+ const visibleWidth = design.width * fit.scaleX;
128
+ const visibleHeight = design.height * fit.scaleY;
129
+ const fx = (x - (design.width - visibleWidth) / 2) / visibleWidth;
130
+ const fy = (y - (design.height - visibleHeight) / 2) / visibleHeight;
131
+ return out.set(fx * 2 - 1, -(fy * 2 - 1));
132
+ },
133
+ on(_event, cb) {
134
+ listeners.add(cb);
135
+ return () => {
136
+ listeners.delete(cb);
137
+ };
138
+ },
139
+ setSafeArea(inset) {
140
+ safeArea = inset;
141
+ measure();
142
+ emit();
143
+ },
144
+ setPixelRatio(ratio) {
145
+ pixelRatio = Math.max(Math.min(ratio, maxPixelRatio), 1);
146
+ emit();
147
+ },
148
+ refresh() {
149
+ measure();
150
+ emit();
151
+ },
152
+ dispose() {
153
+ listeners.clear();
154
+ window.removeEventListener('resize', onResize);
155
+ window.removeEventListener('orientationchange', onResize);
156
+ window.visualViewport?.removeEventListener('resize', onResize);
157
+ },
158
+ };
159
+ const onResize = () => {
160
+ // DPR changes when the window moves between displays, so re-read it.
161
+ pixelRatio = Math.min(devicePixelRatioOf(), maxPixelRatio);
162
+ measure();
163
+ emit();
164
+ };
165
+ measure();
166
+ window.addEventListener('resize', onResize);
167
+ window.addEventListener('orientationchange', onResize);
168
+ window.visualViewport?.addEventListener('resize', onResize);
169
+ return viewport;
170
+ }
171
+ function devicePixelRatioOf() {
172
+ const ratio = typeof devicePixelRatio === 'number' ? devicePixelRatio : 1;
173
+ return ratio > 0 ? ratio : 1;
174
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@remix-gg/three",
3
+ "version": "0.1.1",
4
+ "description": "The Remix Three.js SDK for portrait mobile games.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "files": ["dist/**"],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./three": {
18
+ "types": "./dist/three.d.ts",
19
+ "import": "./dist/three.js"
20
+ },
21
+ "./collide": {
22
+ "types": "./dist/collide.d.ts",
23
+ "import": "./dist/collide.js"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "bun run tsc -p tsconfig.build.json && bun run build:flat-types",
28
+ "build:flat-types": "bun run scripts/build-flat-types.ts",
29
+ "test": "bun test --timeout 120000",
30
+ "typecheck": "bun run tsc --noEmit -p tsconfig.json",
31
+ "lint": "bun run biome check ."
32
+ },
33
+ "dependencies": {
34
+ "@types/three": "0.185.4",
35
+ "three": "0.185.1"
36
+ },
37
+ "devDependencies": {
38
+ "@happy-dom/global-registrator": "15.7.4",
39
+ "dts-bundle-generator": "9.5.1",
40
+ "puppeteer": "24.22.0",
41
+ "typescript": "5.8.3"
42
+ }
43
+ }