@kvyverse/world-runtime 0.1.0 → 0.3.2

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 (68) hide show
  1. package/README.md +78 -60
  2. package/dist/{BallCollider-8ZrxReEd.js → BallCollider-3L5vOrcL.js} +2 -2
  3. package/dist/{CapsuleCollider-bynyf6Mc.js → CapsuleCollider-CBTsR-Ys.js} +2 -2
  4. package/dist/ChatModule-2tiWS_Yr.js +411 -0
  5. package/dist/{Collider-4_rCTHVA.js → Collider-CPUCG3RA.js} +2 -2
  6. package/dist/{ConeCollider-DtRZmgK8.js → ConeCollider-C3XU3Y9X.js} +2 -2
  7. package/dist/{ConvexMeshCollider-BSmDM4o3.js → ConvexMeshCollider-DzNV4HTO.js} +1 -1
  8. package/dist/{CuboidCollider-CJvq7--s.js → CuboidCollider-Dp1DZoFM.js} +1 -1
  9. package/dist/{CylinderCollider-CQPSh4tu.js → CylinderCollider-C0h_nhB5.js} +2 -2
  10. package/dist/{FpsCameraController-BE2EirRj.js → FpsCameraController-2GNWJM5D.js} +1 -1
  11. package/dist/RapierDebugRenderer-BQy_m-3C.js +73 -0
  12. package/dist/{RapierFpsPlayerController-VsNfQl6v.js → RapierFpsPlayerController-DthD8dw9.js} +5 -22
  13. package/dist/{RapierPhysics-C_3b7QOn.js → RapierPhysics-zLwVZpwF.js} +33 -15
  14. package/dist/{Rigidbody-b7QNF4ev.js → Rigidbody-DLhN8ddv.js} +12 -1
  15. package/dist/World.d.ts +2 -2
  16. package/dist/WorldRuntime.d.ts +13 -4
  17. package/dist/addons/AddonsRuntime.d.ts +35 -0
  18. package/dist/addons/lib-registry.d.ts +34 -0
  19. package/dist/addons/types.d.ts +28 -0
  20. package/dist/behaviours/physics/BallCollider.d.ts +1 -1
  21. package/dist/behaviours/physics/CapsuleCollider.d.ts +1 -1
  22. package/dist/behaviours/physics/ConeCollider.d.ts +1 -1
  23. package/dist/behaviours/physics/ConvexMeshCollider.d.ts +1 -1
  24. package/dist/behaviours/physics/CuboidCollider.d.ts +1 -1
  25. package/dist/behaviours/physics/CylinderCollider.d.ts +1 -1
  26. package/dist/behaviours/physics/HalfspaceCollider.d.ts +1 -1
  27. package/dist/behaviours/physics/_required-patch.d.ts +10 -6
  28. package/dist/chat.module-descriptor-BTsep-Kq.js +107 -0
  29. package/dist/{get-scale-from-obj-QKv3Fig5.js → get-scale-from-obj-B7JlVPni.js} +1 -1
  30. package/dist/index.d.ts +21 -13
  31. package/dist/index.js +441 -155
  32. package/dist/modules/InputSystemModule.d.ts +14 -32
  33. package/dist/modules/chat/ChatDom.d.ts +100 -0
  34. package/dist/modules/chat/ChatModule.d.ts +36 -0
  35. package/dist/modules/chat/chat.module-descriptor.d.ts +43 -0
  36. package/dist/modules/contracts/addons.module-api.d.ts +15 -0
  37. package/dist/modules/contracts/chat.module-api.d.ts +52 -0
  38. package/dist/modules/contracts/controls.module-api.d.ts +58 -0
  39. package/dist/modules/contracts/html.module-api.d.ts +9 -1
  40. package/dist/modules/contracts/input.module-api.d.ts +11 -11
  41. package/dist/modules/contracts/module-events.d.ts +17 -0
  42. package/dist/modules/contracts/rapier.module-api.d.ts +13 -1
  43. package/dist/modules/controls/ControlsModule.d.ts +45 -0
  44. package/dist/modules/controls/KeyboardControlsDriver.d.ts +12 -0
  45. package/dist/modules/index.d.ts +30 -3
  46. package/dist/modules/optional-module-descriptor.d.ts +36 -0
  47. package/dist/modules/optional-modules.d.ts +11 -0
  48. package/dist/modules/physics/RapierDebugRenderer.d.ts +19 -0
  49. package/dist/{behaviours/physics/rapier-addon-core → modules/physics}/RapierPhysics.d.ts +13 -3
  50. package/dist/modules/physics/physics.module-descriptor.d.ts +16 -0
  51. package/dist/modules/registry.d.ts +7 -3
  52. package/dist/rendering/SceneBloom.d.ts +1 -1
  53. package/dist/rendering/renderer-apply.d.ts +1 -1
  54. package/dist/{settings → rendering}/types.d.ts +1 -12
  55. package/dist/types.d.ts +10 -3
  56. package/dist/version.gen.d.ts +1 -1
  57. package/package.json +9 -1
  58. package/dist/RapierDebugRenderer-sURZDLP4.js +0 -55
  59. package/dist/behaviours/physics/rapier-addon-core/RapierDebugRenderer.d.ts +0 -17
  60. package/dist/modules/contracts/rapierDebug.module-api.d.ts +0 -5
  61. package/dist/rolldown-runtime-D7D4PA-g.js +0 -13
  62. package/dist/settings/AddonsRuntime.d.ts +0 -19
  63. package/dist/settings/lib-loaders.d.ts +0 -7
  64. package/dist/settings/lib-registry.d.ts +0 -24
  65. package/dist/settings/module-registry.d.ts +0 -25
  66. /package/dist/{settings → addons}/externals.d.ts +0 -0
  67. /package/dist/behaviours/physics/{rapier-addon-core/Collider.d.ts → Collider.d.ts} +0 -0
  68. /package/dist/behaviours/physics/{rapier-addon-core/get-scale-from-obj.d.ts → get-scale-from-obj.d.ts} +0 -0
package/README.md CHANGED
@@ -1,87 +1,105 @@
1
1
  # @kvyverse/world-runtime
2
2
 
3
3
  Runtime for worlds exported from [Kvyverse](https://kvyverse.com) — a web-based
4
- 3D world editor. A world exported for self-hosting is a single generated JS
5
- module; this package is the engine that module runs on.
4
+ 3D world editor. When you export a world for self-hosting, Kvyverse gives you a
5
+ generated JS module; this package is the engine that module runs on.
6
6
 
7
- You normally don't install this package by hand out of curiosity: it is only
8
- useful together with a world module built by the Kvyverse editor
9
- (`Export → Build for self-hosting`).
7
+ Install it only together with such a module: on its own it does nothing.
10
8
 
11
- ## Install
9
+ ## 1. Export the world from Kvyverse
12
10
 
13
- ```
14
- npm i @kvyverse/world-runtime three three-start
15
- ```
11
+ In the editor: **burger menu → Export → Build for self-hosting**. You get a
12
+ `<world>.zip` with `index.js` (the world) and `index.d.ts` (its types), plus a
13
+ dialog with two ready-to-copy snippets — the install command and the import.
16
14
 
17
- All runtime dependencies are `peerDependencies`: the required ones (`howler`,
18
- `vanjs-core`, `eventemitter3`) are auto-installed by npm 7+, while optional
19
- ones (`@dimforge/rapier3d-compat` for physics, `@tweenjs/tween.js`, `animejs`)
20
- are only needed when the world uses them — the export dialog shows the exact
21
- install command for your world.
15
+ ## 2. Put the world into your project
22
16
 
23
- Requirements: ES modules, a bundler, WebGPU-capable browser
24
- (`three/webgpu` renderer).
17
+ Unzip the folder into your sources, for example `src/my-world/`. Nothing else
18
+ from the archive is needed: your bundler picks the module up like any local file.
25
19
 
26
- ## Usage
20
+ Asset files (models, textures, audio, fonts) are **not** in the archive — they
21
+ stay on the Kvyverse CDN, and the module carries a table of their URLs.
27
22
 
28
- ```js
29
- import { loadWorld } from "./my-world-3f9a1c02.js"; // file built by Kvyverse
23
+ ## 3. Install the dependencies
30
24
 
31
- const world = await loadWorld({ onProgress: (p) => console.log(p.stage) });
32
- world.mount(document.getElementById("app")); // canvas + render loop
33
- world.start(); // activates world behaviours
25
+ Copy the command from the export dialog — it already lists exactly what your
26
+ world needs and pins the runtime version the world was built with, for example:
27
+
28
+ ```
29
+ npm i @kvyverse/world-runtime@<version> three three-start @dimforge/rapier3d-compat
34
30
  ```
35
31
 
36
- `World` extends `ThreeStart` (from `three-start`), so the standard lifecycle
37
- and context are available: `world.ctx.scene`, `world.ctx.camera`,
38
- `world.ctx.renderer`, `unmount()`, `runLoop()` / `stopLoop()`, `dispose()`.
39
- Extra: `world.getUrl(path)` resolves a world asset URL, `world.events` is the
40
- world lifecycle emitter.
32
+ Everything the runtime needs is a **peer dependency**, so nothing is duplicated
33
+ in your `node_modules`:
34
+
35
+ | Package | When |
36
+ | --- | --- |
37
+ | `three`, `three-start` | always |
38
+ | `howler`, `vanjs-core`, `eventemitter3` | always — npm 7+ installs them for you |
39
+ | `@dimforge/rapier3d-compat` | worlds with physics |
40
+ | `@tweenjs/tween.js`, `animejs`, `nipplejs` | worlds that enable these built-in libs |
41
41
 
42
- `loadWorld` options: `onProgress` callback, `dracoPath` (self-hosted Draco
43
- decoder folder; defaults to Google's public one), `assetsBaseUrl` (only for
44
- worlds built with relative asset paths).
42
+ The optional ones are listed in the dialog only when the world actually uses
43
+ them. Skip one it does need and the build fails with a clear
44
+ `failed to resolve import` — install it and rebuild.
45
45
 
46
- Call `start()` after a user gesture if the world plays audio or captures the
47
- pointer — browser policies require it.
46
+ Requirements for your project: ES modules, a bundler (Vite, webpack, Rollup, …),
47
+ and a browser with WebGPU (the world renders through `three/webgpu`).
48
48
 
49
- ## License
49
+ ## 4. Run it
50
50
 
51
- Proprietary, see [LICENSE.md](./LICENSE.md). In short: free to use (including
52
- commercially) **only to run worlds exported from Kvyverse**; no modification or
53
- redistribution; bundling the unmodified package into your app build is allowed.
51
+ ```js
52
+ import { loadWorld } from "./my-world/index.js";
54
53
 
55
- ---
54
+ const world = await loadWorld({
55
+ onProgress: (p) => console.log(p.stage), // optional loading screen
56
+ });
56
57
 
57
- ## Development (internal)
58
+ world.mount(document.getElementById("app")); // creates the canvas, starts the render loop
59
+ world.start(); // activates the world's behaviours
60
+ ```
61
+
62
+ `mount` and `start` are separate on purpose: you can show your own intro or wait
63
+ for a click before the world comes alive. If the world plays audio or locks the
64
+ pointer, call `start()` from a user gesture — browsers require it.
58
65
 
59
- The app consumes this package **from sources** via the path alias
60
- `@kvyverse/world-runtime` → `packages/world-runtime/src`; no build needed
61
- locally. `npm run build:world-runtime` from the repo root produces
62
- `dist/index.js` + `.d.ts`. The package is covered by the root `tsc -b` and
63
- `npm run lint`.
66
+ ### `loadWorld(options)`
64
67
 
65
- | Folder | Contents |
68
+ | Option | What it does |
66
69
  | --- | --- |
67
- | `modules/` | three-start ctx modules (`env`, `html`, `input`, `sounds`, `utils`) + their contracts and the `ThreeStartRegister` augment. `createWorldModules(mode)` is the single registration point (used by the game too). |
68
- | `behaviours/` | Built-in behaviour classes (animation, controls, rapier physics), required patches, and the `uuid → class` registry — source of truth for the editor and the export. |
69
- | `resources/` | Lazy three loaders (`AssetLoaders`), resource factories (`loadTexture`, `parseModel`/`loadModel`, `collectModelSubResources`, `parseHdr`, `parseGeometryJson`, `createAudio`, `createVideo`, `createFont`), built-in resources with fixed uuids (`getDefaultResource`). |
70
- | `rendering/` | `SceneBloom` (+ MRT contracts), renderer config application, TSL node/uniform factories (`createTslNode`, `createUniformNode`). |
71
- | `settings/` | World config types (`RendererConfig`, `AddonsConfig`), lib/module registries, `AddonsRuntime`. |
72
- | `bootstrap.ts` | The world assembly order — the only place it is defined. |
73
- | `World.ts` / `WorldRuntime.ts` | `ThreeStart` subclass for the host and the runtime state owner (resources by path, `require`, script execution, styles/fonts, `world.events`). |
74
- | `world-events.ts` | `WorldEvent` lifecycle enum, shared with the game. |
70
+ | `onProgress` | `({ stage, details }) => void` while the world loads — for your loading screen |
71
+ | `dracoPath` | Folder with your own Draco decoder for compressed models; defaults to Google's public one |
72
+ | `assetsBaseUrl` | Prefix for relative asset paths (only for worlds built that way) |
75
73
 
76
- Feature docs: [`/docs/self-hosted-export.md`](../../docs/self-hosted-export.md),
77
- maintenance rules: [`/docs/self-hosted-export-maintenance.md`](../../docs/self-hosted-export-maintenance.md).
74
+ ### The `world` object
78
75
 
79
- ### Publishing
76
+ `World` extends `ThreeStart` from `three-start`, so the usual lifecycle and
77
+ context are yours:
80
78
 
79
+ ```js
80
+ world.ctx.scene, world.ctx.camera, world.ctx.renderer
81
+ world.unmount(), world.runLoop(), world.stopLoop(), world.dispose()
81
82
  ```
82
- npm run build:world-runtime
83
- cd packages/world-runtime && npm publish # publishConfig.access = public
84
- ```
85
83
 
86
- Versioning: exported worlds pin `^<version>` and update only via `npm update`,
87
- so breaking runtime changes require a major bump (see maintenance doc).
84
+ On top of that: `world.getUrl(path)` resolves an asset URL the same way scripts
85
+ inside the world do, and `world.events` is the world lifecycle emitter (the same
86
+ `world.events` your scripts use).
87
+
88
+ ## Good to know
89
+
90
+ - **Scripts, behaviours, materials and shaders** written in the editor are baked
91
+ into the module — no Kvyverse code runs at load time to interpret them.
92
+ - **Re-exporting** a world overwrites the same folder with the same file names,
93
+ so your import never changes.
94
+ - **Runtime version.** The module records the runtime version it was built with
95
+ and the world format version, and refuses to load on an incompatible runtime.
96
+ Re-export the world after a major runtime update.
97
+ - **Not everything crosses over.** Platform features (multiplayer server, user
98
+ state, launcher worlds, the editor's debug overlay) stay in Kvyverse; the
99
+ export report lists whatever your particular world loses.
100
+
101
+ ## License
102
+
103
+ Proprietary, see [LICENSE.md](./LICENSE.md). In short: free to use (including
104
+ commercially) **only to run worlds exported from Kvyverse**; no modification or
105
+ redistribution; bundling the unmodified package into your app build is allowed.
@@ -1,5 +1,5 @@
1
- import { n as getRadiusScale } from "./get-scale-from-obj-QKv3Fig5.js";
2
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { n as getRadiusScale } from "./get-scale-from-obj-B7JlVPni.js";
2
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
3
3
  //#region packages/world-runtime/src/behaviours/physics/BallCollider.ts
4
4
  var BallCollider = class extends Collider {
5
5
  type = "Ball";
@@ -1,5 +1,5 @@
1
- import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-QKv3Fig5.js";
2
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-B7JlVPni.js";
2
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
3
3
  //#region packages/world-runtime/src/behaviours/physics/CapsuleCollider.ts
4
4
  var CapsuleCollider = class extends Collider {
5
5
  type = "Capsule";
@@ -0,0 +1,411 @@
1
+ import { t as CHAT_COMMAND_KEY } from "./chat.module-descriptor-BTsep-Kq.js";
2
+ import { ContextModule, ThreeContextEvents } from "three-start";
3
+ import van from "vanjs-core";
4
+ //#region packages/world-runtime/src/modules/chat/chat.css?inline
5
+ var chat_default = "/* Neutral base for the chat input: only what it breaks without on real devices.\n Looks are left to the world. */\n\n.kvy-chat {\n position: absolute;\n left: 0;\n bottom: 0;\n width: 100%;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n /* On-screen keyboard: ChatDom writes the overlap height here. */\n padding-bottom: var(--kvy-chat-keyboard-inset, 0px);\n /* Click-through like the HUD around it: the root stretches over the whole feed,\n so catching events here would kill clicks on the world behind the messages.\n Only the input takes events back. */\n pointer-events: none;\n}\n\n.kvy-chat__form {\n display: flex;\n margin: 0;\n}\n\n.kvy-chat__input {\n flex: 1 1 auto;\n min-width: 0;\n /* 16px — below that iOS Safari zooms the page on focus. */\n font: inherit;\n font-size: 16px;\n line-height: 1.25;\n color: inherit;\n background: transparent;\n border: 0;\n outline: none;\n padding: 4px 6px;\n -webkit-appearance: none;\n appearance: none;\n border-radius: 0;\n /* The one interactive part of the chat. */\n pointer-events: auto;\n /* No 300ms delay and no double-tap zoom on the input. */\n touch-action: manipulation;\n}\n\n/* The message feed is a TTL queue, not a scroll container. It sticks to the\n bottom and extra lines are cut from the top — no scrollbar here by design. */\n.kvy-chat__feed {\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n max-height: 40vh;\n overflow: hidden;\n /* Belt and braces: the root is already click-through, but a world restyling it\n must not accidentally make messages catch clicks. */\n pointer-events: none;\n}\n\n.kvy-chat__line {\n opacity: 1;\n transition: opacity 300ms ease;\n overflow-wrap: anywhere;\n}\n\n/* The line has outlived its time: it fades but stays in the DOM — opening the\n chat shows it again instead of rebuilding the feed. */\n.kvy-chat__line--faded {\n opacity: 0;\n}\n\n/* Hidden submit button: backs up implicit submission in old webviews. */\n.kvy-chat__submit {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n}\n\n/* Toggle mode: the input is hidden until entered (Enter on the canvas).\n Opacity, not visibility/display: an element hidden by those cannot be focused\n programmatically, leaving nothing to open the chat with. */\n.kvy-chat--idle-hidden .kvy-chat__input {\n opacity: 0;\n pointer-events: none;\n}\n";
6
+ //#endregion
7
+ //#region packages/world-runtime/src/modules/chat/ChatDom.ts
8
+ var { button, div, form, input } = van.tags;
9
+ var STYLE_ID = "kvyverse-chat-styles";
10
+ /**
11
+ * Chat DOM: the message feed plus the input field.
12
+ *
13
+ * Built to survive real devices: submit through a `<form>` (Enter/Go/Send on any
14
+ * mobile keyboard), an IME composition guard, and lifting above the on-screen
15
+ * keyboard via `visualViewport`. Details —
16
+ * `./README.md`.
17
+ */
18
+ var ChatDom = class ChatDom {
19
+ root;
20
+ feed;
21
+ form;
22
+ input;
23
+ _options;
24
+ _lines = [];
25
+ _isComposing = false;
26
+ _viewportRaf = 0;
27
+ _mounted = false;
28
+ constructor(options = {}) {
29
+ this._options = options;
30
+ this.input = input({
31
+ class: "kvy-chat__input",
32
+ type: "text",
33
+ enterkeyhint: "send",
34
+ inputmode: "text",
35
+ autocomplete: "off",
36
+ "aria-label": "Chat message",
37
+ placeholder: options.placeholder ?? "",
38
+ ...options.maxLength ? { maxlength: String(options.maxLength) } : {}
39
+ });
40
+ this.input.spellcheck = false;
41
+ this.input.setAttribute("autocorrect", "off");
42
+ this.input.setAttribute("autocapitalize", "sentences");
43
+ this.form = form({
44
+ class: "kvy-chat__form",
45
+ novalidate: true
46
+ }, this.input, button({
47
+ class: "kvy-chat__submit",
48
+ type: "submit",
49
+ tabindex: "-1"
50
+ }, "Send"));
51
+ this.feed = div({ class: "kvy-chat__feed" });
52
+ this.root = div({ class: "kvy-chat" }, this.feed, this.form);
53
+ }
54
+ /** The focus policy is only known at runtime (mobile/desktop, canvas). */
55
+ setFocusPolicy(policy) {
56
+ this._options.keepFocusAfterSubmit = policy.keepFocusAfterSubmit;
57
+ this._options.returnFocusTo = policy.returnFocusTo;
58
+ }
59
+ get isMounted() {
60
+ return this._mounted;
61
+ }
62
+ /** Whether to hide the input while unfocused (`toggle` mode). */
63
+ setHideWhenIdle(hide) {
64
+ this._options.hideWhenIdle = hide;
65
+ this._syncVisibility();
66
+ }
67
+ get isFocused() {
68
+ return document.activeElement === this.input;
69
+ }
70
+ get value() {
71
+ return this.input.value;
72
+ }
73
+ set value(next) {
74
+ this.input.value = next;
75
+ }
76
+ /**
77
+ * Syncs the feed with the history: new lines are appended at the bottom, lines
78
+ * gone from the history are removed, extra ones are cut from the top. There is
79
+ * no scrolling — what does not fit is not shown.
80
+ */
81
+ renderMessages(messages) {
82
+ const byId = new Map(this._lines.map((line) => [line.id, line]));
83
+ const next = [];
84
+ for (const message of messages.slice(-this._maxLines)) {
85
+ const existing = byId.get(message.id);
86
+ if (existing) {
87
+ byId.delete(message.id);
88
+ next.push(existing);
89
+ continue;
90
+ }
91
+ next.push(this._createLine(message));
92
+ }
93
+ for (const stale of byId.values()) this._removeLine(stale);
94
+ this.feed.replaceChildren(...next.map((line) => line.el));
95
+ this._lines = next;
96
+ this._syncLines();
97
+ }
98
+ mount(container) {
99
+ if (this._mounted) return;
100
+ ChatDom._ensureStyles();
101
+ container.append(this.root);
102
+ this._bind();
103
+ this._mounted = true;
104
+ this._syncVisibility();
105
+ this._syncKeyboardInset();
106
+ }
107
+ unmount() {
108
+ if (!this._mounted) return;
109
+ this._unbind();
110
+ this.root.remove();
111
+ this._mounted = false;
112
+ }
113
+ focus() {
114
+ this.input.focus({ preventScroll: true });
115
+ const end = this.input.value.length;
116
+ this.input.setSelectionRange(end, end);
117
+ }
118
+ blur() {
119
+ this.input.blur();
120
+ }
121
+ clear() {
122
+ this.input.value = "";
123
+ }
124
+ dispose() {
125
+ this.unmount();
126
+ for (const line of this._lines) this._removeLine(line);
127
+ this._lines = [];
128
+ }
129
+ /** Keep in the DOM as much as an opened chat could need. */
130
+ get _maxLines() {
131
+ return Math.max(this._options.visibleLines ?? 10, this._options.openedLines ?? 20);
132
+ }
133
+ _createLine(message) {
134
+ const fadeAfter = this._options.fadeAfter ?? 1e4;
135
+ return {
136
+ id: message.id,
137
+ el: div({ class: "kvy-chat__line" }, message.text),
138
+ deadline: fadeAfter > 0 ? message.timestamp + fadeAfter : Infinity,
139
+ timer: 0,
140
+ faded: false
141
+ };
142
+ }
143
+ _removeLine(line) {
144
+ if (line.timer) clearTimeout(line.timer);
145
+ line.el.remove();
146
+ }
147
+ /**
148
+ * While the input is focused more lines are shown, including expired ones (an
149
+ * opened chat is meant to be read). Once closed, every line lives by its own
150
+ * deadline again: expired ones fade at once, the rest sit out their remainder.
151
+ */
152
+ _syncLines() {
153
+ const opened = this.isFocused;
154
+ const visible = opened ? this._options.openedLines ?? 20 : this._options.visibleLines ?? 10;
155
+ const offscreen = this._lines.length - visible;
156
+ this._lines.forEach((line, index) => {
157
+ line.el.style.display = index < offscreen ? "none" : "";
158
+ });
159
+ const now = Date.now();
160
+ for (const line of this._lines) {
161
+ if (line.timer) {
162
+ clearTimeout(line.timer);
163
+ line.timer = 0;
164
+ }
165
+ if (opened) {
166
+ this._setFaded(line, false);
167
+ continue;
168
+ }
169
+ const remaining = line.deadline - now;
170
+ if (remaining <= 0) {
171
+ this._setFaded(line, true);
172
+ continue;
173
+ }
174
+ this._setFaded(line, false);
175
+ if (remaining === Infinity) continue;
176
+ line.timer = window.setTimeout(() => {
177
+ line.timer = 0;
178
+ this._setFaded(line, true);
179
+ }, remaining);
180
+ }
181
+ }
182
+ _setFaded(line, faded) {
183
+ if (line.faded === faded) return;
184
+ line.faded = faded;
185
+ line.el.classList.toggle("kvy-chat__line--faded", faded);
186
+ }
187
+ _bind() {
188
+ this.form.addEventListener("submit", this._onSubmit);
189
+ this.input.addEventListener("compositionstart", this._onCompositionStart);
190
+ this.input.addEventListener("compositionend", this._onCompositionEnd);
191
+ this.input.addEventListener("beforeinput", this._onBeforeInput);
192
+ this.input.addEventListener("keydown", this._onKeyDown);
193
+ this.input.addEventListener("keyup", this._stopPropagation);
194
+ this.input.addEventListener("focus", this._onFocus);
195
+ this.input.addEventListener("blur", this._onBlur);
196
+ const vv = window.visualViewport;
197
+ if (vv) {
198
+ vv.addEventListener("resize", this._onViewportChange);
199
+ vv.addEventListener("scroll", this._onViewportChange);
200
+ }
201
+ }
202
+ _unbind() {
203
+ this.form.removeEventListener("submit", this._onSubmit);
204
+ this.input.removeEventListener("compositionstart", this._onCompositionStart);
205
+ this.input.removeEventListener("compositionend", this._onCompositionEnd);
206
+ this.input.removeEventListener("beforeinput", this._onBeforeInput);
207
+ this.input.removeEventListener("keydown", this._onKeyDown);
208
+ this.input.removeEventListener("keyup", this._stopPropagation);
209
+ this.input.removeEventListener("focus", this._onFocus);
210
+ this.input.removeEventListener("blur", this._onBlur);
211
+ const vv = window.visualViewport;
212
+ if (vv) {
213
+ vv.removeEventListener("resize", this._onViewportChange);
214
+ vv.removeEventListener("scroll", this._onViewportChange);
215
+ }
216
+ if (this._viewportRaf) {
217
+ cancelAnimationFrame(this._viewportRaf);
218
+ this._viewportRaf = 0;
219
+ }
220
+ }
221
+ _onSubmit = (e) => {
222
+ e.preventDefault();
223
+ if (this._isComposing) return;
224
+ const text = this.input.value.trim();
225
+ this.clear();
226
+ if (!text) return;
227
+ this._options.onSubmit?.(text);
228
+ if (this._options.keepFocusAfterSubmit) {
229
+ this.focus();
230
+ return;
231
+ }
232
+ this.blur();
233
+ this._options.returnFocusTo?.focus({ preventScroll: true });
234
+ };
235
+ _onCompositionStart = () => {
236
+ this._isComposing = true;
237
+ };
238
+ _onCompositionEnd = () => {
239
+ this._isComposing = false;
240
+ };
241
+ /** Some Android keyboards send no Enter keydown, only `insertLineBreak`. */
242
+ _onBeforeInput = (e) => {
243
+ if (e.inputType !== "insertLineBreak") return;
244
+ e.preventDefault();
245
+ this.form.requestSubmit();
246
+ };
247
+ _onKeyDown = (e) => {
248
+ e.stopPropagation();
249
+ if (e.key !== "Escape") return;
250
+ this.blur();
251
+ this._options.returnFocusTo?.focus({ preventScroll: true });
252
+ };
253
+ _stopPropagation = (e) => {
254
+ e.stopPropagation();
255
+ };
256
+ _onFocus = () => {
257
+ this._options.onFocusChange?.(true);
258
+ this._syncVisibility();
259
+ this._syncLines();
260
+ this._syncKeyboardInset();
261
+ };
262
+ _onBlur = () => {
263
+ this._options.onFocusChange?.(false);
264
+ this._syncVisibility();
265
+ this._syncLines();
266
+ this._syncKeyboardInset();
267
+ };
268
+ /** In `toggle` mode a hidden input must catch neither clicks nor tab focus. */
269
+ _syncVisibility() {
270
+ const hidden = !!this._options.hideWhenIdle && !this.isFocused;
271
+ this.root.classList.toggle("kvy-chat--idle-hidden", hidden);
272
+ this.input.tabIndex = hidden ? -1 : 0;
273
+ }
274
+ _onViewportChange = () => {
275
+ if (this._viewportRaf) return;
276
+ this._viewportRaf = requestAnimationFrame(() => {
277
+ this._viewportRaf = 0;
278
+ this._syncKeyboardInset();
279
+ });
280
+ };
281
+ /**
282
+ * The keyboard changes the layout viewport neither on iOS nor (by default) on
283
+ * Android — anything fixed to the bottom ends up underneath it. We measure the
284
+ * overlap through `visualViewport` and lift the chat by that much.
285
+ */
286
+ _syncKeyboardInset() {
287
+ const vv = window.visualViewport;
288
+ if (!vv) return;
289
+ const overlap = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
290
+ this.root.style.setProperty("--kvy-chat-keyboard-inset", `${Math.round(overlap)}px`);
291
+ }
292
+ /** Styles are inlined once per document: the package ships no separate css file. */
293
+ static _ensureStyles() {
294
+ if (document.getElementById(STYLE_ID)) return;
295
+ const style = document.createElement("style");
296
+ style.id = STYLE_ID;
297
+ style.textContent = chat_default;
298
+ document.head.append(style);
299
+ }
300
+ };
301
+ //#endregion
302
+ //#region packages/world-runtime/src/modules/chat/ChatModule.ts
303
+ /**
304
+ * The world chat as a pluggable ctx module: message history plus its own DOM
305
+ * input (`dom`), no React. Enabled from `settings.addons.json → modules`; it
306
+ * mounts its DOM itself. Details and mobile quirks — `./README.md`.
307
+ */
308
+ var ChatModule = class extends ContextModule {
309
+ dom;
310
+ _maxMessages;
311
+ _openKeys;
312
+ _messages = [];
313
+ _seq = 0;
314
+ constructor(config) {
315
+ super();
316
+ this._maxMessages = config.maxMessages;
317
+ this._openKeys = config.openKeys;
318
+ this.dom = new ChatDom({
319
+ placeholder: config.placeholder,
320
+ maxLength: config.maxLength,
321
+ hideWhenIdle: config.input === "toggle",
322
+ visibleLines: config.visibleLines,
323
+ openedLines: config.openedLines,
324
+ fadeAfter: config.fadeAfter,
325
+ onSubmit: (text) => this.send(text),
326
+ onFocusChange: (focused) => this.emit("inputfocuschanged", focused)
327
+ });
328
+ }
329
+ get messages() {
330
+ return this._messages;
331
+ }
332
+ onAwake() {
333
+ const ctx = this.ctx;
334
+ const isMobile = ctx.modules.env.isMobile;
335
+ this.dom.setFocusPolicy({
336
+ keepFocusAfterSubmit: isMobile,
337
+ returnFocusTo: ctx.renderer.domElement
338
+ });
339
+ if (isMobile) this.dom.setHideWhenIdle(false);
340
+ ctx.modules.input.on("keydown", this._onKeyDown);
341
+ if (ctx.canvasContainer) this._onMount(ctx.canvasContainer);
342
+ ctx.on(ThreeContextEvents.Mount, this._onMount);
343
+ ctx.on(ThreeContextEvents.Unmount, this._onUnmount);
344
+ }
345
+ /** Print a line into the feed as is. */
346
+ print(text) {
347
+ this._messages.push(this._createMessage(text));
348
+ if (this._messages.length > this._maxMessages) this._messages = this._messages.slice(-this._maxMessages);
349
+ this._emitChanged();
350
+ }
351
+ /**
352
+ * Send a line the way a player does: commands are handled here, everything else
353
+ * goes out as `sent`. It prints nothing by itself — what to show is up to the world.
354
+ */
355
+ send(text) {
356
+ const value = text.trim();
357
+ if (!value) return;
358
+ if (value === "/clear") {
359
+ this.clear();
360
+ return;
361
+ }
362
+ this.emit("sent", value);
363
+ }
364
+ /** Redraw the whole feed — animation frames, tables, ASCII art. */
365
+ setMessages(texts) {
366
+ const tail = texts.length > this._maxMessages ? texts.slice(-this._maxMessages) : texts;
367
+ this._messages = tail.map((text) => this._createMessage(text));
368
+ this._emitChanged();
369
+ }
370
+ clear() {
371
+ this._messages = [];
372
+ this._emitChanged();
373
+ }
374
+ _emitChanged() {
375
+ this.dom.renderMessages(this._messages);
376
+ this.emit("changed", this._messages);
377
+ }
378
+ _createMessage(text) {
379
+ return {
380
+ id: `m${++this._seq}`,
381
+ text: text.trim(),
382
+ timestamp: Date.now()
383
+ };
384
+ }
385
+ /** three-start ContextModule has no onDestroy; teardown is explicit. */
386
+ dispose() {
387
+ const ctx = this.ctx;
388
+ ctx.modules.input.off("keydown", this._onKeyDown);
389
+ ctx.off(ThreeContextEvents.Mount, this._onMount);
390
+ ctx.off(ThreeContextEvents.Unmount, this._onUnmount);
391
+ this.dom.dispose();
392
+ }
393
+ _onKeyDown = (e) => {
394
+ if (e.repeat) return;
395
+ if (!this.dom.isMounted || this.dom.isFocused) return;
396
+ const isCommandKey = e.code === CHAT_COMMAND_KEY;
397
+ if (!isCommandKey && !this._openKeys.includes(e.code)) return;
398
+ e.preventDefault();
399
+ if (isCommandKey) this.dom.value = "/";
400
+ this.dom.focus();
401
+ };
402
+ _onMount = (container) => {
403
+ this.dom.mount(container);
404
+ this.dom.renderMessages(this._messages);
405
+ };
406
+ _onUnmount = () => {
407
+ this.dom.unmount();
408
+ };
409
+ };
410
+ //#endregion
411
+ export { ChatModule };
@@ -1,7 +1,7 @@
1
- import { t as Rigidbody } from "./Rigidbody-b7QNF4ev.js";
1
+ import { t as Rigidbody } from "./Rigidbody-DLhN8ddv.js";
2
2
  import * as THREE from "three/webgpu";
3
3
  import { Object3DBehaviour, assertDefined, getComponents } from "three-start";
4
- //#region packages/world-runtime/src/behaviours/physics/rapier-addon-core/Collider.ts
4
+ //#region packages/world-runtime/src/behaviours/physics/Collider.ts
5
5
  /** Walk the parent chain (ancestors only); stop when `cb` returns `false`. */
6
6
  function traverseUp(obj, cb) {
7
7
  let cur = obj.parent;
@@ -1,5 +1,5 @@
1
- import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-QKv3Fig5.js";
2
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-B7JlVPni.js";
2
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
3
3
  //#region packages/world-runtime/src/behaviours/physics/ConeCollider.ts
4
4
  var ConeCollider = class extends Collider {
5
5
  type = "Cone";
@@ -1,4 +1,4 @@
1
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
2
2
  //#region packages/world-runtime/src/behaviours/physics/ConvexMeshCollider.ts
3
3
  var ConvexMeshCollider = class extends Collider {
4
4
  type = "Convex";
@@ -1,4 +1,4 @@
1
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
2
2
  //#region packages/world-runtime/src/behaviours/physics/CuboidCollider.ts
3
3
  var CuboidCollider = class extends Collider {
4
4
  type = "Cuboid";
@@ -1,5 +1,5 @@
1
- import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-QKv3Fig5.js";
2
- import { t as Collider } from "./Collider-4_rCTHVA.js";
1
+ import { t as getHalfheightRadiusScale } from "./get-scale-from-obj-B7JlVPni.js";
2
+ import { t as Collider } from "./Collider-CPUCG3RA.js";
3
3
  //#region packages/world-runtime/src/behaviours/physics/CylinderCollider.ts
4
4
  var CylinderCollider = class extends Collider {
5
5
  type = "Cylinder";
@@ -11,7 +11,7 @@ var FpsCameraController = class extends Object3DBehaviour {
11
11
  let isDragging = false;
12
12
  let prevX = 0;
13
13
  let prevY = 0;
14
- const isMobile = !!this.modules.input.player.mode;
14
+ const isMobile = this.modules.env.isMobile;
15
15
  const sensitivity = isMobile ? this.mobileSensitivity : this.sensitivity;
16
16
  const inverted = isMobile && this.useInvertedOnMobile ? true : this.inverted;
17
17
  const onMouseDown = (e) => {