@worldmesh/runtime 0.2.1 → 0.2.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.
- package/README.md +33 -3
- package/dist/audio/footsteps.d.ts +37 -0
- package/dist/audio/footsteps.d.ts.map +1 -0
- package/dist/audio/footsteps.js +178 -0
- package/dist/audio/footsteps.js.map +1 -0
- package/dist/avatar/descriptor.d.ts +15 -0
- package/dist/avatar/descriptor.d.ts.map +1 -1
- package/dist/avatar/descriptor.js +19 -0
- package/dist/avatar/descriptor.js.map +1 -1
- package/dist/avatar/loader.d.ts +12 -1
- package/dist/avatar/loader.d.ts.map +1 -1
- package/dist/avatar/loader.js +16 -1
- package/dist/avatar/loader.js.map +1 -1
- package/dist/avatar/share.d.ts +30 -0
- package/dist/avatar/share.d.ts.map +1 -0
- package/dist/avatar/share.js +203 -0
- package/dist/avatar/share.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +4 -0
- package/dist/camera/cameraRig.d.ts.map +1 -1
- package/dist/camera/cameraRig.js +10 -1
- package/dist/camera/cameraRig.js.map +1 -1
- package/dist/camera/viewHandoff.d.ts +20 -0
- package/dist/camera/viewHandoff.d.ts.map +1 -0
- package/dist/camera/viewHandoff.js +64 -0
- package/dist/camera/viewHandoff.js.map +1 -0
- package/dist/controls/input.d.ts +29 -0
- package/dist/controls/input.d.ts.map +1 -1
- package/dist/controls/input.js +124 -1
- package/dist/controls/input.js.map +1 -1
- package/dist/controls/touch.d.ts +10 -0
- package/dist/controls/touch.d.ts.map +1 -1
- package/dist/controls/touch.js +57 -1
- package/dist/controls/touch.js.map +1 -1
- package/dist/core/worldmesh.d.ts +9 -0
- package/dist/core/worldmesh.d.ts.map +1 -1
- package/dist/core/worldmesh.js +498 -26
- package/dist/core/worldmesh.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/net/follow.d.ts +7 -0
- package/dist/net/follow.d.ts.map +1 -0
- package/dist/net/follow.js +10 -0
- package/dist/net/follow.js.map +1 -0
- package/dist/net/presence.d.ts +125 -3
- package/dist/net/presence.d.ts.map +1 -1
- package/dist/net/presence.js +471 -24
- package/dist/net/presence.js.map +1 -1
- package/dist/net/roomFull.d.ts +44 -0
- package/dist/net/roomFull.d.ts.map +1 -0
- package/dist/net/roomFull.js +147 -0
- package/dist/net/roomFull.js.map +1 -0
- package/dist/player/avatar.d.ts +17 -4
- package/dist/player/avatar.d.ts.map +1 -1
- package/dist/player/avatar.js +85 -18
- package/dist/player/avatar.js.map +1 -1
- package/dist/player/player.d.ts +6 -2
- package/dist/player/player.d.ts.map +1 -1
- package/dist/player/player.js +13 -3
- package/dist/player/player.js.map +1 -1
- package/dist/player/stroke.d.ts +11 -0
- package/dist/player/stroke.d.ts.map +1 -0
- package/dist/player/stroke.js +96 -0
- package/dist/player/stroke.js.map +1 -0
- package/dist/portals/portals.d.ts +1 -1
- package/dist/portals/portals.d.ts.map +1 -1
- package/dist/portals/portals.js +6 -2
- package/dist/portals/portals.js.map +1 -1
- package/dist/types.d.ts +53 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/overlay.d.ts +18 -0
- package/dist/ui/overlay.d.ts.map +1 -1
- package/dist/ui/overlay.js +166 -1
- package/dist/ui/overlay.js.map +1 -1
- package/dist/xr/session.d.ts +13 -0
- package/dist/xr/session.d.ts.map +1 -0
- package/dist/xr/session.js +33 -0
- package/dist/xr/session.js.map +1 -0
- package/package.json +1 -1
- package/src/audio/footsteps.ts +185 -0
- package/src/avatar/descriptor.ts +28 -0
- package/src/avatar/loader.ts +19 -2
- package/src/avatar/share.ts +196 -0
- package/src/camera/cameraRig.ts +10 -1
- package/src/camera/viewHandoff.ts +67 -0
- package/src/controls/input.ts +119 -1
- package/src/controls/touch.ts +59 -1
- package/src/core/worldmesh.ts +495 -25
- package/src/index.ts +14 -7
- package/src/net/follow.ts +9 -0
- package/src/net/presence.ts +547 -27
- package/src/net/roomFull.ts +160 -0
- package/src/player/avatar.ts +87 -18
- package/src/player/player.ts +13 -4
- package/src/player/stroke.ts +93 -0
- package/src/portals/portals.ts +5 -2
- package/src/types.ts +52 -9
- package/src/ui/overlay.ts +171 -1
- package/src/xr/session.ts +33 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay refuses a full room with HTTP 503 and the body "Room is full"
|
|
3
|
+
* before the WebSocket upgrade. Browsers do not expose that status on a failed
|
|
4
|
+
* handshake (close is usually 1006 with an empty reason). Node's `ws` package
|
|
5
|
+
* can surface it via `unexpected-response`. These helpers classify both.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Close / HTTP / error shapes that mean the relay refused a full room. */
|
|
9
|
+
export function isRoomFullSignal(signal: {
|
|
10
|
+
status?: number;
|
|
11
|
+
code?: number;
|
|
12
|
+
reason?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
body?: string;
|
|
15
|
+
} | null | undefined): boolean {
|
|
16
|
+
if (!signal) return false;
|
|
17
|
+
if (signal.status === 503) return true;
|
|
18
|
+
const text = [signal.reason, signal.message, signal.body].filter((part) => typeof part === 'string').join(' ');
|
|
19
|
+
return /room is full/i.test(text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Copy on the full-screen overlay. Named rooms (the hub lobby) vs world rooms. */
|
|
23
|
+
export function roomFullLabel(wsUrl: string): string {
|
|
24
|
+
try {
|
|
25
|
+
if (/^\/room\//i.test(new URL(wsUrl).pathname)) return 'Lobby is full';
|
|
26
|
+
} catch {
|
|
27
|
+
// Fall through to the world wording.
|
|
28
|
+
}
|
|
29
|
+
return 'World is full';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function presenceHttpUrl(wsUrl: string): string {
|
|
33
|
+
return wsUrl.replace(/^ws/i, 'http');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** GET /occupancy query for this Presence URL (named room or this page's world). */
|
|
37
|
+
export function occupancyProbeUrl(wsUrl: string, pageOrigin?: string): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(presenceHttpUrl(wsUrl));
|
|
40
|
+
const room = url.pathname.match(/^\/room\/([a-z0-9-]{1,64})$/i);
|
|
41
|
+
if (room) {
|
|
42
|
+
url.pathname = '/occupancy';
|
|
43
|
+
url.search = `rooms=${encodeURIComponent(room[1].toLowerCase())}`;
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
if (/\/world\/?$/i.test(url.pathname)) {
|
|
47
|
+
const origin = pageOrigin || (typeof location !== 'undefined' ? location.origin : '');
|
|
48
|
+
if (!origin) return null;
|
|
49
|
+
url.pathname = '/occupancy';
|
|
50
|
+
url.search = `origins=${encodeURIComponent(origin)}`;
|
|
51
|
+
return url.toString();
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function occupancyIsFull(payload: unknown, wsUrl: string, pageOrigin?: string): boolean {
|
|
60
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
61
|
+
const data = payload as Record<string, unknown>;
|
|
62
|
+
let key: string | null = null;
|
|
63
|
+
try {
|
|
64
|
+
const path = new URL(wsUrl).pathname;
|
|
65
|
+
const room = path.match(/^\/room\/([a-z0-9-]{1,64})$/i);
|
|
66
|
+
if (room) key = `room:${room[1].toLowerCase()}`;
|
|
67
|
+
else {
|
|
68
|
+
const origin = (pageOrigin || (typeof location !== 'undefined' ? location.origin : '')).toLowerCase();
|
|
69
|
+
key = origin || null;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!key) return false;
|
|
75
|
+
const entry = data[key];
|
|
76
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
77
|
+
const { count, cap } = entry as { count?: unknown; cap?: unknown };
|
|
78
|
+
return atCapacity(count, cap);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function atCapacity(count: unknown, cap: unknown): boolean {
|
|
82
|
+
return (
|
|
83
|
+
typeof count === 'number' &&
|
|
84
|
+
typeof cap === 'number' &&
|
|
85
|
+
Number.isFinite(count) &&
|
|
86
|
+
Number.isFinite(cap) &&
|
|
87
|
+
cap > 0 &&
|
|
88
|
+
count >= cap
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ProbeFetch = (
|
|
93
|
+
input: string,
|
|
94
|
+
init?: { method?: string; headers?: Record<string, string> },
|
|
95
|
+
) => Promise<Response>;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The shared relay turns a world away for the rest of the UTC day once its
|
|
99
|
+
* room has used the free daily allowance: HTTP 429 with "allowance" in the
|
|
100
|
+
* body. A plain 429 ("Too many requests") is only a short rate limit.
|
|
101
|
+
*/
|
|
102
|
+
export function isOverAllowanceSignal(signal: { status?: number; body?: string } | null | undefined): boolean {
|
|
103
|
+
return signal?.status === 429 && /allowance/i.test(signal.body ?? '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Why a join was refused: the room is full, the world is past today's allowance, or neither (a network blip). */
|
|
107
|
+
export type RoomProbe = 'full' | 'allowance' | 'open';
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* After a handshake that never welcomed us, ask the relay why. Same-path GET
|
|
111
|
+
* (no Upgrade) is the browser stand-in for the refused upgrade; GET
|
|
112
|
+
* /occupancy covers older relays and Node callers that omit Origin.
|
|
113
|
+
*/
|
|
114
|
+
export async function probeRoom(
|
|
115
|
+
wsUrl: string,
|
|
116
|
+
pageOrigin?: string,
|
|
117
|
+
fetchFn: ProbeFetch = fetch as ProbeFetch,
|
|
118
|
+
): Promise<RoomProbe> {
|
|
119
|
+
const origin = pageOrigin || (typeof location !== 'undefined' ? location.origin : undefined);
|
|
120
|
+
const headers: Record<string, string> = { Accept: 'text/plain, application/json' };
|
|
121
|
+
if (origin) headers.Origin = origin;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const direct = await fetchFn(presenceHttpUrl(wsUrl), { method: 'GET', headers });
|
|
125
|
+
const text = await direct.text();
|
|
126
|
+
if (isRoomFullSignal({ status: direct.status, body: text })) return 'full';
|
|
127
|
+
if (isOverAllowanceSignal({ status: direct.status, body: text })) return 'allowance';
|
|
128
|
+
if (direct.ok) {
|
|
129
|
+
try {
|
|
130
|
+
const json = JSON.parse(text) as { count?: unknown; cap?: unknown };
|
|
131
|
+
if (atCapacity(json.count, json.cap)) return 'full';
|
|
132
|
+
} catch {
|
|
133
|
+
// Not occupancy JSON (e.g. the old 426 body).
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Fall through to /occupancy.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const occupancyUrl = occupancyProbeUrl(wsUrl, origin);
|
|
141
|
+
if (!occupancyUrl) return 'open';
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetchFn(occupancyUrl, { method: 'GET', headers: { Accept: 'application/json' } });
|
|
144
|
+
const text = await res.text();
|
|
145
|
+
if (isRoomFullSignal({ status: res.status, body: text })) return 'full';
|
|
146
|
+
if (!res.ok) return 'open';
|
|
147
|
+
return occupancyIsFull(JSON.parse(text), wsUrl, origin) ? 'full' : 'open';
|
|
148
|
+
} catch {
|
|
149
|
+
return 'open';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** probeRoom, answering only whether the room is full. */
|
|
154
|
+
export async function probeRoomFull(
|
|
155
|
+
wsUrl: string,
|
|
156
|
+
pageOrigin?: string,
|
|
157
|
+
fetchFn: ProbeFetch = fetch as ProbeFetch,
|
|
158
|
+
): Promise<boolean> {
|
|
159
|
+
return (await probeRoom(wsUrl, pageOrigin, fetchFn)) === 'full';
|
|
160
|
+
}
|
package/src/player/avatar.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
SplineCurve,
|
|
15
15
|
SRGBColorSpace,
|
|
16
16
|
Vector2,
|
|
17
|
+
Vector3,
|
|
17
18
|
type BufferGeometry,
|
|
18
19
|
type Object3D,
|
|
19
20
|
} from 'three';
|
|
@@ -100,20 +101,38 @@ function toonSteps(shadow: string, light: string): CanvasTexture {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
const APPEAR_KEY = 'worldmeshAppear';
|
|
104
|
+
const APPEAR_AXIS_KEY = 'worldmeshAppearAxis';
|
|
105
|
+
const UP = new Vector3(0, 1, 0);
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
108
|
+
* Warp the surface along an axis (up and down unless told otherwise) and fade
|
|
109
|
+
* it in as `uFill` goes from 0 to 1. Materials on one figure share uniforms so
|
|
110
|
+
* the body and face appear together. Runs after any shader changes the
|
|
111
|
+
* material already makes (a VRM's MToon has its own).
|
|
107
112
|
*/
|
|
108
|
-
function attachAppear(
|
|
109
|
-
|
|
113
|
+
function attachAppear(
|
|
114
|
+
material: Material,
|
|
115
|
+
shared?: { fill: { value: number }; axis: { value: Vector3 } },
|
|
116
|
+
): { fill: { value: number }; axis: { value: Vector3 } } {
|
|
117
|
+
const fill = shared?.fill ?? { value: 1 };
|
|
118
|
+
const axis = shared?.axis ?? { value: UP.clone() };
|
|
110
119
|
material.userData[APPEAR_KEY] = fill;
|
|
111
|
-
material.
|
|
112
|
-
|
|
120
|
+
material.userData[APPEAR_AXIS_KEY] = axis;
|
|
121
|
+
const ownKey = material.customProgramCacheKey;
|
|
122
|
+
const ownCompile = material.onBeforeCompile;
|
|
123
|
+
material.customProgramCacheKey = () => `${ownKey.call(material)}|worldmesh-appear`;
|
|
124
|
+
material.onBeforeCompile = (shader, renderer) => {
|
|
125
|
+
ownCompile.call(material, shader, renderer);
|
|
126
|
+
// A shader without these hooks is left as it is: it just appears without the warp.
|
|
127
|
+
const hooks = ['#include <common>', '#include <begin_vertex>'].every((hook) => shader.vertexShader.includes(hook)) &&
|
|
128
|
+
['#include <common>', '#include <dithering_fragment>'].every((hook) => shader.fragmentShader.includes(hook));
|
|
129
|
+
if (!hooks) return;
|
|
113
130
|
shader.uniforms.uFill = fill;
|
|
131
|
+
shader.uniforms.uAppearAxis = axis;
|
|
114
132
|
const appearFns = `
|
|
115
133
|
varying vec3 vAppearPos;
|
|
116
134
|
uniform float uFill;
|
|
135
|
+
uniform vec3 uAppearAxis;
|
|
117
136
|
float appearHash(vec3 p) {
|
|
118
137
|
p = fract(p * vec3(443.897, 441.423, 437.195));
|
|
119
138
|
p += dot(p, p.yzx + 19.19);
|
|
@@ -122,17 +141,26 @@ float appearHash(vec3 p) {
|
|
|
122
141
|
float appearNoise(vec3 p) {
|
|
123
142
|
return appearHash(p) * 0.65 + appearHash(p * 2.7 + 4.2) * 0.35;
|
|
124
143
|
}`;
|
|
144
|
+
// The shift is picked from the rest pose but added after skinning: added
|
|
145
|
+
// before, every bone would turn it with its own rotation (a T-posed arm
|
|
146
|
+
// hanging down throws it sideways) instead of it running along the axis.
|
|
147
|
+
const shift = 'transformed += (inverse(modelMatrix) * vec4(uAppearAxis * appearShift, 0.0)).xyz;';
|
|
148
|
+
const skinned = shader.vertexShader.includes('#include <skinning_vertex>');
|
|
125
149
|
shader.vertexShader = shader.vertexShader
|
|
126
150
|
.replace('#include <common>', `#include <common>\n${appearFns}`)
|
|
127
151
|
.replace(
|
|
128
152
|
'#include <begin_vertex>',
|
|
129
153
|
`#include <begin_vertex>
|
|
130
|
-
|
|
154
|
+
// Eased so the warp stays strong for longer before it settles.
|
|
155
|
+
float appearWarp = pow(1.0 - uFill, 0.6);
|
|
131
156
|
vec3 appearWorld = (modelMatrix * vec4(position, 1.0)).xyz;
|
|
132
|
-
float appearShift = (appearNoise(appearWorld * 5.5) * 2.0 - 1.0) *
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
float appearShift = (appearNoise(appearWorld * 5.5) * 2.0 - 1.0) * 1.1 * appearWarp;
|
|
158
|
+
vAppearPos = appearWorld;
|
|
159
|
+
${skinned ? '' : shift}`,
|
|
135
160
|
);
|
|
161
|
+
if (skinned) {
|
|
162
|
+
shader.vertexShader = shader.vertexShader.replace('#include <skinning_vertex>', `#include <skinning_vertex>\n${shift}`);
|
|
163
|
+
}
|
|
136
164
|
shader.fragmentShader = shader.fragmentShader
|
|
137
165
|
.replace(
|
|
138
166
|
'#include <common>',
|
|
@@ -141,11 +169,29 @@ ${appearFns}`,
|
|
|
141
169
|
)
|
|
142
170
|
.replace(
|
|
143
171
|
'#include <dithering_fragment>',
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
`#include <dithering_fragment>
|
|
173
|
+
// Fades from invisible to solid over the whole warp.
|
|
174
|
+
gl_FragColor.a *= uFill * uFill * (3.0 - 2.0 * uFill);`,
|
|
146
175
|
);
|
|
147
176
|
};
|
|
148
|
-
return fill;
|
|
177
|
+
return { fill, axis };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Give a loaded avatar (VRM or glTF) the same appear warp as the default body,
|
|
182
|
+
* so setAvatarAppear can bring it in. Settled (solid) to begin with.
|
|
183
|
+
*/
|
|
184
|
+
export function attachAvatarAppear(root: Object3D): void {
|
|
185
|
+
let shared: { fill: { value: number }; axis: { value: Vector3 } } | undefined;
|
|
186
|
+
root.traverse((child) => {
|
|
187
|
+
const mesh = child as Mesh;
|
|
188
|
+
if (!mesh.isMesh) return;
|
|
189
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
190
|
+
// Meshes can share a material: warp it once.
|
|
191
|
+
if (material.userData[APPEAR_KEY]) continue;
|
|
192
|
+
shared = attachAppear(material, shared);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
149
195
|
}
|
|
150
196
|
|
|
151
197
|
/** Tint a default avatar. Custom bodies are left alone. */
|
|
@@ -164,8 +210,11 @@ export function setAvatarColor(root: Object3D, color: string): boolean {
|
|
|
164
210
|
return found;
|
|
165
211
|
}
|
|
166
212
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Drive the point-fill on a default avatar or a loaded one. The surface warps
|
|
215
|
+
* along `axis` (a world direction; up by default). Returns false for anything else.
|
|
216
|
+
*/
|
|
217
|
+
export function setAvatarAppear(root: Object3D, amount: number, axis: { x: number; y: number; z: number } = UP): boolean {
|
|
169
218
|
let found = false;
|
|
170
219
|
root.traverse((child) => {
|
|
171
220
|
const mesh = child as Mesh;
|
|
@@ -175,6 +224,15 @@ export function setAvatarAppear(root: Object3D, amount: number): boolean {
|
|
|
175
224
|
const fill = material.userData[APPEAR_KEY] as { value: number } | undefined;
|
|
176
225
|
if (!fill) continue;
|
|
177
226
|
fill.value = amount;
|
|
227
|
+
(material.userData[APPEAR_AXIS_KEY] as { value: Vector3 } | undefined)?.value.set(axis.x, axis.y, axis.z);
|
|
228
|
+
// Blend only while fading in, so a settled figure draws as before.
|
|
229
|
+
// The face decal is always see-through; keep that.
|
|
230
|
+
material.userData.appearTransparent ??= material.transparent;
|
|
231
|
+
const blend = amount < 0.999 || material.userData.appearTransparent === true;
|
|
232
|
+
if (material.transparent !== blend) {
|
|
233
|
+
material.transparent = blend;
|
|
234
|
+
material.needsUpdate = true;
|
|
235
|
+
}
|
|
178
236
|
found = true;
|
|
179
237
|
}
|
|
180
238
|
});
|
|
@@ -300,11 +358,13 @@ export function getAvatarExpression(avatar: Object3D): AvatarExpression | null {
|
|
|
300
358
|
/**
|
|
301
359
|
* Swing the limbs of an avatar made by `createDefaultAvatar`. Call once per
|
|
302
360
|
* frame with how fast it is moving; anything else is ignored, so it is safe
|
|
303
|
-
* to call on custom avatars too.
|
|
361
|
+
* to call on custom avatars too. Returns true on the frame a foot lands,
|
|
362
|
+
* false otherwise, and null when `avatar` is not a default avatar.
|
|
304
363
|
*/
|
|
305
|
-
export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion):
|
|
364
|
+
export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): boolean | null {
|
|
306
365
|
const rig = avatar.userData[RIG_KEY] as AvatarRig | undefined;
|
|
307
|
-
if (!rig
|
|
366
|
+
if (!rig) return null;
|
|
367
|
+
if (motion.dt <= 0) return false;
|
|
308
368
|
|
|
309
369
|
const blend = 1 - Math.exp(-motion.dt * 10);
|
|
310
370
|
const walking = motion.grounded ? MathUtils.clamp(motion.speed / 4.5, 0, 1) : 0;
|
|
@@ -312,6 +372,7 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
|
|
|
312
372
|
rig.air += ((motion.grounded ? 0 : 1) - rig.air) * blend;
|
|
313
373
|
|
|
314
374
|
// Steps get longer, not just faster, as speed rises.
|
|
375
|
+
const before = footfalls(rig.phase);
|
|
315
376
|
rig.phase += motion.dt * (4 + motion.speed * 1.2) * (rig.stride > 0.01 ? 1 : 0);
|
|
316
377
|
const swing = Math.sin(rig.phase) * 0.75 * rig.stride;
|
|
317
378
|
|
|
@@ -324,6 +385,8 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
|
|
|
324
385
|
|
|
325
386
|
rig.torso.position.y = Math.abs(Math.cos(rig.phase)) * 0.045 * rig.stride;
|
|
326
387
|
rig.head.rotation.z = Math.sin(rig.phase) * 0.04 * rig.stride;
|
|
388
|
+
// A foot lands where the legs are furthest apart and the body is lowest.
|
|
389
|
+
const landed = motion.grounded && rig.stride > 0.15 && footfalls(rig.phase) !== before;
|
|
327
390
|
|
|
328
391
|
rig.blinkTimer -= motion.dt;
|
|
329
392
|
if (rig.blinkTimer <= 0) {
|
|
@@ -331,6 +394,12 @@ export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): vo
|
|
|
331
394
|
rig.blinkTimer = rig.blinking ? 0.12 : nextBlink();
|
|
332
395
|
rig.face.map = faceTexture(rig.expression, rig.blinking);
|
|
333
396
|
}
|
|
397
|
+
return landed;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** How many footfalls a walk cycle at `phase` has passed: one per half turn, offset to the widest stance. */
|
|
401
|
+
function footfalls(phase: number): number {
|
|
402
|
+
return Math.floor((phase - Math.PI / 2) / Math.PI);
|
|
334
403
|
}
|
|
335
404
|
|
|
336
405
|
function nextBlink(): number {
|
package/src/player/player.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Group, Mesh, Object3D, type Vector3 } from 'three';
|
|
2
2
|
import type { LoadedAvatar } from '../avatar/loader.js';
|
|
3
3
|
import type { PlayerOptions } from '../types.js';
|
|
4
|
+
import { setFigureStroke } from './stroke.js';
|
|
4
5
|
import {
|
|
5
6
|
animateDefaultAvatar,
|
|
6
7
|
createDefaultAvatar,
|
|
@@ -51,6 +52,8 @@ export class Player {
|
|
|
51
52
|
this.isDefaultAvatar = !options.avatar;
|
|
52
53
|
|
|
53
54
|
const body = options.avatar ?? createDefaultAvatar(options.height);
|
|
55
|
+
// The default figure always wears its ink outline, in the hub and every world alike.
|
|
56
|
+
if (this.isDefaultAvatar) setFigureStroke(body, true);
|
|
54
57
|
this.root.add(body as Object3D);
|
|
55
58
|
this.baseBody = body;
|
|
56
59
|
}
|
|
@@ -86,24 +89,30 @@ export class Player {
|
|
|
86
89
|
if (this.baseBody) this.baseBody.visible = true;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Place the avatar at the player's feet, facing `yaw`, and animate it for `motion`.
|
|
94
|
+
* Says whether a foot landed this frame, or null when the avatar's walk is
|
|
95
|
+
* not one the runtime animates and so cannot tell.
|
|
96
|
+
*/
|
|
97
|
+
sync(feet: Vector3, yaw: number, currentHeight: number, motion?: AvatarMotion, freeLook = false): boolean | null {
|
|
91
98
|
if (!freeLook) this.facing = yaw;
|
|
92
99
|
else if (motion && motion.speed > 0.3 && motion.heading !== undefined) {
|
|
93
100
|
let delta = motion.heading - this.facing;
|
|
94
101
|
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
|
|
95
102
|
this.facing += delta * (1 - Math.exp(-12 * motion.dt));
|
|
96
103
|
}
|
|
97
|
-
if (!this.root) return;
|
|
104
|
+
if (!this.root) return null;
|
|
98
105
|
this.root.position.copy(feet);
|
|
99
106
|
this.root.rotation.y = this.facing;
|
|
100
107
|
if (this.external) {
|
|
101
108
|
if (motion) this.external.animate(motion);
|
|
109
|
+
return null;
|
|
102
110
|
} else if (this.isDefaultAvatar) {
|
|
103
111
|
// Squash the default body while crouching instead of rebuilding it.
|
|
104
112
|
this.root.scale.y = currentHeight / this.height;
|
|
105
|
-
if (motion && this.baseBody) animateDefaultAvatar(this.baseBody, motion);
|
|
113
|
+
if (motion && this.baseBody) return animateDefaultAvatar(this.baseBody, motion);
|
|
106
114
|
}
|
|
115
|
+
return null;
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
setExpression(expression: AvatarExpression): void {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { BackSide, Mesh, MeshBasicMaterial, MeshToonMaterial, type Material, type Object3D } from 'three';
|
|
2
|
+
|
|
3
|
+
/** How far the shell sits outside the body, in metres. Thick enough to read as ink. */
|
|
4
|
+
const STROKE_WIDTH = 0.05;
|
|
5
|
+
/** Same ink as the door frames. Shown in both themes. */
|
|
6
|
+
const STROKE_COLOR = 0x1c1c1c;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A cartoon outline around the default white figure. Each body part gets a
|
|
10
|
+
* slightly larger shell of its back faces, so the ink shows only at the
|
|
11
|
+
* silhouette. The drawn-on face is left alone.
|
|
12
|
+
*/
|
|
13
|
+
export function setFigureStroke(root: Object3D | null, enabled: boolean): void {
|
|
14
|
+
if (!root) return;
|
|
15
|
+
const strokes: Mesh[] = [];
|
|
16
|
+
root.traverse((child) => {
|
|
17
|
+
if (child instanceof Mesh && child.userData.stroke === true) strokes.push(child);
|
|
18
|
+
});
|
|
19
|
+
if (!enabled) {
|
|
20
|
+
const materials = new Set<Material>();
|
|
21
|
+
for (const mesh of strokes) {
|
|
22
|
+
if (!Array.isArray(mesh.material)) materials.add(mesh.material);
|
|
23
|
+
mesh.removeFromParent();
|
|
24
|
+
}
|
|
25
|
+
for (const material of materials) material.dispose();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (strokes.length > 0) return;
|
|
29
|
+
|
|
30
|
+
const sources: Mesh[] = [];
|
|
31
|
+
root.traverse((child) => {
|
|
32
|
+
if (!(child instanceof Mesh) || child.userData.stroke === true) return;
|
|
33
|
+
if (!(child.material instanceof MeshToonMaterial)) return;
|
|
34
|
+
sources.push(child);
|
|
35
|
+
});
|
|
36
|
+
if (sources.length === 0) return;
|
|
37
|
+
|
|
38
|
+
const sample = sources[0].material as MeshToonMaterial;
|
|
39
|
+
const fading = sample.transparent;
|
|
40
|
+
const material = new MeshBasicMaterial({
|
|
41
|
+
color: STROKE_COLOR,
|
|
42
|
+
side: BackSide,
|
|
43
|
+
toneMapped: false,
|
|
44
|
+
transparent: fading,
|
|
45
|
+
opacity: fading ? sample.opacity : 1,
|
|
46
|
+
depthWrite: sample.depthWrite,
|
|
47
|
+
});
|
|
48
|
+
material.userData.stroke = true;
|
|
49
|
+
material.polygonOffset = true;
|
|
50
|
+
material.polygonOffsetFactor = 1;
|
|
51
|
+
material.polygonOffsetUnits = 1;
|
|
52
|
+
material.customProgramCacheKey = () => 'worldmesh-stroke';
|
|
53
|
+
material.onBeforeCompile = (shader) => {
|
|
54
|
+
shader.vertexShader = shader.vertexShader.replace(
|
|
55
|
+
'#include <begin_vertex>',
|
|
56
|
+
`#include <begin_vertex>\ntransformed += normalize(normal) * ${STROKE_WIDTH.toFixed(3)};`,
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (const source of sources) {
|
|
61
|
+
const stroke = new Mesh(source.geometry, material);
|
|
62
|
+
stroke.name = 'worldmesh:stroke';
|
|
63
|
+
stroke.userData.stroke = true;
|
|
64
|
+
stroke.position.copy(source.position);
|
|
65
|
+
stroke.quaternion.copy(source.quaternion);
|
|
66
|
+
stroke.scale.copy(source.scale);
|
|
67
|
+
stroke.castShadow = false;
|
|
68
|
+
stroke.receiveShadow = false;
|
|
69
|
+
stroke.raycast = () => {};
|
|
70
|
+
source.parent?.add(stroke);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Fade the outline with the figure. Shared across every part, so one write is enough. */
|
|
75
|
+
export function setStrokeOpacity(root: Object3D | null, amount: number): void {
|
|
76
|
+
if (!root) return;
|
|
77
|
+
const solid = amount >= 0.999;
|
|
78
|
+
let material: MeshBasicMaterial | null = null;
|
|
79
|
+
root.traverse((child) => {
|
|
80
|
+
if (material || !(child instanceof Mesh) || child.userData.stroke !== true) return;
|
|
81
|
+
if (child.material instanceof MeshBasicMaterial) material = child.material;
|
|
82
|
+
});
|
|
83
|
+
const stroke = material as MeshBasicMaterial | null;
|
|
84
|
+
if (!stroke) return;
|
|
85
|
+
stroke.transparent = !solid;
|
|
86
|
+
stroke.opacity = amount;
|
|
87
|
+
stroke.depthWrite = solid;
|
|
88
|
+
stroke.needsUpdate = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isStrokeMaterial(material: Material): boolean {
|
|
92
|
+
return material.userData.stroke === true;
|
|
93
|
+
}
|
package/src/portals/portals.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Vector3,
|
|
13
13
|
} from 'three';
|
|
14
14
|
import { withAvatarTicket } from '../avatar/handoff.js';
|
|
15
|
+
import { withView } from '../camera/viewHandoff.js';
|
|
15
16
|
import type { PortalMode, PortalOptions } from '../types.js';
|
|
16
17
|
|
|
17
18
|
export interface ResolvedPortal extends PortalOptions {
|
|
@@ -110,7 +111,7 @@ export class PortalManager {
|
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
113
|
* Build the destination URL, carrying a back-reference to this world, and the
|
|
113
|
-
* visitor's avatar handoff ticket (in the fragment) if
|
|
114
|
+
* visitor's avatar handoff ticket and camera view (in the fragment) if any.
|
|
114
115
|
*/
|
|
115
116
|
export function buildTravelUrl(target: string): string {
|
|
116
117
|
try {
|
|
@@ -118,7 +119,9 @@ export function buildTravelUrl(target: string): string {
|
|
|
118
119
|
if (!url.searchParams.has('from')) {
|
|
119
120
|
url.searchParams.set('from', window.location.origin + window.location.pathname);
|
|
120
121
|
}
|
|
121
|
-
|
|
122
|
+
// A world's own fragment (e.g. a hash route) is left alone.
|
|
123
|
+
if (url.hash) return url.toString();
|
|
124
|
+
return withView(withAvatarTicket(url)).toString();
|
|
122
125
|
} catch {
|
|
123
126
|
return target;
|
|
124
127
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AvatarDescriptor } from './avatar/descriptor.js';
|
|
1
|
+
import type { AvatarDescriptor, SignedAvatarShare } from './avatar/descriptor.js';
|
|
2
2
|
import type { AvatarExpression } from './player/avatar.js';
|
|
3
3
|
import type { Camera, Object3D, PerspectiveCamera, Scene, WebGLRenderer } from 'three';
|
|
4
4
|
|
|
@@ -134,7 +134,7 @@ export interface PortalOptions {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
export interface UiOptions {
|
|
137
|
-
/** Crosshair dot in
|
|
137
|
+
/** Crosshair dot in the middle of the screen. Off unless set to true. */
|
|
138
138
|
crosshair?: boolean;
|
|
139
139
|
/** Bottom-left WorldMesh badge linking back to the hub. */
|
|
140
140
|
badge?: boolean;
|
|
@@ -191,6 +191,12 @@ export interface WorldMeshEvents {
|
|
|
191
191
|
'avatar:error': { descriptor: AvatarDescriptor | null; error: unknown };
|
|
192
192
|
/** People in this world including the visitor, or null while not connected. Only with `multiplayer`. */
|
|
193
193
|
players: { count: number | null };
|
|
194
|
+
/** The presence relay refused the join because the room is at capacity. */
|
|
195
|
+
'room:full': { full: boolean };
|
|
196
|
+
/** An immersive-vr session started. */
|
|
197
|
+
'vr:enter': Record<string, never>;
|
|
198
|
+
/** The immersive-vr session ended. */
|
|
199
|
+
'vr:exit': Record<string, never>;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
202
|
/** Someone else's body, an upright cylinder the local player cannot walk through. */
|
|
@@ -205,17 +211,24 @@ export interface PeerBody {
|
|
|
205
211
|
|
|
206
212
|
/**
|
|
207
213
|
* Multiplayer seam. `multiplayer` plugs in the built-in relay client; pass
|
|
208
|
-
* your own adapter
|
|
214
|
+
* your own adapter as `network` to network the world some other way
|
|
215
|
+
* (docs/compatibility.md, "Your own network layer"). The runtime calls only
|
|
216
|
+
* `attach`, `bodies` and `detach`; the adapter does everything else itself.
|
|
209
217
|
*/
|
|
210
218
|
export interface NetworkAdapter {
|
|
211
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Called once when the world goes online, with the world's handle. Listen
|
|
221
|
+
* to its `update` event for the local player's state every frame. In a
|
|
222
|
+
* hub's live doorway the world stays offline until the visitor walks in.
|
|
223
|
+
*/
|
|
212
224
|
attach(world: WorldMeshHandle): void;
|
|
213
|
-
/**
|
|
225
|
+
/** Never called by the runtime: a conventional name for the adapter's own send, called from `update`. */
|
|
214
226
|
sendLocalState?(state: PlayerState): void;
|
|
215
|
-
/**
|
|
227
|
+
/** Never called by the runtime: a conventional name for the adapter's own receive. */
|
|
216
228
|
onRemoteState?(peerId: string, state: PlayerState): void;
|
|
217
|
-
/** Where everyone else is standing right now. The local player is kept out of them. */
|
|
229
|
+
/** Where everyone else is standing right now, asked every step. The local player is kept out of them. */
|
|
218
230
|
bodies?(): Iterable<PeerBody>;
|
|
231
|
+
/** Called from `world.dispose()`. Disconnect and remove what the adapter added to the scene. */
|
|
219
232
|
detach?(): void;
|
|
220
233
|
}
|
|
221
234
|
|
|
@@ -253,8 +266,15 @@ export interface WorldMeshOptions {
|
|
|
253
266
|
* Ignored when `network` is set.
|
|
254
267
|
*/
|
|
255
268
|
multiplayer?: boolean | MultiplayerOptions;
|
|
269
|
+
/** Play footstep sounds as the player walks. Off by default. */
|
|
270
|
+
footsteps?: boolean;
|
|
256
271
|
/** A custom network layer. Replaces `multiplayer`. */
|
|
257
272
|
network?: NetworkAdapter;
|
|
273
|
+
/**
|
|
274
|
+
* Offer immersive VR through the shared Enter VR control. Defaults to true.
|
|
275
|
+
* The button only appears when the browser can start an `immersive-vr` session.
|
|
276
|
+
*/
|
|
277
|
+
vr?: boolean;
|
|
258
278
|
}
|
|
259
279
|
|
|
260
280
|
export interface MultiplayerOptions {
|
|
@@ -285,19 +305,42 @@ export interface WorldMeshHandle {
|
|
|
285
305
|
|
|
286
306
|
setViewMode(mode: ViewMode): void;
|
|
287
307
|
getViewMode(): ViewMode;
|
|
308
|
+
/** How far back the third-person camera sits, in metres. */
|
|
309
|
+
getCameraDistance(): number;
|
|
310
|
+
/** Move the third-person camera in or out (0.4 m at the closest); it follows at once when closer. */
|
|
311
|
+
setCameraDistance(distance: number): void;
|
|
288
312
|
|
|
289
313
|
addPortal(portal: PortalOptions): void;
|
|
290
314
|
/** Navigate to another WorldMesh world, carrying a `from` back-reference. */
|
|
291
315
|
travelTo(url: string): void;
|
|
316
|
+
/** Free the cursor for leaving the page, without bringing up the pause screen. */
|
|
317
|
+
releasePointer(): void;
|
|
292
318
|
|
|
293
319
|
on<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): () => void;
|
|
294
320
|
off<K extends keyof WorldMeshEvents>(event: K, fn: (payload: WorldMeshEvents[K]) => void): void;
|
|
295
321
|
|
|
296
|
-
/**
|
|
297
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Show an external avatar. Resolves to false (default body kept) if it
|
|
324
|
+
* cannot be loaded. Pass `data` (downloadAvatarModel) if the model file is
|
|
325
|
+
* already downloaded, and it is not fetched again.
|
|
326
|
+
*/
|
|
327
|
+
loadAvatar(descriptor: AvatarDescriptor, data?: ArrayBuffer): Promise<boolean>;
|
|
298
328
|
/** Go back to the world's default body. */
|
|
299
329
|
clearAvatar(): void;
|
|
330
|
+
/**
|
|
331
|
+
* The link other players may download this visitor's avatar from, signed by
|
|
332
|
+
* the hub and renewed before it expires; null for the default body or an
|
|
333
|
+
* avatar that may not be shown to others. For a custom `network` to send.
|
|
334
|
+
*/
|
|
335
|
+
getSharedAvatar(): SignedAvatarShare | null;
|
|
300
336
|
|
|
301
337
|
/** Refresh the collider list after the world adds geometry. */
|
|
302
338
|
refreshColliders(): void;
|
|
339
|
+
|
|
340
|
+
/** Start an immersive-vr session. Resolves false if the browser refuses. */
|
|
341
|
+
enterVR(): Promise<boolean>;
|
|
342
|
+
/** End the immersive-vr session, if one is running. */
|
|
343
|
+
exitVR(): Promise<void>;
|
|
344
|
+
/** True while a WebXR immersive-vr session is presenting. */
|
|
345
|
+
isVR(): boolean;
|
|
303
346
|
}
|