@worldmesh/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/abilities/abilities.d.ts +9 -0
- package/dist/abilities/abilities.d.ts.map +1 -0
- package/dist/abilities/abilities.js +25 -0
- package/dist/abilities/abilities.js.map +1 -0
- package/dist/camera/cameraRig.d.ts +39 -0
- package/dist/camera/cameraRig.d.ts.map +1 -0
- package/dist/camera/cameraRig.js +88 -0
- package/dist/camera/cameraRig.js.map +1 -0
- package/dist/controls/input.d.ts +64 -0
- package/dist/controls/input.d.ts.map +1 -0
- package/dist/controls/input.js +236 -0
- package/dist/controls/input.js.map +1 -0
- package/dist/controls/keymap.d.ts +8 -0
- package/dist/controls/keymap.d.ts.map +1 -0
- package/dist/controls/keymap.js +21 -0
- package/dist/controls/keymap.js.map +1 -0
- package/dist/controls/touch.d.ts +43 -0
- package/dist/controls/touch.d.ts.map +1 -0
- package/dist/controls/touch.js +329 -0
- package/dist/controls/touch.js.map +1 -0
- package/dist/core/events.d.ts +9 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +27 -0
- package/dist/core/events.js.map +1 -0
- package/dist/core/worldmesh.d.ts +10 -0
- package/dist/core/worldmesh.d.ts.map +1 -0
- package/dist/core/worldmesh.js +283 -0
- package/dist/core/worldmesh.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/movement/collision.d.ts +39 -0
- package/dist/movement/collision.d.ts.map +1 -0
- package/dist/movement/collision.js +93 -0
- package/dist/movement/collision.js.map +1 -0
- package/dist/movement/controller.d.ts +53 -0
- package/dist/movement/controller.d.ts.map +1 -0
- package/dist/movement/controller.js +200 -0
- package/dist/movement/controller.js.map +1 -0
- package/dist/net/adapter.d.ts +22 -0
- package/dist/net/adapter.d.ts.map +1 -0
- package/dist/net/adapter.js +8 -0
- package/dist/net/adapter.js.map +1 -0
- package/dist/player/avatar.d.ts +29 -0
- package/dist/player/avatar.d.ts.map +1 -0
- package/dist/player/avatar.js +420 -0
- package/dist/player/avatar.js.map +1 -0
- package/dist/player/player.d.ts +30 -0
- package/dist/player/player.d.ts.map +1 -0
- package/dist/player/player.js +76 -0
- package/dist/player/player.js.map +1 -0
- package/dist/portals/portals.d.ts +34 -0
- package/dist/portals/portals.d.ts.map +1 -0
- package/dist/portals/portals.js +132 -0
- package/dist/portals/portals.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/overlay.d.ts +19 -0
- package/dist/ui/overlay.d.ts.map +1 -0
- package/dist/ui/overlay.js +370 -0
- package/dist/ui/overlay.js.map +1 -0
- package/package.json +63 -0
- package/src/abilities/abilities.ts +30 -0
- package/src/camera/cameraRig.ts +106 -0
- package/src/controls/input.ts +252 -0
- package/src/controls/keymap.ts +23 -0
- package/src/controls/touch.ts +378 -0
- package/src/core/events.ts +31 -0
- package/src/core/worldmesh.ts +313 -0
- package/src/index.ts +46 -0
- package/src/movement/collision.ts +109 -0
- package/src/movement/controller.ts +248 -0
- package/src/net/adapter.ts +28 -0
- package/src/player/avatar.ts +495 -0
- package/src/player/player.ts +91 -0
- package/src/portals/portals.ts +173 -0
- package/src/types.ts +230 -0
- package/src/ui/overlay.ts +398 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BufferAttribute,
|
|
3
|
+
CanvasTexture,
|
|
4
|
+
Group,
|
|
5
|
+
LatheGeometry,
|
|
6
|
+
MathUtils,
|
|
7
|
+
Mesh,
|
|
8
|
+
MeshBasicMaterial,
|
|
9
|
+
MeshStandardMaterial,
|
|
10
|
+
RingGeometry,
|
|
11
|
+
SphereGeometry,
|
|
12
|
+
SplineCurve,
|
|
13
|
+
SRGBColorSpace,
|
|
14
|
+
Vector2,
|
|
15
|
+
type BufferGeometry,
|
|
16
|
+
type Object3D,
|
|
17
|
+
} from 'three';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The default WorldMesh body: a small white figure with a big round head,
|
|
21
|
+
* a drawn-on face, a pear-shaped body and stubby limbs. Every world gets the
|
|
22
|
+
* same one until visitors bring their own, so it is deliberately plain.
|
|
23
|
+
*
|
|
24
|
+
* Built for a 1.8 m standing height with feet at y = 0, facing -Z (the
|
|
25
|
+
* direction the camera looks at yaw 0). Scale the result for other heights.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const RIG_KEY = 'worldmeshAvatarRig';
|
|
29
|
+
|
|
30
|
+
/** Faces the default figure can pull, in number-key order (1–9, then 0). */
|
|
31
|
+
export const AVATAR_EXPRESSIONS = [
|
|
32
|
+
'smile',
|
|
33
|
+
'grin',
|
|
34
|
+
'laugh',
|
|
35
|
+
'wink',
|
|
36
|
+
'love',
|
|
37
|
+
'surprised',
|
|
38
|
+
'sad',
|
|
39
|
+
'angry',
|
|
40
|
+
'sleepy',
|
|
41
|
+
'neutral',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type AvatarExpression = (typeof AVATAR_EXPRESSIONS)[number];
|
|
45
|
+
|
|
46
|
+
/** The expression bound to a number key: 1 is the first, 0 the tenth. */
|
|
47
|
+
export function expressionForDigit(digit: number): AvatarExpression | null {
|
|
48
|
+
if (!Number.isInteger(digit) || digit < 0 || digit > 9) return null;
|
|
49
|
+
return AVATAR_EXPRESSIONS[(digit + 9) % 10];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isAvatarExpression(value: unknown): value is AvatarExpression {
|
|
53
|
+
return typeof value === 'string' && (AVATAR_EXPRESSIONS as readonly string[]).includes(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface AvatarRig {
|
|
57
|
+
torso: Group;
|
|
58
|
+
head: Group;
|
|
59
|
+
face: MeshBasicMaterial;
|
|
60
|
+
armL: Group;
|
|
61
|
+
armR: Group;
|
|
62
|
+
legL: Group;
|
|
63
|
+
legR: Group;
|
|
64
|
+
phase: number;
|
|
65
|
+
stride: number;
|
|
66
|
+
air: number;
|
|
67
|
+
expression: AvatarExpression;
|
|
68
|
+
blinking: boolean;
|
|
69
|
+
/** Seconds until the next blink starts, or until the current one ends. */
|
|
70
|
+
blinkTimer: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AvatarMotion {
|
|
74
|
+
dt: number;
|
|
75
|
+
/** Horizontal speed in metres per second. */
|
|
76
|
+
speed: number;
|
|
77
|
+
grounded: boolean;
|
|
78
|
+
/** Direction of travel as a yaw, used to turn the body in third person. */
|
|
79
|
+
heading?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const HEAD_RADIUS = 0.47;
|
|
83
|
+
const HEAD_Y = 1.33;
|
|
84
|
+
/** How far the arms hang away from the body at rest, in radians. */
|
|
85
|
+
const ARM_REST = 0.4;
|
|
86
|
+
|
|
87
|
+
export function createDefaultAvatar(height = 1.8): Group {
|
|
88
|
+
const root = new Group();
|
|
89
|
+
root.name = 'worldmesh:avatar';
|
|
90
|
+
|
|
91
|
+
// Soft glossy white; a little self-light keeps the shaded side from going grey.
|
|
92
|
+
const skin = new MeshStandardMaterial({ color: 0xf7f7f7, emissive: 0x1e1e1e, roughness: 0.42, metalness: 0 });
|
|
93
|
+
const shaded = (geometry: BufferGeometry) => {
|
|
94
|
+
const mesh = new Mesh(geometry, skin);
|
|
95
|
+
mesh.castShadow = true;
|
|
96
|
+
return mesh;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Everything above the legs bobs together while walking.
|
|
100
|
+
const torso = new Group();
|
|
101
|
+
root.add(torso);
|
|
102
|
+
|
|
103
|
+
// One soft bean: shoulders tucked right under the head, rounding off, a
|
|
104
|
+
// belly that is widest low down, and a rounded seat the legs grow out of.
|
|
105
|
+
const body = shaded(lathe([
|
|
106
|
+
[0, 1.1],
|
|
107
|
+
[0.12, 1.08],
|
|
108
|
+
[0.19, 1.02],
|
|
109
|
+
[0.217, 0.93],
|
|
110
|
+
[0.227, 0.8],
|
|
111
|
+
[0.232, 0.56],
|
|
112
|
+
[0.243, 0.44],
|
|
113
|
+
[0.232, 0.36],
|
|
114
|
+
[0.205, 0.29],
|
|
115
|
+
[0.155, 0.24],
|
|
116
|
+
[0.08, 0.214],
|
|
117
|
+
[0, 0.208],
|
|
118
|
+
]));
|
|
119
|
+
body.scale.z = 0.84;
|
|
120
|
+
torso.add(body);
|
|
121
|
+
|
|
122
|
+
const head = new Group();
|
|
123
|
+
head.position.y = HEAD_Y;
|
|
124
|
+
torso.add(head);
|
|
125
|
+
head.add(shaded(new SphereGeometry(HEAD_RADIUS, 64, 48)));
|
|
126
|
+
|
|
127
|
+
const faceMaterial = new MeshBasicMaterial({
|
|
128
|
+
map: faceTexture('smile', false),
|
|
129
|
+
transparent: true,
|
|
130
|
+
depthWrite: false,
|
|
131
|
+
polygonOffset: true,
|
|
132
|
+
polygonOffsetFactor: -1,
|
|
133
|
+
});
|
|
134
|
+
const face = new Mesh(faceGeometry(), faceMaterial);
|
|
135
|
+
face.name = 'worldmesh:avatar-face';
|
|
136
|
+
// Built facing +Z; turn it to the front of the figure.
|
|
137
|
+
face.rotation.y = Math.PI;
|
|
138
|
+
head.add(face);
|
|
139
|
+
|
|
140
|
+
// Limbs hang from pivots at the shoulders and hips so they can swing.
|
|
141
|
+
const limb = (x: number, y: number, length: number, top: number, bottom: number, target: Object3D) => {
|
|
142
|
+
const pivot = new Group();
|
|
143
|
+
pivot.position.set(x, y, 0);
|
|
144
|
+
pivot.add(shaded(taperedCapsule(length, top, bottom)));
|
|
145
|
+
target.add(pivot);
|
|
146
|
+
return pivot;
|
|
147
|
+
};
|
|
148
|
+
// Thick, soft arms whose round tops sink into the shoulders, so there is
|
|
149
|
+
// no seam; hands swell slightly at the tips.
|
|
150
|
+
const armL = limb(-0.182, 0.92, 0.52, 0.074, 0.08, torso);
|
|
151
|
+
const armR = limb(0.182, 0.92, 0.52, 0.074, 0.08, torso);
|
|
152
|
+
armL.rotation.z = -ARM_REST;
|
|
153
|
+
armR.rotation.z = ARM_REST;
|
|
154
|
+
// Stubby legs, wide where they leave the body and rounded at the feet.
|
|
155
|
+
const legL = limb(-0.1, 0.34, 0.34, 0.088, 0.08, root);
|
|
156
|
+
const legR = limb(0.1, 0.34, 0.34, 0.088, 0.08, root);
|
|
157
|
+
|
|
158
|
+
const rig: AvatarRig = {
|
|
159
|
+
torso,
|
|
160
|
+
head,
|
|
161
|
+
face: faceMaterial,
|
|
162
|
+
armL,
|
|
163
|
+
armR,
|
|
164
|
+
legL,
|
|
165
|
+
legR,
|
|
166
|
+
phase: 0,
|
|
167
|
+
stride: 0,
|
|
168
|
+
air: 0,
|
|
169
|
+
expression: 'smile',
|
|
170
|
+
blinking: false,
|
|
171
|
+
blinkTimer: nextBlink(),
|
|
172
|
+
};
|
|
173
|
+
root.userData[RIG_KEY] = rig;
|
|
174
|
+
|
|
175
|
+
if (height !== 1.8) root.scale.setScalar(height / 1.8);
|
|
176
|
+
return root;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Change the face of an avatar made by `createDefaultAvatar`. Unknown names
|
|
181
|
+
* and custom avatars are ignored.
|
|
182
|
+
*/
|
|
183
|
+
export function setAvatarExpression(avatar: Object3D, expression: string): void {
|
|
184
|
+
const rig = avatar.userData[RIG_KEY] as AvatarRig | undefined;
|
|
185
|
+
if (!rig || !isAvatarExpression(expression) || rig.expression === expression) return;
|
|
186
|
+
rig.expression = expression;
|
|
187
|
+
rig.face.map = faceTexture(expression, rig.blinking);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getAvatarExpression(avatar: Object3D): AvatarExpression | null {
|
|
191
|
+
const rig = avatar.userData[RIG_KEY] as AvatarRig | undefined;
|
|
192
|
+
return rig ? rig.expression : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Swing the limbs of an avatar made by `createDefaultAvatar`. Call once per
|
|
197
|
+
* frame with how fast it is moving; anything else is ignored, so it is safe
|
|
198
|
+
* to call on custom avatars too.
|
|
199
|
+
*/
|
|
200
|
+
export function animateDefaultAvatar(avatar: Object3D, motion: AvatarMotion): void {
|
|
201
|
+
const rig = avatar.userData[RIG_KEY] as AvatarRig | undefined;
|
|
202
|
+
if (!rig || motion.dt <= 0) return;
|
|
203
|
+
|
|
204
|
+
const blend = 1 - Math.exp(-motion.dt * 10);
|
|
205
|
+
const walking = motion.grounded ? MathUtils.clamp(motion.speed / 4.5, 0, 1) : 0;
|
|
206
|
+
rig.stride += (walking - rig.stride) * blend;
|
|
207
|
+
rig.air += ((motion.grounded ? 0 : 1) - rig.air) * blend;
|
|
208
|
+
|
|
209
|
+
// Steps get longer, not just faster, as speed rises.
|
|
210
|
+
rig.phase += motion.dt * (4 + motion.speed * 1.2) * (rig.stride > 0.01 ? 1 : 0);
|
|
211
|
+
const swing = Math.sin(rig.phase) * 0.75 * rig.stride;
|
|
212
|
+
|
|
213
|
+
rig.legL.rotation.x = swing + rig.air * 0.35;
|
|
214
|
+
rig.legR.rotation.x = -swing - rig.air * 0.15;
|
|
215
|
+
rig.armL.rotation.x = -swing * 0.8;
|
|
216
|
+
rig.armR.rotation.x = swing * 0.8;
|
|
217
|
+
rig.armL.rotation.z = -ARM_REST - rig.air * 0.6;
|
|
218
|
+
rig.armR.rotation.z = ARM_REST + rig.air * 0.6;
|
|
219
|
+
|
|
220
|
+
rig.torso.position.y = Math.abs(Math.cos(rig.phase)) * 0.045 * rig.stride;
|
|
221
|
+
rig.head.rotation.z = Math.sin(rig.phase) * 0.04 * rig.stride;
|
|
222
|
+
|
|
223
|
+
rig.blinkTimer -= motion.dt;
|
|
224
|
+
if (rig.blinkTimer <= 0) {
|
|
225
|
+
rig.blinking = !rig.blinking;
|
|
226
|
+
rig.blinkTimer = rig.blinking ? 0.12 : nextBlink();
|
|
227
|
+
rig.face.map = faceTexture(rig.expression, rig.blinking);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function nextBlink(): number {
|
|
232
|
+
return 2.5 + Math.random() * 3.5;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- Geometry ---------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/** A smooth solid of revolution through (radius, y) points, top to bottom. */
|
|
238
|
+
function lathe(profile: [number, number][]): LatheGeometry {
|
|
239
|
+
const curve = new SplineCurve(profile.map(([r, y]) => new Vector2(r, y)));
|
|
240
|
+
// Lathe wants the profile bottom to top so the faces point outward.
|
|
241
|
+
const points = curve.getSpacedPoints(96).reverse();
|
|
242
|
+
points[0].x = 0;
|
|
243
|
+
points[points.length - 1].x = 0;
|
|
244
|
+
return new LatheGeometry(points, 64);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A capsule hanging down from its top, `top` wide at the pivot and `bottom` wide at the tip. */
|
|
248
|
+
function taperedCapsule(length: number, top: number, bottom: number): LatheGeometry {
|
|
249
|
+
const points: Vector2[] = [];
|
|
250
|
+
const steps = 16;
|
|
251
|
+
// Bottom cap, then the straight taper, then the top cap.
|
|
252
|
+
for (let i = 0; i <= steps; i++) {
|
|
253
|
+
const a = -Math.PI / 2 + (i / steps) * (Math.PI / 2);
|
|
254
|
+
points.push(new Vector2(Math.cos(a) * bottom, -length + bottom + Math.sin(a) * bottom));
|
|
255
|
+
}
|
|
256
|
+
for (let i = 0; i <= steps; i++) {
|
|
257
|
+
const a = (i / steps) * (Math.PI / 2);
|
|
258
|
+
points.push(new Vector2(Math.cos(a) * top, -top + Math.sin(a) * top));
|
|
259
|
+
}
|
|
260
|
+
points[0].x = 0;
|
|
261
|
+
points[points.length - 1].x = 0;
|
|
262
|
+
return new LatheGeometry(points, 40);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Half the width of the square the face is drawn in, in metres on the head. */
|
|
266
|
+
const FACE_EXTENT = 0.4;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A disc wrapped onto the front of the head. Its UVs are a straight front-on
|
|
270
|
+
* projection, so whatever is drawn on the face canvas looks exactly like that
|
|
271
|
+
* when the figure faces the camera.
|
|
272
|
+
*/
|
|
273
|
+
function faceGeometry(): RingGeometry {
|
|
274
|
+
const geometry = new RingGeometry(0.001, FACE_EXTENT - 0.02, 64, 24);
|
|
275
|
+
const position = geometry.getAttribute('position') as BufferAttribute;
|
|
276
|
+
const normals = new Float32Array(position.count * 3);
|
|
277
|
+
const radius = HEAD_RADIUS * 1.004;
|
|
278
|
+
for (let i = 0; i < position.count; i++) {
|
|
279
|
+
const x = position.getX(i);
|
|
280
|
+
const y = position.getY(i);
|
|
281
|
+
const z = Math.sqrt(Math.max(0, radius * radius - x * x - y * y));
|
|
282
|
+
position.setZ(i, z);
|
|
283
|
+
normals.set([x / radius, y / radius, z / radius], i * 3);
|
|
284
|
+
}
|
|
285
|
+
geometry.setAttribute('normal', new BufferAttribute(normals, 3));
|
|
286
|
+
const uv = geometry.getAttribute('uv') as BufferAttribute;
|
|
287
|
+
for (let i = 0; i < uv.count; i++) {
|
|
288
|
+
uv.setXY(i, (position.getX(i) + FACE_EXTENT) / (2 * FACE_EXTENT), (position.getY(i) + FACE_EXTENT) / (2 * FACE_EXTENT));
|
|
289
|
+
}
|
|
290
|
+
return geometry;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Faces ------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
const FACE_SIZE = 512;
|
|
296
|
+
const INK = '#111114';
|
|
297
|
+
const EYE_X = 0.22;
|
|
298
|
+
const EYE_Y = -0.055;
|
|
299
|
+
const EYE_R = 0.05;
|
|
300
|
+
const LINE = 0.014;
|
|
301
|
+
/** How big the features are drawn, relative to the layout below. */
|
|
302
|
+
const FACE_SCALE = 0.78;
|
|
303
|
+
/** The point between the eyes and the mouth the features shrink toward. */
|
|
304
|
+
const FACE_CENTER_Y = -0.13;
|
|
305
|
+
|
|
306
|
+
/** Every avatar shares one texture per face, so a crowd costs no more than one visitor. */
|
|
307
|
+
const faceCache = new Map<string, CanvasTexture>();
|
|
308
|
+
|
|
309
|
+
function faceTexture(expression: AvatarExpression, blinking: boolean): CanvasTexture {
|
|
310
|
+
const key = `${expression}${blinking ? ':blink' : ''}`;
|
|
311
|
+
let texture = faceCache.get(key);
|
|
312
|
+
if (texture) return texture;
|
|
313
|
+
|
|
314
|
+
const canvas = document.createElement('canvas');
|
|
315
|
+
canvas.width = canvas.height = FACE_SIZE;
|
|
316
|
+
const ctx = canvas.getContext('2d')!;
|
|
317
|
+
// Draw in metres on the head, y up, origin at the head centre.
|
|
318
|
+
const scale = FACE_SIZE / (2 * FACE_EXTENT);
|
|
319
|
+
ctx.setTransform(scale, 0, 0, -scale, FACE_SIZE / 2, FACE_SIZE / 2);
|
|
320
|
+
// Shrink the features around the middle of the face, not the head centre,
|
|
321
|
+
// so the face gets smaller without sliding up the head.
|
|
322
|
+
ctx.translate(0, FACE_CENTER_Y);
|
|
323
|
+
ctx.scale(FACE_SCALE, FACE_SCALE);
|
|
324
|
+
ctx.translate(0, -FACE_CENTER_Y);
|
|
325
|
+
ctx.lineCap = 'round';
|
|
326
|
+
ctx.lineJoin = 'round';
|
|
327
|
+
drawFace(ctx, expression, blinking);
|
|
328
|
+
|
|
329
|
+
texture = new CanvasTexture(canvas);
|
|
330
|
+
texture.colorSpace = SRGBColorSpace;
|
|
331
|
+
texture.anisotropy = 4;
|
|
332
|
+
faceCache.set(key, texture);
|
|
333
|
+
return texture;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
type Ctx = CanvasRenderingContext2D;
|
|
337
|
+
|
|
338
|
+
function drawFace(ctx: Ctx, expression: AvatarExpression, blinking: boolean): void {
|
|
339
|
+
const eyes = (radius = EYE_R) => {
|
|
340
|
+
for (const side of [-1, 1]) {
|
|
341
|
+
if (blinking) closedEye(ctx, side * EYE_X, EYE_Y, 'flat');
|
|
342
|
+
else dotEye(ctx, side * EYE_X, EYE_Y, radius);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
switch (expression) {
|
|
347
|
+
case 'smile':
|
|
348
|
+
eyes();
|
|
349
|
+
smile(ctx, 0.084, -0.127, -0.235);
|
|
350
|
+
break;
|
|
351
|
+
case 'grin':
|
|
352
|
+
eyes();
|
|
353
|
+
openMouth(ctx, 0.11, -0.115, -0.285);
|
|
354
|
+
break;
|
|
355
|
+
case 'laugh':
|
|
356
|
+
for (const side of [-1, 1]) closedEye(ctx, side * EYE_X, EYE_Y, 'happy');
|
|
357
|
+
openMouth(ctx, 0.125, -0.105, -0.31);
|
|
358
|
+
break;
|
|
359
|
+
case 'wink':
|
|
360
|
+
// The figure's right eye (the viewer's left) stays open.
|
|
361
|
+
if (blinking) closedEye(ctx, -EYE_X, EYE_Y, 'flat');
|
|
362
|
+
else dotEye(ctx, -EYE_X, EYE_Y, EYE_R);
|
|
363
|
+
closedEye(ctx, EYE_X, EYE_Y, 'happy');
|
|
364
|
+
smile(ctx, 0.084, -0.127, -0.235);
|
|
365
|
+
break;
|
|
366
|
+
case 'love':
|
|
367
|
+
for (const side of [-1, 1]) heart(ctx, side * EYE_X, EYE_Y, 0.092);
|
|
368
|
+
smile(ctx, 0.084, -0.127, -0.235);
|
|
369
|
+
break;
|
|
370
|
+
case 'surprised':
|
|
371
|
+
eyes(EYE_R * 1.2);
|
|
372
|
+
for (const side of [-1, 1]) brow(ctx, side, 0.05, 0.05, 0.035);
|
|
373
|
+
ctx.fillStyle = INK;
|
|
374
|
+
ctx.beginPath();
|
|
375
|
+
ctx.ellipse(0, -0.175, 0.04, 0.052, 0, 0, Math.PI * 2);
|
|
376
|
+
ctx.fill();
|
|
377
|
+
break;
|
|
378
|
+
case 'sad':
|
|
379
|
+
eyes();
|
|
380
|
+
for (const side of [-1, 1]) brow(ctx, side, 0.06, -0.035);
|
|
381
|
+
smile(ctx, 0.07, -0.19, -0.1);
|
|
382
|
+
tear(ctx, EYE_X + 0.01, EYE_Y - 0.085);
|
|
383
|
+
break;
|
|
384
|
+
case 'angry':
|
|
385
|
+
eyes();
|
|
386
|
+
for (const side of [-1, 1]) brow(ctx, side, -0.015, 0.05);
|
|
387
|
+
smile(ctx, 0.075, -0.18, -0.115);
|
|
388
|
+
break;
|
|
389
|
+
case 'sleepy':
|
|
390
|
+
for (const side of [-1, 1]) closedEye(ctx, side * EYE_X, EYE_Y, 'sleep');
|
|
391
|
+
ctx.fillStyle = INK;
|
|
392
|
+
ctx.beginPath();
|
|
393
|
+
ctx.ellipse(0, -0.17, 0.022, 0.028, 0, 0, Math.PI * 2);
|
|
394
|
+
ctx.fill();
|
|
395
|
+
break;
|
|
396
|
+
case 'neutral':
|
|
397
|
+
eyes();
|
|
398
|
+
line(ctx, [[-0.065, -0.155], [0.065, -0.155]]);
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function dotEye(ctx: Ctx, x: number, y: number, radius: number): void {
|
|
404
|
+
ctx.fillStyle = INK;
|
|
405
|
+
ctx.beginPath();
|
|
406
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
407
|
+
ctx.fill();
|
|
408
|
+
ctx.fillStyle = '#ffffff';
|
|
409
|
+
ctx.beginPath();
|
|
410
|
+
ctx.arc(x - radius * 0.5, y + radius * 0.46, radius * 0.26, 0, Math.PI * 2);
|
|
411
|
+
ctx.fill();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** `happy` is an upturned ^, `sleep` a relaxed downturned curve, `flat` a blink. */
|
|
415
|
+
function closedEye(ctx: Ctx, x: number, y: number, shape: 'happy' | 'sleep' | 'flat'): void {
|
|
416
|
+
const w = EYE_R * 1.15;
|
|
417
|
+
const bend = shape === 'happy' ? 0.07 : shape === 'sleep' ? -0.04 : 0;
|
|
418
|
+
ctx.strokeStyle = INK;
|
|
419
|
+
ctx.lineWidth = LINE * 1.15;
|
|
420
|
+
ctx.beginPath();
|
|
421
|
+
ctx.moveTo(x - w, y);
|
|
422
|
+
ctx.quadraticCurveTo(x, y + bend, x + w, y);
|
|
423
|
+
ctx.stroke();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A curve from (±halfWidth, endY) through a control point at controlY: below the ends smiles, above frowns. */
|
|
427
|
+
function smile(ctx: Ctx, halfWidth: number, endY: number, controlY: number): void {
|
|
428
|
+
ctx.strokeStyle = INK;
|
|
429
|
+
ctx.lineWidth = LINE;
|
|
430
|
+
// A soft U rather than a V: both handles pulled in from the corners and
|
|
431
|
+
// only half as deep, so the bottom is round and flat-ish and the corners
|
|
432
|
+
// rise gently.
|
|
433
|
+
const handleY = endY + (controlY - endY) * 0.52;
|
|
434
|
+
ctx.beginPath();
|
|
435
|
+
ctx.moveTo(-halfWidth, endY);
|
|
436
|
+
ctx.bezierCurveTo(-halfWidth * 0.5, handleY, halfWidth * 0.5, handleY, halfWidth, endY);
|
|
437
|
+
ctx.stroke();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A D-shaped open mouth with a tongue. */
|
|
441
|
+
function openMouth(ctx: Ctx, halfWidth: number, topY: number, controlY: number): void {
|
|
442
|
+
ctx.save();
|
|
443
|
+
ctx.beginPath();
|
|
444
|
+
ctx.moveTo(-halfWidth, topY);
|
|
445
|
+
ctx.quadraticCurveTo(0, controlY, halfWidth, topY);
|
|
446
|
+
ctx.closePath();
|
|
447
|
+
ctx.fillStyle = INK;
|
|
448
|
+
ctx.fill();
|
|
449
|
+
ctx.clip();
|
|
450
|
+
const bottom = (topY + controlY) / 2;
|
|
451
|
+
ctx.fillStyle = '#ff7b8c';
|
|
452
|
+
ctx.beginPath();
|
|
453
|
+
ctx.ellipse(0, bottom - 0.005, halfWidth * 0.55, 0.045, 0, 0, Math.PI * 2);
|
|
454
|
+
ctx.fill();
|
|
455
|
+
ctx.restore();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A brow over one eye. `inner` and `outer` are heights above the eye top, so they set the slant; `arch` bows it upward. */
|
|
459
|
+
function brow(ctx: Ctx, side: number, inner: number, outer: number, arch = 0): void {
|
|
460
|
+
const x = side * EYE_X;
|
|
461
|
+
const base = EYE_Y + EYE_R + 0.03;
|
|
462
|
+
ctx.strokeStyle = INK;
|
|
463
|
+
ctx.lineWidth = LINE;
|
|
464
|
+
ctx.beginPath();
|
|
465
|
+
ctx.moveTo(x - side * 0.05, base + inner);
|
|
466
|
+
ctx.quadraticCurveTo(x, base + (inner + outer) / 2 + arch * 2, x + side * 0.05, base + outer);
|
|
467
|
+
ctx.stroke();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function heart(ctx: Ctx, x: number, y: number, size: number): void {
|
|
471
|
+
ctx.fillStyle = '#ff4d6d';
|
|
472
|
+
ctx.beginPath();
|
|
473
|
+
ctx.moveTo(x, y - size * 0.75);
|
|
474
|
+
ctx.bezierCurveTo(x - size * 1.1, y - size * 0.05, x - size * 0.7, y + size * 0.85, x, y + size * 0.35);
|
|
475
|
+
ctx.bezierCurveTo(x + size * 0.7, y + size * 0.85, x + size * 1.1, y - size * 0.05, x, y - size * 0.75);
|
|
476
|
+
ctx.fill();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function tear(ctx: Ctx, x: number, y: number): void {
|
|
480
|
+
ctx.fillStyle = '#6cc4ff';
|
|
481
|
+
ctx.beginPath();
|
|
482
|
+
ctx.moveTo(x, y + 0.04);
|
|
483
|
+
ctx.quadraticCurveTo(x + 0.028, y - 0.002, x + 0.02, y - 0.016);
|
|
484
|
+
ctx.arc(x, y - 0.016, 0.02, 0, Math.PI, true);
|
|
485
|
+
ctx.quadraticCurveTo(x - 0.028, y - 0.002, x, y + 0.04);
|
|
486
|
+
ctx.fill();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function line(ctx: Ctx, points: [number, number][]): void {
|
|
490
|
+
ctx.strokeStyle = INK;
|
|
491
|
+
ctx.lineWidth = LINE;
|
|
492
|
+
ctx.beginPath();
|
|
493
|
+
points.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
|
|
494
|
+
ctx.stroke();
|
|
495
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Group, Mesh, Object3D, type Vector3 } from 'three';
|
|
2
|
+
import type { PlayerOptions } from '../types.js';
|
|
3
|
+
import {
|
|
4
|
+
animateDefaultAvatar,
|
|
5
|
+
createDefaultAvatar,
|
|
6
|
+
setAvatarExpression,
|
|
7
|
+
type AvatarExpression,
|
|
8
|
+
type AvatarMotion,
|
|
9
|
+
} from './avatar.js';
|
|
10
|
+
|
|
11
|
+
export interface PlayerAvatarOptions extends PlayerOptions {
|
|
12
|
+
height: number;
|
|
13
|
+
radius: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The visible body. It is a plain Object3D so a world can swap in its own
|
|
18
|
+
* model, and so a future multiplayer layer can instantiate the same avatar
|
|
19
|
+
* for remote peers without touching movement code.
|
|
20
|
+
*/
|
|
21
|
+
export class Player {
|
|
22
|
+
readonly root: Group | null;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
readonly radius: number;
|
|
25
|
+
readonly eyeHeight: number;
|
|
26
|
+
|
|
27
|
+
/** Tracked even for custom avatars, so worlds and peers can react to it. */
|
|
28
|
+
expression: AvatarExpression = 'smile';
|
|
29
|
+
/** Where the body faces. In third person it follows movement, not the camera. */
|
|
30
|
+
facing = 0;
|
|
31
|
+
|
|
32
|
+
private isDefaultAvatar: boolean;
|
|
33
|
+
|
|
34
|
+
constructor(options: PlayerAvatarOptions) {
|
|
35
|
+
this.height = options.height;
|
|
36
|
+
this.radius = options.radius;
|
|
37
|
+
this.eyeHeight = options.eyeHeight ?? options.height - 0.22;
|
|
38
|
+
|
|
39
|
+
if (options.avatar === false) {
|
|
40
|
+
this.root = null;
|
|
41
|
+
this.isDefaultAvatar = false;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.root = new Group();
|
|
46
|
+
this.root.name = 'worldmesh:player';
|
|
47
|
+
this.isDefaultAvatar = !options.avatar;
|
|
48
|
+
|
|
49
|
+
const body = options.avatar ?? createDefaultAvatar(options.height);
|
|
50
|
+
this.root.add(body as Object3D);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Place the avatar at the player's feet, facing `yaw`, and animate it for `motion`. */
|
|
54
|
+
sync(feet: Vector3, yaw: number, currentHeight: number, motion?: AvatarMotion, freeLook = false): void {
|
|
55
|
+
if (!freeLook) this.facing = yaw;
|
|
56
|
+
else if (motion && motion.speed > 0.3 && motion.heading !== undefined) {
|
|
57
|
+
let delta = motion.heading - this.facing;
|
|
58
|
+
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
|
|
59
|
+
this.facing += delta * (1 - Math.exp(-12 * motion.dt));
|
|
60
|
+
}
|
|
61
|
+
if (!this.root) return;
|
|
62
|
+
this.root.position.copy(feet);
|
|
63
|
+
this.root.rotation.y = this.facing;
|
|
64
|
+
if (this.isDefaultAvatar) {
|
|
65
|
+
// Squash the default body while crouching instead of rebuilding it.
|
|
66
|
+
this.root.scale.y = currentHeight / this.height;
|
|
67
|
+
if (motion) animateDefaultAvatar(this.root.children[0], motion);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setExpression(expression: AvatarExpression): void {
|
|
72
|
+
this.expression = expression;
|
|
73
|
+
if (this.root && this.isDefaultAvatar) setAvatarExpression(this.root.children[0], expression);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setVisible(visible: boolean): void {
|
|
77
|
+
if (this.root) this.root.visible = visible;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
dispose(): void {
|
|
81
|
+
this.root?.traverse((child) => {
|
|
82
|
+
if (child instanceof Mesh) {
|
|
83
|
+
child.geometry.dispose();
|
|
84
|
+
const material = child.material;
|
|
85
|
+
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
|
86
|
+
else material.dispose();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
this.root?.removeFromParent();
|
|
90
|
+
}
|
|
91
|
+
}
|