@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,36 @@
1
+ import { type AnimationClip, type BufferGeometry, type Group, type Material, type Texture } from 'three';
2
+ /**
3
+ * Manifest values MUST be literal strings like `'assets/hero.glb'`.
4
+ *
5
+ * `remix publish` finds every asset a build references with
6
+ * `/(?:\.\/|\/)?assets\/([A-Za-z0-9._-]+)/g` over the bundled code and rewrites
7
+ * each one to its hosted CDN URL. A path built at runtime — `` `assets/${name}` ``
8
+ * — is invisible to that regex, so it uploads nothing and the published game
9
+ * 404s on a file that worked perfectly in dev.
10
+ */
11
+ export type AssetManifest = {
12
+ textures?: Record<string, string>;
13
+ models?: Record<string, string>;
14
+ audio?: Record<string, string>;
15
+ json?: Record<string, string>;
16
+ };
17
+ export type Assets = {
18
+ texture(key: string): Texture;
19
+ /** A fresh clone every call — never mutate the cached original. */
20
+ model(key: string): Group;
21
+ animations(key: string): AnimationClip[];
22
+ sound(key: string): AudioBuffer;
23
+ json<T = unknown>(key: string): T;
24
+ load(manifest: AssetManifest, onProgress?: (loaded: number, total: number) => void): Promise<void>;
25
+ /**
26
+ * Every resource the cache owns. Pass it as `skip` to disposal so a restart
27
+ * never frees a shared asset out from under the next run.
28
+ */
29
+ readonly owned: ReadonlySet<Texture | BufferGeometry | Material>;
30
+ };
31
+ export type AssetsOptions = {
32
+ /** Supplied by the audio bus, which owns the AudioContext. */
33
+ decodeAudio?: (data: ArrayBuffer) => Promise<AudioBuffer>;
34
+ };
35
+ export declare function createAssets(options?: AssetsOptions): Assets;
36
+ //# sourceMappingURL=assets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../src/assets.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,KAAK,EACV,KAAK,QAAQ,EAGb,KAAK,OAAO,EAEb,MAAM,OAAO,CAAA;AAId;;;;;;;;GAQG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC9B,CAAA;AAED,MAAM,MAAM,MAAM,GAAG;IACnB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAC7B,mEAAmE;IACnE,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAAA;IACzB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,EAAE,CAAA;IACxC,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAAA;IAC/B,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,CAAA;IACjC,IAAI,CAAC,QAAQ,EAAE,aAAa,EAAE,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClG;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,OAAO,GAAG,cAAc,GAAG,QAAQ,CAAC,CAAA;CACjE,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,8DAA8D;IAC9D,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,WAAW,CAAC,CAAA;CAC1D,CAAA;AASD,wBAAgB,YAAY,CAAC,OAAO,GAAE,aAAkB,GAAG,MAAM,CA8GhE"}
package/dist/assets.js ADDED
@@ -0,0 +1,100 @@
1
+ import { SRGBColorSpace, TextureLoader, } from 'three';
2
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
+ import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js';
4
+ export function createAssets(options = {}) {
5
+ const loaded = {
6
+ textures: new Map(),
7
+ models: new Map(),
8
+ audio: new Map(),
9
+ json: new Map(),
10
+ };
11
+ const owned = new Set();
12
+ const textureLoader = new TextureLoader();
13
+ const gltfLoader = new GLTFLoader();
14
+ function require(map, key, kind) {
15
+ const value = map.get(key);
16
+ if (value === undefined) {
17
+ throw new Error(`Asset '${key}' is not a loaded ${kind}. Add it to the manifest passed to createGame.`);
18
+ }
19
+ return value;
20
+ }
21
+ function ownResources(root) {
22
+ root.traverse((node) => {
23
+ const renderable = node;
24
+ if (renderable.geometry)
25
+ owned.add(renderable.geometry);
26
+ const material = renderable.material;
27
+ if (!material)
28
+ return;
29
+ for (const entry of Array.isArray(material) ? material : [material]) {
30
+ owned.add(entry);
31
+ for (const value of Object.values(entry)) {
32
+ if (value?.isTexture)
33
+ owned.add(value);
34
+ }
35
+ }
36
+ });
37
+ }
38
+ async function loadTexture(key, url) {
39
+ const texture = await textureLoader.loadAsync(url);
40
+ // Colour textures are authored in sRGB; leaving three's default linear
41
+ // interpretation is why untagged textures look washed out.
42
+ texture.colorSpace = SRGBColorSpace;
43
+ texture.anisotropy = 4;
44
+ loaded.textures.set(key, texture);
45
+ owned.add(texture);
46
+ }
47
+ async function loadModel(key, url) {
48
+ // No Draco or KTX2: their decoders are side-loaded .wasm/.js files, and a
49
+ // published Remix game is a single self-contained HTML document.
50
+ const gltf = await gltfLoader.loadAsync(url);
51
+ loaded.models.set(key, { scene: gltf.scene, animations: gltf.animations });
52
+ ownResources(gltf.scene);
53
+ }
54
+ async function loadAudio(key, url) {
55
+ if (!options.decodeAudio)
56
+ return;
57
+ const response = await fetch(url);
58
+ if (!response.ok)
59
+ throw new Error(`Failed to load audio '${key}' from ${url}`);
60
+ loaded.audio.set(key, await options.decodeAudio(await response.arrayBuffer()));
61
+ }
62
+ async function loadJson(key, url) {
63
+ const response = await fetch(url);
64
+ if (!response.ok)
65
+ throw new Error(`Failed to load json '${key}' from ${url}`);
66
+ loaded.json.set(key, await response.json());
67
+ }
68
+ return {
69
+ texture: (key) => require(loaded.textures, key, 'texture'),
70
+ model(key) {
71
+ const entry = require(loaded.models, key, 'model');
72
+ // SkeletonUtils.clone, not Object3D.clone: the plain clone shares the
73
+ // skeleton, so two clones of a rigged model animate as one.
74
+ return cloneSkinned(entry.scene);
75
+ },
76
+ animations: (key) => require(loaded.models, key, 'model').animations,
77
+ sound: (key) => require(loaded.audio, key, 'sound'),
78
+ json: (key) => require(loaded.json, key, 'json'),
79
+ async load(manifest, onProgress) {
80
+ const jobs = [];
81
+ for (const [key, url] of Object.entries(manifest.textures ?? {}))
82
+ jobs.push(() => loadTexture(key, url));
83
+ for (const [key, url] of Object.entries(manifest.models ?? {}))
84
+ jobs.push(() => loadModel(key, url));
85
+ for (const [key, url] of Object.entries(manifest.audio ?? {}))
86
+ jobs.push(() => loadAudio(key, url));
87
+ for (const [key, url] of Object.entries(manifest.json ?? {}))
88
+ jobs.push(() => loadJson(key, url));
89
+ const total = jobs.length;
90
+ let done = 0;
91
+ onProgress?.(0, total);
92
+ await Promise.all(jobs.map(async (job) => {
93
+ await job();
94
+ done++;
95
+ onProgress?.(done, total);
96
+ }));
97
+ },
98
+ owned,
99
+ };
100
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Two buses over one AudioContext: music and sfx.
3
+ *
4
+ * The separation exists because the platform's mute is not a volume control —
5
+ * `toggle_mute` is a player-level "this game is silent now" and must take
6
+ * effect within a frame, while the game keeps its own music/sfx balance
7
+ * untouched underneath so unmuting restores exactly what was there.
8
+ */
9
+ export type PlaySfxOptions = {
10
+ volume?: number;
11
+ /** Playback rate. Randomising it slightly stops repeated hits sounding fake. */
12
+ rate?: number;
13
+ /** -1 left .. 1 right. */
14
+ pan?: number;
15
+ };
16
+ export type PlayMusicOptions = {
17
+ volume?: number;
18
+ loop?: boolean;
19
+ /** Seconds. */
20
+ fadeIn?: number;
21
+ };
22
+ export type AudioBus = {
23
+ /**
24
+ * Resumes the context. Mobile browsers start it suspended and only a real
25
+ * user gesture may resume it, so `createGame` calls this on the first press.
26
+ */
27
+ unlock(): Promise<void>;
28
+ decode(data: ArrayBuffer): Promise<AudioBuffer>;
29
+ playSfx(key: string, options?: PlaySfxOptions): void;
30
+ playMusic(key: string, options?: PlayMusicOptions): void;
31
+ stopMusic(fadeOut?: number): void;
32
+ musicVolume: number;
33
+ sfxVolume: number;
34
+ readonly muted: boolean;
35
+ setMuted(muted: boolean): void;
36
+ dispose(): void;
37
+ };
38
+ export type AudioOptions = {
39
+ sound: (key: string) => AudioBuffer | undefined;
40
+ };
41
+ export declare function createAudio(options: AudioOptions): AudioBus;
42
+ //# sourceMappingURL=audio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../src/audio.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0BAA0B;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,eAAe;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,QAAQ,GAAG;IACrB;;;OAGG;IACH,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,MAAM,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IACpD,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IACxD,SAAS,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAA;IAC9B,OAAO,IAAI,IAAI,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,WAAW,GAAG,SAAS,CAAA;CAChD,CAAA;AASD,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,QAAQ,CAwJ3D"}
package/dist/audio.js ADDED
@@ -0,0 +1,150 @@
1
+ export function createAudio(options) {
2
+ let graph = null;
3
+ let musicSource = null;
4
+ let musicVolume = 0.6;
5
+ let sfxVolume = 1;
6
+ let muted = false;
7
+ function ensure() {
8
+ if (graph)
9
+ return graph;
10
+ const Ctor = window.AudioContext ??
11
+ window.webkitAudioContext;
12
+ if (!Ctor)
13
+ throw new Error('Web Audio is unavailable in this runtime');
14
+ const context = new Ctor();
15
+ const master = context.createGain();
16
+ const music = context.createGain();
17
+ const sfx = context.createGain();
18
+ master.gain.value = muted ? 0 : 1;
19
+ music.gain.value = musicVolume;
20
+ sfx.gain.value = sfxVolume;
21
+ music.connect(master);
22
+ sfx.connect(master);
23
+ master.connect(context.destination);
24
+ graph = { context, master, music, sfx };
25
+ return graph;
26
+ }
27
+ const onVisibility = () => {
28
+ if (!graph)
29
+ return;
30
+ // Suspending on hide is not politeness: an iOS WebView that keeps an active
31
+ // context in the background gets its audio session killed outright.
32
+ if (document.visibilityState === 'hidden')
33
+ void graph.context.suspend();
34
+ else if (!muted)
35
+ void graph.context.resume();
36
+ };
37
+ document.addEventListener('visibilitychange', onVisibility);
38
+ return {
39
+ async unlock() {
40
+ const { context } = ensure();
41
+ if (context.state !== 'running')
42
+ await context.resume();
43
+ },
44
+ decode(data) {
45
+ return ensure().context.decodeAudioData(data);
46
+ },
47
+ playSfx(key, sfxOptions = {}) {
48
+ const buffer = options.sound(key);
49
+ if (!buffer || muted)
50
+ return;
51
+ const { context, sfx } = ensure();
52
+ if (context.state !== 'running')
53
+ return;
54
+ const source = context.createBufferSource();
55
+ source.buffer = buffer;
56
+ source.playbackRate.value = sfxOptions.rate ?? 1;
57
+ const gain = context.createGain();
58
+ gain.gain.value = sfxOptions.volume ?? 1;
59
+ if (sfxOptions.pan !== undefined && context.createStereoPanner) {
60
+ const panner = context.createStereoPanner();
61
+ panner.pan.value = Math.max(-1, Math.min(1, sfxOptions.pan));
62
+ source.connect(gain).connect(panner).connect(sfx);
63
+ }
64
+ else {
65
+ source.connect(gain).connect(sfx);
66
+ }
67
+ source.start();
68
+ // One-shots are fire-and-forget; without this the nodes stay referenced.
69
+ source.onended = () => {
70
+ source.disconnect();
71
+ gain.disconnect();
72
+ };
73
+ },
74
+ playMusic(key, musicOptions = {}) {
75
+ const buffer = options.sound(key);
76
+ if (!buffer)
77
+ return;
78
+ const { context, music } = ensure();
79
+ this.stopMusic(0);
80
+ const source = context.createBufferSource();
81
+ source.buffer = buffer;
82
+ source.loop = musicOptions.loop ?? true;
83
+ source.connect(music);
84
+ const target = musicOptions.volume ?? musicVolume;
85
+ const fade = musicOptions.fadeIn ?? 0;
86
+ if (fade > 0) {
87
+ music.gain.cancelScheduledValues(context.currentTime);
88
+ music.gain.setValueAtTime(0, context.currentTime);
89
+ music.gain.linearRampToValueAtTime(target, context.currentTime + fade);
90
+ }
91
+ else {
92
+ music.gain.value = target;
93
+ }
94
+ source.start();
95
+ musicSource = source;
96
+ },
97
+ stopMusic(fadeOut = 0) {
98
+ const source = musicSource;
99
+ if (!source || !graph)
100
+ return;
101
+ musicSource = null;
102
+ if (fadeOut <= 0) {
103
+ source.stop();
104
+ source.disconnect();
105
+ return;
106
+ }
107
+ const { context, music } = graph;
108
+ music.gain.cancelScheduledValues(context.currentTime);
109
+ music.gain.setValueAtTime(music.gain.value, context.currentTime);
110
+ music.gain.linearRampToValueAtTime(0, context.currentTime + fadeOut);
111
+ source.stop(context.currentTime + fadeOut);
112
+ source.onended = () => source.disconnect();
113
+ },
114
+ get musicVolume() {
115
+ return musicVolume;
116
+ },
117
+ set musicVolume(value) {
118
+ musicVolume = Math.max(0, value);
119
+ if (graph)
120
+ graph.music.gain.value = musicVolume;
121
+ },
122
+ get sfxVolume() {
123
+ return sfxVolume;
124
+ },
125
+ set sfxVolume(value) {
126
+ sfxVolume = Math.max(0, value);
127
+ if (graph)
128
+ graph.sfx.gain.value = sfxVolume;
129
+ },
130
+ get muted() {
131
+ return muted;
132
+ },
133
+ setMuted(next) {
134
+ muted = next;
135
+ if (!graph)
136
+ return;
137
+ // Ramp rather than snap: a hard gain step on a playing buffer clicks.
138
+ const { context, master } = graph;
139
+ master.gain.cancelScheduledValues(context.currentTime);
140
+ master.gain.setValueAtTime(master.gain.value, context.currentTime);
141
+ master.gain.linearRampToValueAtTime(next ? 0 : 1, context.currentTime + 0.05);
142
+ },
143
+ dispose() {
144
+ document.removeEventListener('visibilitychange', onVisibility);
145
+ this.stopMusic(0);
146
+ void graph?.context.close();
147
+ graph = null;
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,72 @@
1
+ import { type Box3, OrthographicCamera, PerspectiveCamera, Vector3 } from 'three';
2
+ import type { Viewport } from './viewport.js';
3
+ export type GameCamera = PerspectiveCamera | OrthographicCamera;
4
+ /**
5
+ * A rig owns its camera's resize and per-frame motion, so `createGame` never
6
+ * has to guess what a game meant by "portrait".
7
+ *
8
+ * `resize` is the load-bearing half: it opens the frustum by `viewport.scale`
9
+ * so the design box stays fully visible at any aspect. Pick a rig once and use
10
+ * `fitBounds` instead of hand-tuning coordinates.
11
+ */
12
+ export type CameraRig = {
13
+ readonly camera: GameCamera;
14
+ resize(viewport: Viewport): void;
15
+ update(dt: number): void;
16
+ };
17
+ export type PortraitRigOptions = {
18
+ /** Vertical field of view in degrees at the design aspect. */
19
+ fov?: number;
20
+ position?: Vector3;
21
+ lookAt?: Vector3;
22
+ near?: number;
23
+ far?: number;
24
+ };
25
+ /**
26
+ * The default: a perspective camera looking slightly down at the origin, framed
27
+ * so the design box's height is exactly what `fov` covers at the design aspect.
28
+ */
29
+ export declare function portraitRig(options?: PortraitRigOptions): CameraRig;
30
+ export type OrthoRigOptions = {
31
+ /** World units visible vertically across the design box. */
32
+ frustumHeight?: number;
33
+ /** Yaw around Y. `Math.PI / 4` with the default pitch gives isometric. */
34
+ heading?: number;
35
+ /** Elevation in radians. `Math.atan(1 / Math.SQRT2)` ~= 0.6155 for isometric. */
36
+ pitch?: number;
37
+ distance?: number;
38
+ lookAt?: Vector3;
39
+ near?: number;
40
+ far?: number;
41
+ };
42
+ /** Orthographic rig. Isometric is this rig with `heading: Math.PI / 4`. */
43
+ export declare function orthoRig(options?: OrthoRigOptions): CameraRig;
44
+ export type FollowRigOptions = {
45
+ offset?: Vector3;
46
+ lookOffset?: Vector3;
47
+ /** Seconds to close ~63% of the gap. 0 snaps. */
48
+ smoothing?: number;
49
+ /** Clamps the camera's focus point, e.g. so it never runs past the level. */
50
+ bounds?: Box3;
51
+ fov?: number;
52
+ near?: number;
53
+ far?: number;
54
+ };
55
+ /** Perspective camera that chases an object with frame-rate-independent easing. */
56
+ export declare function followRig(target: Object3DLike, options?: FollowRigOptions): CameraRig;
57
+ /** Structural minimum of `THREE.Object3D` that `followRig` needs. */
58
+ type Object3DLike = {
59
+ position: Vector3;
60
+ };
61
+ export type FitBoundsOptions = {
62
+ /** Slack around the bounds. 1.1 leaves 10%. */
63
+ padding?: number;
64
+ lookAt?: Vector3;
65
+ };
66
+ /**
67
+ * Places `camera` so `bounds` fits on screen at the current viewport, along the
68
+ * camera's existing view direction. Call it after `rig.resize`.
69
+ */
70
+ export declare function fitBounds(camera: GameCamera, bounds: Box3, viewport: Viewport, options?: FitBoundsOptions): void;
71
+ export {};
72
+ //# sourceMappingURL=camera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"camera.d.ts","sourceRoot":"","sources":["../src/camera.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,EAAa,kBAAkB,EAAE,iBAAiB,EAAU,OAAO,EAAE,MAAM,OAAO,CAAA;AACpG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE7C,MAAM,MAAM,UAAU,GAAG,iBAAiB,GAAG,kBAAkB,CAAA;AAE/D;;;;;;;GAOG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;IAC3B,MAAM,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;IAChC,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,8DAA8D;IAC9D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,SAAS,CAwBvE;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,4DAA4D;IAC5D,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,2EAA2E;AAC3E,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,SAAS,CA2BjE;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,IAAI,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS,CAuCzF;AAED,qEAAqE;AACrE,KAAK,YAAY,GAAG;IAAE,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAA;AAEzC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,+CAA+C;IAC/C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,IAAI,EACZ,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,gBAAqB,GAC7B,IAAI,CA0BN"}
package/dist/camera.js ADDED
@@ -0,0 +1,120 @@
1
+ import { MathUtils, OrthographicCamera, PerspectiveCamera, Sphere, Vector3 } from 'three';
2
+ /**
3
+ * The default: a perspective camera looking slightly down at the origin, framed
4
+ * so the design box's height is exactly what `fov` covers at the design aspect.
5
+ */
6
+ export function portraitRig(options = {}) {
7
+ const fov = options.fov ?? 50;
8
+ const position = options.position ?? new Vector3(0, 6, 14);
9
+ const target = options.lookAt ?? new Vector3(0, 1.5, 0);
10
+ const camera = new PerspectiveCamera(fov, 1, options.near ?? 0.1, options.far ?? 300);
11
+ camera.position.copy(position);
12
+ camera.lookAt(target);
13
+ const distance = Math.max(position.distanceTo(target), 1e-3);
14
+ const baseHeight = 2 * distance * Math.tan(MathUtils.degToRad(fov) / 2);
15
+ return {
16
+ camera,
17
+ resize(viewport) {
18
+ camera.aspect = viewport.aspect;
19
+ // Widen the vertical FOV by scale.y. When the window is wider than the
20
+ // design box scale.y is 1 and the extra width comes free from `aspect`.
21
+ const visibleHeight = baseHeight * viewport.scale.y;
22
+ camera.fov = MathUtils.radToDeg(2 * Math.atan(visibleHeight / (2 * distance)));
23
+ camera.updateProjectionMatrix();
24
+ },
25
+ update() { },
26
+ };
27
+ }
28
+ /** Orthographic rig. Isometric is this rig with `heading: Math.PI / 4`. */
29
+ export function orthoRig(options = {}) {
30
+ const frustumHeight = options.frustumHeight ?? 14;
31
+ const heading = options.heading ?? 0;
32
+ const pitch = options.pitch ?? 0.22;
33
+ const distance = options.distance ?? 40;
34
+ const target = options.lookAt ?? new Vector3();
35
+ const camera = new OrthographicCamera(-1, 1, 1, -1, options.near ?? 0.1, options.far ?? 200);
36
+ camera.position
37
+ .set(Math.sin(heading) * Math.cos(pitch), Math.sin(pitch), Math.cos(heading) * Math.cos(pitch))
38
+ .multiplyScalar(distance)
39
+ .add(target);
40
+ camera.lookAt(target);
41
+ return {
42
+ camera,
43
+ resize(viewport) {
44
+ const height = frustumHeight * viewport.scale.y;
45
+ const width = height * viewport.aspect;
46
+ camera.top = height / 2;
47
+ camera.bottom = -height / 2;
48
+ camera.right = width / 2;
49
+ camera.left = -width / 2;
50
+ camera.updateProjectionMatrix();
51
+ },
52
+ update() { },
53
+ };
54
+ }
55
+ /** Perspective camera that chases an object with frame-rate-independent easing. */
56
+ export function followRig(target, options = {}) {
57
+ const offset = options.offset ?? new Vector3(0, 9, 12);
58
+ const lookOffset = options.lookOffset ?? new Vector3(0, 1, 0);
59
+ const smoothing = options.smoothing ?? 0.18;
60
+ const fov = options.fov ?? 50;
61
+ const camera = new PerspectiveCamera(fov, 1, options.near ?? 0.1, options.far ?? 300);
62
+ const focus = target.position.clone();
63
+ const desired = new Vector3();
64
+ const lookTarget = new Vector3();
65
+ const distance = Math.max(offset.length(), 1e-3);
66
+ const baseHeight = 2 * distance * Math.tan(MathUtils.degToRad(fov) / 2);
67
+ focus.copy(target.position);
68
+ if (options.bounds)
69
+ options.bounds.clampPoint(focus, focus);
70
+ camera.position.copy(focus).add(offset);
71
+ camera.lookAt(lookTarget.copy(focus).add(lookOffset));
72
+ return {
73
+ camera,
74
+ resize(viewport) {
75
+ camera.aspect = viewport.aspect;
76
+ camera.fov = MathUtils.radToDeg(2 * Math.atan((baseHeight * viewport.scale.y) / (2 * distance)));
77
+ camera.updateProjectionMatrix();
78
+ },
79
+ update(dt) {
80
+ desired.copy(target.position);
81
+ if (options.bounds)
82
+ options.bounds.clampPoint(desired, desired);
83
+ // Exponential smoothing on dt, not a fixed lerp factor: a fixed factor
84
+ // makes the camera lag differently at 60 Hz and 120 Hz.
85
+ const alpha = smoothing > 0 ? 1 - Math.exp(-dt / smoothing) : 1;
86
+ focus.lerp(desired, Math.min(alpha, 1));
87
+ camera.position.copy(focus).add(offset);
88
+ camera.lookAt(lookTarget.copy(focus).add(lookOffset));
89
+ },
90
+ };
91
+ }
92
+ /**
93
+ * Places `camera` so `bounds` fits on screen at the current viewport, along the
94
+ * camera's existing view direction. Call it after `rig.resize`.
95
+ */
96
+ export function fitBounds(camera, bounds, viewport, options = {}) {
97
+ const padding = options.padding ?? 1.1;
98
+ const sphere = bounds.getBoundingSphere(new Sphere());
99
+ const center = options.lookAt ?? sphere.center;
100
+ const radius = Math.max(sphere.radius * padding, 1e-3);
101
+ if (camera instanceof OrthographicCamera) {
102
+ const halfHeight = (camera.top - camera.bottom) / 2;
103
+ const halfWidth = (camera.right - camera.left) / 2;
104
+ camera.zoom = Math.min(halfHeight / radius, halfWidth / radius);
105
+ camera.updateProjectionMatrix();
106
+ camera.lookAt(center);
107
+ return;
108
+ }
109
+ const vFov = MathUtils.degToRad(camera.fov);
110
+ // The horizontal half-angle is derived from the vertical one and the aspect;
111
+ // taking the max of the two required distances is what keeps a wide object
112
+ // from spilling off the sides of a narrow phone.
113
+ const hFov = 2 * Math.atan(Math.tan(vFov / 2) * viewport.aspect);
114
+ const distance = Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
115
+ const direction = camera.position.clone().sub(center);
116
+ if (direction.lengthSq() < 1e-8)
117
+ direction.set(0, 0.4, 1);
118
+ camera.position.copy(center).addScaledVector(direction.normalize(), distance);
119
+ camera.lookAt(center);
120
+ }
@@ -0,0 +1,111 @@
1
+ import { type Box3, Vector3 } from 'three';
2
+ /**
3
+ * Collision QUERIES, not a physics engine.
4
+ *
5
+ * A solver is deliberately absent: `@dimforge/rapier3d-compat` is 2.0 MB of JS
6
+ * with the wasm inlined as base64, against a ~623 KB three baseline — it would
7
+ * roughly quadruple a published game. Portrait games (hop, stack, dodge,
8
+ * runner) need to know *what is in the way*, and that is composition over
9
+ * three's own `Box3`/`Vector3`. A game that genuinely needs a solver adds
10
+ * Rapier itself and drives it from `update(step)`, which is one line because
11
+ * the loop is already fixed-timestep.
12
+ */
13
+ export type SweepResult = {
14
+ hit: boolean;
15
+ /** Fraction of the motion travelled before contact, 0..1. */
16
+ time: number;
17
+ normal: Vector3;
18
+ };
19
+ /**
20
+ * Swept AABB against a set of static boxes: how far `box` can move along
21
+ * `velocity` before it touches something, and the face it touched.
22
+ *
23
+ * This is the slab method. Discrete overlap tests are what let a fast-moving
24
+ * player tunnel straight through the floor on a slow frame; sweeping cannot.
25
+ *
26
+ * Contact is measured against `contactEpsilon`, and the three states a real
27
+ * game spends its time in all resolve here rather than in the caller:
28
+ *
29
+ * - **resting** (`box.min.y === obstacle.max.y`) reports a hit at `t = 0` with
30
+ * an upward normal, so the caller can cancel gravity and stay grounded;
31
+ * - **sliding along** that same surface — no motion on the contact axis —
32
+ * reports no hit, because touching is not overlapping;
33
+ * - **leaving** it (a jump) reports no hit, because the overlap window closed
34
+ * before this step began.
35
+ */
36
+ export declare function sweepAabb(box: Box3, velocity: Vector3, obstacles: readonly Box3[], contactEpsilon?: number): SweepResult;
37
+ export type KinematicResult = {
38
+ /** Where the box's centre ended up. */
39
+ position: Vector3;
40
+ /** Velocity after cancelling the components that hit something. */
41
+ velocity: Vector3;
42
+ grounded: boolean;
43
+ };
44
+ export type KinematicOptions = {
45
+ /** Sweep/slide passes. 3 handles a corner; more is wasted work. */
46
+ maxIterations?: number;
47
+ /** Gap left at contact, in world units, so the next sweep starts outside. */
48
+ slop?: number;
49
+ /** How close counts as touching — contact, depenetration and grounding. */
50
+ contactEpsilon?: number;
51
+ };
52
+ /**
53
+ * The displacement that pushes `box` out of everything it is currently inside.
54
+ *
55
+ * A sweep cannot help a body that starts overlapping: there is no direction of
56
+ * travel that leaves the obstacle, so every step reports a contact at t=0 and
57
+ * the body never moves. Depenetration is the only way out, and games produce
58
+ * the state constantly — a player authored at `y = platformTop` straddles the
59
+ * surface by half its height, and anything spawned into a scene it did not
60
+ * measure lands inside something.
61
+ *
62
+ * Per obstacle: the smallest of the six axis-aligned exits (the minimum
63
+ * translation vector), applied immediately so the next obstacle is measured
64
+ * against the already-displaced box. Summing every obstacle's MTV instead — the
65
+ * obvious implementation — double-counts a body resting in a floor built from
66
+ * two adjacent tiles, because both tiles ask for the same upward push and the
67
+ * body pops out twice as far as it should.
68
+ *
69
+ * Bodies are pushed to exactly touching, not to touching-plus-a-nudge: contact
70
+ * is a band, and `sweepAabb` already treats a body flush against a surface as
71
+ * resting on it rather than stuck in it.
72
+ *
73
+ * Mutates nothing.
74
+ */
75
+ export declare function depenetrate(box: Box3, obstacles: readonly Box3[], contactEpsilon?: number): Vector3;
76
+ /**
77
+ * Moves a kinematic AABB through static geometry, sliding along contacts.
78
+ *
79
+ * The model, in the order it runs:
80
+ *
81
+ * 1. **Depenetrate.** Anything the body starts inside is escaped first, so the
82
+ * sweep is always asked a question it can answer.
83
+ * 2. **Sweep and slide**, up to `maxIterations` times. A contact cancels the
84
+ * component of the motion pointing into the surface and keeps the rest, so a
85
+ * body resting on the ground still walks along it and a body against a wall
86
+ * still slides.
87
+ * 3. **Probe for ground.** Grounded is a state — a surface within
88
+ * `contactEpsilon` under the body — not just a normal seen this step.
89
+ *
90
+ * Mutates nothing: `box` is read, and the caller applies the returned position.
91
+ */
92
+ export declare function resolveKinematic(box: Box3, velocity: Vector3, obstacles: readonly Box3[], options?: KinematicOptions): KinematicResult;
93
+ /**
94
+ * Uniform-grid broadphase.
95
+ *
96
+ * Testing every candidate against every other is O(n^2) and a phone notices at
97
+ * a couple of hundred. Size cells around your typical object.
98
+ */
99
+ export declare class Grid<T> {
100
+ private readonly cellSize;
101
+ private readonly cells;
102
+ constructor(cellSize: number);
103
+ insert(item: T, bounds: Box3): void;
104
+ /** Candidates overlapping `bounds`, deduplicated. Still needs a narrow test. */
105
+ query(bounds: Box3, out?: T[]): T[];
106
+ clear(): void;
107
+ private keysFor;
108
+ }
109
+ /** Indices of every box whose closest point is within `radius` of `center`. */
110
+ export declare function overlapSphere(center: Vector3, radius: number, boxes: readonly Box3[]): number[];
111
+ //# sourceMappingURL=collide.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collide.d.ts","sourceRoot":"","sources":["../src/collide.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,EAAE,OAAO,EAAE,MAAM,OAAO,CAAA;AAE1C;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,GAAG,EAAE,OAAO,CAAA;IACZ,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,CAAA;CAChB,CAAA;AAqBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,IAAI,EACT,QAAQ,EAAE,OAAO,EACjB,SAAS,EAAE,SAAS,IAAI,EAAE,EAC1B,cAAc,SAAkB,GAC/B,WAAW,CA4Eb;AAID,MAAM,MAAM,eAAe,GAAG;IAC5B,uCAAuC;IACvC,QAAQ,EAAE,OAAO,CAAA;IACjB,mEAAmE;IACnE,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,EAAE,OAAO,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,IAAI,EACT,SAAS,EAAE,SAAS,IAAI,EAAE,EAC1B,cAAc,SAAkB,GAC/B,OAAO,CAgCT;AA0BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,EACT,QAAQ,EAAE,OAAO,EACjB,SAAS,EAAE,SAAS,IAAI,EAAE,EAC1B,OAAO,GAAE,gBAAqB,GAC7B,eAAe,CA+CjB;AAED;;;;;GAKG;AACH,qBAAa,IAAI,CAAC,CAAC;IAGL,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAFrC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;gBAElB,QAAQ,EAAE,MAAM;IAI7C,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,GAAG,IAAI;IAQnC,gFAAgF;IAChF,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,GAAG,GAAE,CAAC,EAAO,GAAG,CAAC,EAAE;IAavC,KAAK,IAAI,IAAI;IAIb,OAAO,CAAE,OAAO;CAajB;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,IAAI,EAAE,GAAG,MAAM,EAAE,CAW/F"}