@pmndrs/viverse 0.1.16 → 0.1.18

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.
@@ -5,10 +5,8 @@ import { loadVrmModelMixamoAnimations as loadModelMixamoAnimations } from './mix
5
5
  import { scaleAnimationClipTime, trimAnimationClip } from './utils.js';
6
6
  import { loadVrmModelVrmaAnimations } from './vrma.js';
7
7
  import { cached } from '../utils.js';
8
- const parentWorldVector = new Vector3();
9
8
  const restRotationInverse = new Quaternion();
10
9
  const parentRestWorldRotation = new Quaternion();
11
- const parentRestWorldRotationInverse = new Quaternion();
12
10
  const quaternion = new Quaternion();
13
11
  const vector = new Vector3();
14
12
  const nonVrmRotationOffset = new Quaternion().setFromEuler(new Euler(0, Math.PI, 0));
@@ -18,7 +16,9 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
18
16
  throw new Error('Failed to determine hips bone name for VRM animation. Please check the bone map or animation file.');
19
17
  }
20
18
  const clipSceneHips = clipScene.getObjectByName(hipsBoneName);
21
- const vrmHipsPosition = model instanceof VRM ? model.humanoid.normalizedRestPose.hips?.position : clipSceneHips?.position;
19
+ const vrmHipsPosition = model instanceof VRM
20
+ ? model.humanoid.normalizedRestPose.hips?.position
21
+ : model.scene.getObjectByName('hips')?.position.toArray();
22
22
  if (clipSceneHips == null || vrmHipsPosition == null) {
23
23
  throw new Error('Failed to load VRM animation: missing animation hips object or VRM hips position.');
24
24
  }
@@ -32,14 +32,18 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
32
32
  const vrmBoneName = boneMap?.[boneName] ?? boneName;
33
33
  const vrmNodeName = model instanceof VRM ? model.humanoid.getNormalizedBoneNode(vrmBoneName)?.name : vrmBoneName;
34
34
  const bone = clipScene.getObjectByName(boneName);
35
- if (vrmNodeName == null || bone == null || bone.parent == null) {
35
+ if (vrmNodeName == null || bone == null) {
36
36
  continue;
37
37
  }
38
- bone.getWorldQuaternion(restRotationInverse).invert();
39
- bone.parent.getWorldQuaternion(parentRestWorldRotation);
40
- parentRestWorldRotationInverse.copy(parentRestWorldRotation).invert();
41
- bone.parent.getWorldPosition(parentWorldVector);
42
38
  if (track instanceof QuaternionKeyframeTrack) {
39
+ if (bone.parent != null) {
40
+ bone.getWorldQuaternion(restRotationInverse).invert();
41
+ bone.parent.getWorldQuaternion(parentRestWorldRotation);
42
+ }
43
+ else {
44
+ restRotationInverse.identity();
45
+ parentRestWorldRotation.identity();
46
+ }
43
47
  // Store rotations of rest-pose.
44
48
  for (let i = 0; i < track.values.length; i += 4) {
45
49
  quaternion.fromArray(track.values, i);
package/dist/camera.d.ts CHANGED
@@ -56,7 +56,7 @@ export type SimpleCharacterCameraBehaviorOptions = {
56
56
  } | boolean;
57
57
  } | boolean;
58
58
  export declare class SimpleCharacterCameraBehavior {
59
- camera: Object3D;
59
+ getCamera: () => Object3D;
60
60
  character: SimpleCharacter;
61
61
  private readonly raycast?;
62
62
  rotationPitch: number;
@@ -64,7 +64,7 @@ export declare class SimpleCharacterCameraBehavior {
64
64
  zoomDistance: number;
65
65
  private collisionFreeZoomDistance;
66
66
  private firstUpdate;
67
- constructor(camera: Object3D, character: SimpleCharacter, raycast?: ((ray: Ray, far: number) => number | undefined) | undefined);
67
+ constructor(getCamera: () => Object3D, character: SimpleCharacter, raycast?: ((ray: Ray, far: number) => number | undefined) | undefined);
68
68
  private setRotationFromDelta;
69
69
  private setDistanceFromDelta;
70
70
  private computeCharacterBaseOffset;
package/dist/camera.js CHANGED
@@ -12,7 +12,7 @@ const characterWorldPosition = new Vector3();
12
12
  const euler = new Euler();
13
13
  const rayHelper = new Ray();
14
14
  export class SimpleCharacterCameraBehavior {
15
- camera;
15
+ getCamera;
16
16
  character;
17
17
  raycast;
18
18
  rotationPitch = (-20 * Math.PI) / 180;
@@ -21,15 +21,15 @@ export class SimpleCharacterCameraBehavior {
21
21
  //internal state
22
22
  collisionFreeZoomDistance = this.zoomDistance;
23
23
  firstUpdate = true;
24
- constructor(camera, character, raycast) {
25
- this.camera = camera;
24
+ constructor(getCamera, character, raycast) {
25
+ this.getCamera = getCamera;
26
26
  this.character = character;
27
27
  this.raycast = raycast;
28
28
  }
29
29
  setRotationFromDelta(delta, rotationOptions) {
30
30
  if (delta.lengthSq() < 0.0001) {
31
31
  // use current camera rotation if very close to target
32
- euler.setFromQuaternion(this.camera.quaternion, 'YXZ');
32
+ euler.setFromQuaternion(this.getCamera().quaternion, 'YXZ');
33
33
  this.rotationPitch = euler.x;
34
34
  this.rotationYaw = euler.y;
35
35
  return;
@@ -76,7 +76,7 @@ export class SimpleCharacterCameraBehavior {
76
76
  this.computeCharacterBaseOffset(chracterBaseOffsetHelper, options.characterBaseOffset);
77
77
  this.character.getWorldPosition(characterWorldPosition);
78
78
  characterWorldPosition.add(chracterBaseOffsetHelper);
79
- this.camera.getWorldPosition(deltaHelper);
79
+ this.getCamera().getWorldPosition(deltaHelper);
80
80
  deltaHelper.sub(characterWorldPosition);
81
81
  // apply rotation input to rotationYaw and rotationPitch if not disabled or first update
82
82
  let rotationOptions = options.rotation ?? true;
@@ -92,8 +92,8 @@ export class SimpleCharacterCameraBehavior {
92
92
  this.setRotationFromDelta(deltaHelper, typeof rotationOptions === 'boolean' ? {} : rotationOptions);
93
93
  }
94
94
  // apply yaw and pitch to camera rotation
95
- this.camera.rotation.set(this.rotationPitch, this.rotationYaw, 0, 'YXZ');
96
- rayHelper.direction.set(0, 0, 1).applyEuler(this.camera.rotation);
95
+ this.getCamera().rotation.set(this.rotationPitch, this.rotationYaw, 0, 'YXZ');
96
+ rayHelper.direction.set(0, 0, 1).applyEuler(this.getCamera().rotation);
97
97
  rayHelper.origin.copy(characterWorldPosition);
98
98
  // apply zoom input to zoomDistance if not disabled or first update
99
99
  let zoomOptions = options.zoom ?? true;
@@ -126,12 +126,12 @@ export class SimpleCharacterCameraBehavior {
126
126
  }
127
127
  // Calculate camera position using spherical coordinates from euler
128
128
  sphericalOffset.set(0, 0, this.collisionFreeZoomDistance);
129
- sphericalOffset.applyEuler(this.camera.rotation);
129
+ sphericalOffset.applyEuler(this.getCamera().rotation);
130
130
  // Get target position with offset (reuse helper vector)
131
131
  this.character.getWorldPosition(characterWorldPosition);
132
132
  this.computeCharacterBaseOffset(chracterBaseOffsetHelper, options.characterBaseOffset);
133
133
  characterWorldPosition.add(chracterBaseOffsetHelper);
134
134
  // Set camera position relative to target
135
- this.camera.position.copy(characterWorldPosition).add(sphericalOffset);
135
+ this.getCamera().position.copy(characterWorldPosition).add(sphericalOffset);
136
136
  }
137
137
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { extractProxy, getIsMobileMediaQuery, isMobile } from './utils.js';
1
2
  export * from './input/index.js';
2
3
  export * from './camera.js';
3
4
  export * from './physics/index.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { extractProxy, getIsMobileMediaQuery, isMobile } from './utils.js';
1
2
  export * from './input/index.js';
2
3
  export * from './camera.js';
3
4
  export * from './physics/index.js';
@@ -5,3 +6,16 @@ export * from './animation/index.js';
5
6
  export * from './material.js';
6
7
  export * from './simple-character.js';
7
8
  export * from './model/index.js';
9
+ (function injectMobileClassStyle() {
10
+ if (typeof document === 'undefined') {
11
+ return;
12
+ }
13
+ const STYLE_ID = 'viverse-mobile-class-style';
14
+ if (document.getElementById(STYLE_ID)) {
15
+ return;
16
+ }
17
+ const style = document.createElement('style');
18
+ style.id = STYLE_ID;
19
+ style.textContent = `.mobile-only{display:none;}@media (hover: none) and (pointer: coarse){.mobile-only{display:unset;}}`;
20
+ document.head.appendChild(style);
21
+ })();
@@ -1,16 +1,16 @@
1
1
  export declare class InputSystem {
2
- enabled: boolean;
3
2
  private readonly inputs;
4
3
  constructor(domElement: HTMLElement, inputs: ReadonlyArray<Input | {
5
- new (element: HTMLElement): Input;
6
- }>);
4
+ new (element: HTMLElement, options?: {}): Input;
5
+ }>, options?: {});
7
6
  add(input: Input): void;
8
7
  remove(input: Input): void;
9
- destroy(): void;
8
+ dispose(): void;
10
9
  get<T>(field: InputField<T>): T;
11
10
  }
12
11
  export type InputField<T> = {
13
12
  default: T;
13
+ combine: (v1: any, v2: any) => T;
14
14
  };
15
15
  export declare const MoveForwardField: InputField<number>;
16
16
  export declare const MoveBackwardField: InputField<number>;
@@ -23,8 +23,10 @@ export declare const DeltaYawField: InputField<number>;
23
23
  export declare const DeltaPitchField: InputField<number>;
24
24
  export interface Input {
25
25
  get<T>(field: InputField<T>): T | undefined;
26
- destroy?(): void;
26
+ dispose?(): void;
27
27
  }
28
28
  export * from './pointer-lock.js';
29
29
  export * from './pointer-capture.js';
30
30
  export * from './keyboard.js';
31
+ export * from './screen-joystick.js';
32
+ export * from './screen-jump-button.js';
@@ -1,8 +1,7 @@
1
1
  export class InputSystem {
2
- enabled = true;
3
2
  inputs;
4
- constructor(domElement, inputs) {
5
- this.inputs = inputs.map((input) => (typeof input === 'function' ? new input(domElement) : input));
3
+ constructor(domElement, inputs, options) {
4
+ this.inputs = inputs.map((input) => (typeof input === 'function' ? new input(domElement, options) : input));
6
5
  }
7
6
  add(input) {
8
7
  this.inputs.push(input);
@@ -14,51 +13,64 @@ export class InputSystem {
14
13
  }
15
14
  this.inputs.splice(index, 1);
16
15
  }
17
- destroy() {
18
- this.inputs.forEach((input) => input.destroy?.());
16
+ dispose() {
17
+ this.inputs.forEach((input) => input.dispose?.());
19
18
  this.inputs.length = 0;
20
19
  }
21
20
  get(field) {
22
- if (!this.enabled) {
23
- return field.default;
24
- }
21
+ let current;
25
22
  for (const input of this.inputs) {
26
23
  const result = input.get(field);
27
- if (result === undefined) {
24
+ if (result == null) {
25
+ continue;
26
+ }
27
+ if (current == undefined) {
28
+ current = result;
28
29
  continue;
29
30
  }
30
- return result;
31
+ current = field.combine(current, result);
31
32
  }
32
- return field.default;
33
+ return current ?? field.default;
33
34
  }
34
35
  }
35
36
  export const MoveForwardField = {
36
37
  default: 0,
38
+ combine: Math.max,
37
39
  };
38
40
  export const MoveBackwardField = {
39
41
  default: 0,
42
+ combine: Math.max,
40
43
  };
41
44
  export const MoveLeftField = {
42
45
  default: 0,
46
+ combine: Math.max,
43
47
  };
44
48
  export const MoveRightField = {
45
49
  default: 0,
50
+ combine: Math.max,
46
51
  };
47
52
  export const LastTimeJumpPressedField = {
48
53
  default: null,
54
+ combine: (v1, v2) => (v1 == null && v2 == null ? null : Math.min(v1 ?? Infinity, v2 ?? Infinity)),
49
55
  };
50
56
  export const RunField = {
51
57
  default: false,
58
+ combine: (v1, v2) => v1 || v2,
52
59
  };
53
60
  export const DeltaZoomField = {
54
61
  default: 0,
62
+ combine: Math.max,
55
63
  };
56
64
  export const DeltaYawField = {
57
65
  default: 0,
66
+ combine: Math.max,
58
67
  };
59
68
  export const DeltaPitchField = {
60
69
  default: 0,
70
+ combine: Math.max,
61
71
  };
62
72
  export * from './pointer-lock.js';
63
73
  export * from './pointer-capture.js';
64
74
  export * from './keyboard.js';
75
+ export * from './screen-joystick.js';
76
+ export * from './screen-jump-button.js';
@@ -1,8 +1,17 @@
1
1
  import { Input, InputField } from './index.js';
2
+ export type LocomotionKeyboardInputOptions = {
3
+ keyboardMoveForwardKeys?: Array<string>;
4
+ keyboardMoveBackwardKeys?: Array<string>;
5
+ keyboardMoveLeftKeys?: Array<string>;
6
+ keyboardMoveRightKeys?: Array<string>;
7
+ keyboardRunKeys?: Array<string>;
8
+ keyboardJumpKeys?: Array<string>;
9
+ };
2
10
  export declare class LocomotionKeyboardInput implements Input {
11
+ private readonly options;
3
12
  private readonly abortController;
4
13
  private readonly keyState;
5
- constructor(domElement: HTMLElement);
14
+ constructor(domElement: HTMLElement, options?: LocomotionKeyboardInputOptions);
6
15
  get<T>(field: InputField<T>): T | undefined;
7
- destroy(): void;
16
+ dispose(): void;
8
17
  }
@@ -1,13 +1,16 @@
1
1
  import { MoveForwardField, MoveBackwardField, MoveLeftField, MoveRightField, LastTimeJumpPressedField, RunField, } from './index.js';
2
- const MoveForwardKeys = ['KeyW'];
3
- const MoveBackwardKeys = ['KeyS'];
4
- const MoveLeftKeys = ['KeyA'];
5
- const MoveRightKeys = ['KeyD'];
6
- const RunKeys = ['ShiftRight', 'ShiftLeft'];
2
+ const DefaultMoveForwardKeys = ['KeyW'];
3
+ const DefaultMoveBackwardKeys = ['KeyS'];
4
+ const DefaultMoveLeftKeys = ['KeyA'];
5
+ const DefaultMoveRightKeys = ['KeyD'];
6
+ const DefaultRunKeys = ['ShiftRight', 'ShiftLeft'];
7
+ const DefaultJumpKeys = ['Space'];
7
8
  export class LocomotionKeyboardInput {
9
+ options;
8
10
  abortController = new AbortController();
9
11
  keyState = new Map();
10
- constructor(domElement) {
12
+ constructor(domElement, options = {}) {
13
+ this.options = options;
11
14
  domElement.tabIndex = 0;
12
15
  domElement.addEventListener('keydown', (event) => {
13
16
  let state = this.keyState.get(event.code);
@@ -40,24 +43,28 @@ export class LocomotionKeyboardInput {
40
43
  }
41
44
  get(field) {
42
45
  if (field === LastTimeJumpPressedField) {
43
- return (this.keyState.get('Space')?.pressTime ?? null);
46
+ const jumpKeys = this.options.keyboardJumpKeys ?? DefaultJumpKeys;
47
+ const pressed = jumpKeys
48
+ .map((key) => this.keyState.get(key)?.pressTime ?? null)
49
+ .filter((t) => t != null);
50
+ return (pressed.length > 0 ? Math.max(...pressed) : null);
44
51
  }
45
52
  let keys;
46
53
  switch (field) {
47
54
  case MoveForwardField:
48
- keys = MoveForwardKeys;
55
+ keys = this.options.keyboardMoveForwardKeys ?? DefaultMoveForwardKeys;
49
56
  break;
50
57
  case MoveBackwardField:
51
- keys = MoveBackwardKeys;
58
+ keys = this.options.keyboardMoveBackwardKeys ?? DefaultMoveBackwardKeys;
52
59
  break;
53
60
  case MoveLeftField:
54
- keys = MoveLeftKeys;
61
+ keys = this.options.keyboardMoveLeftKeys ?? DefaultMoveLeftKeys;
55
62
  break;
56
63
  case MoveRightField:
57
- keys = MoveRightKeys;
64
+ keys = this.options.keyboardMoveRightKeys ?? DefaultMoveRightKeys;
58
65
  break;
59
66
  case RunField:
60
- keys = RunKeys;
67
+ keys = this.options.keyboardRunKeys ?? DefaultRunKeys;
61
68
  break;
62
69
  }
63
70
  if (keys == null) {
@@ -71,7 +78,7 @@ export class LocomotionKeyboardInput {
71
78
  return state.releaseTime == null || state.pressTime > state.releaseTime;
72
79
  });
73
80
  }
74
- destroy() {
81
+ dispose() {
75
82
  this.abortController.abort();
76
83
  }
77
84
  }
@@ -1,4 +1,8 @@
1
1
  import { Input, InputField } from './index.js';
2
+ export type PointerCaptureInputOptions = {
3
+ pointerCaptureRotationSpeed?: number;
4
+ pointerCaptureZoomSpeed?: number;
5
+ };
2
6
  /**
3
7
  * @requires to manually execute `domElement.setPointerCapture(pointerId)` on pointerdown
4
8
  */
@@ -8,7 +12,9 @@ export declare class PointerCaptureInput implements Input {
8
12
  private deltaZoom;
9
13
  private deltaYaw;
10
14
  private deltaPitch;
11
- constructor(domElement: HTMLElement);
15
+ private activePointers;
16
+ private lastPinchDist;
17
+ constructor(domElement: HTMLElement, options?: PointerCaptureInputOptions);
12
18
  get<T>(field: InputField<T>): T | undefined;
13
- destroy(): void;
19
+ dispose(): void;
14
20
  }
@@ -8,29 +8,64 @@ export class PointerCaptureInput {
8
8
  deltaZoom = 0;
9
9
  deltaYaw = 0;
10
10
  deltaPitch = 0;
11
- constructor(domElement) {
11
+ activePointers = new Map();
12
+ lastPinchDist = null;
13
+ constructor(domElement, options = {}) {
12
14
  this.domElement = domElement;
13
- domElement.addEventListener('pointerdown', (event) => this.domElement.setPointerCapture(event.pointerId), {
15
+ domElement.addEventListener('pointerdown', (event) => {
16
+ this.domElement.setPointerCapture(event.pointerId);
17
+ this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
18
+ if (this.activePointers.size === 2) {
19
+ const pts = Array.from(this.activePointers.values());
20
+ this.lastPinchDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
21
+ }
22
+ }, {
14
23
  signal: this.abortController.signal,
15
24
  });
16
25
  domElement.addEventListener('pointermove', (event) => {
17
26
  if (!this.domElement.hasPointerCapture(event.pointerId)) {
18
27
  return;
19
28
  }
20
- this.deltaYaw -= (0.4 * event.movementX) / window.innerHeight;
21
- this.deltaPitch -= (0.4 * event.movementY) / window.innerHeight;
29
+ this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
30
+ if (this.activePointers.size === 2) {
31
+ const pts = Array.from(this.activePointers.values());
32
+ if (this.lastPinchDist != null) {
33
+ const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
34
+ const zoomSpeed = options.pointerCaptureZoomSpeed ?? 0.0001;
35
+ this.deltaZoom += (this.lastPinchDist - d) * zoomSpeed;
36
+ this.lastPinchDist = d;
37
+ }
38
+ event.preventDefault();
39
+ return;
40
+ }
41
+ const rotationSpeed = options.pointerCaptureRotationSpeed ?? 0.4;
42
+ this.deltaYaw -= (rotationSpeed * event.movementX) / window.innerHeight;
43
+ this.deltaPitch -= (rotationSpeed * event.movementY) / window.innerHeight;
22
44
  }, {
23
45
  signal: this.abortController.signal,
24
46
  });
25
- domElement.addEventListener('pointerup', (event) => this.domElement.releasePointerCapture(event.pointerId), {
47
+ domElement.addEventListener('pointerup', (event) => {
48
+ this.domElement.releasePointerCapture(event.pointerId);
49
+ this.activePointers.delete(event.pointerId);
50
+ if (this.activePointers.size < 2) {
51
+ this.lastPinchDist = null;
52
+ }
53
+ }, {
26
54
  signal: this.abortController.signal,
27
55
  });
28
- domElement.addEventListener('pointercancel', (event) => this.domElement.releasePointerCapture(event.pointerId), {
56
+ domElement.addEventListener('pointercancel', (event) => {
57
+ this.domElement.releasePointerCapture(event.pointerId);
58
+ this.activePointers.delete(event.pointerId);
59
+ if (this.activePointers.size < 2) {
60
+ this.lastPinchDist = null;
61
+ }
62
+ }, {
29
63
  signal: this.abortController.signal,
30
64
  });
31
65
  domElement.addEventListener('wheel', (event) => {
32
66
  event.preventDefault();
33
- this.deltaZoom += event.deltaY * 0.0001;
67
+ const zoomSpeed = options.pointerCaptureZoomSpeed ?? 0.0001;
68
+ this.deltaZoom += event.deltaY * zoomSpeed;
34
69
  }, {
35
70
  signal: this.abortController.signal,
36
71
  });
@@ -53,7 +88,7 @@ export class PointerCaptureInput {
53
88
  }
54
89
  return result;
55
90
  }
56
- destroy() {
91
+ dispose() {
57
92
  this.abortController.abort();
58
93
  }
59
94
  }
@@ -1,4 +1,8 @@
1
1
  import { Input, InputField } from './index.js';
2
+ export type PointerLockInputOptions = {
3
+ pointerLockRotationSpeed?: number;
4
+ pointerLockZoomSpeed?: number;
5
+ };
2
6
  /**
3
7
  * @requires to manually execute `domElement.requestPointerLock()`
4
8
  */
@@ -7,7 +11,7 @@ export declare class PointerLockInput implements Input {
7
11
  private deltaZoom;
8
12
  private deltaYaw;
9
13
  private deltaPitch;
10
- constructor(domElement: HTMLElement);
14
+ constructor(domElement: HTMLElement, options?: PointerLockInputOptions);
11
15
  get<T>(field: InputField<T>): T | undefined;
12
- destroy(): void;
16
+ dispose(): void;
13
17
  }
@@ -7,15 +7,16 @@ export class PointerLockInput {
7
7
  deltaZoom = 0;
8
8
  deltaYaw = 0;
9
9
  deltaPitch = 0;
10
- constructor(domElement) {
10
+ constructor(domElement, options = {}) {
11
11
  domElement.addEventListener('pointermove', (event) => {
12
12
  if (document.pointerLockElement != domElement) {
13
13
  return;
14
14
  }
15
+ const rotationSpeed = options.pointerLockRotationSpeed ?? 0.4;
15
16
  // Compute based on domElement bounds instead of window.innerHeight
16
17
  const rect = domElement.getBoundingClientRect();
17
- this.deltaYaw -= (0.4 * event.movementX) / rect.height;
18
- this.deltaPitch -= (0.4 * event.movementY) / rect.height;
18
+ this.deltaYaw -= (rotationSpeed * event.movementX) / rect.height;
19
+ this.deltaPitch -= (rotationSpeed * event.movementY) / rect.height;
19
20
  }, {
20
21
  signal: this.abortController.signal,
21
22
  });
@@ -23,7 +24,8 @@ export class PointerLockInput {
23
24
  if (document.pointerLockElement != domElement) {
24
25
  return;
25
26
  }
26
- this.deltaZoom += event.deltaY * 0.0001;
27
+ const zoomSpeed = options.pointerLockZoomSpeed ?? 0.0001;
28
+ this.deltaZoom += event.deltaY * zoomSpeed;
27
29
  event.preventDefault();
28
30
  }, {
29
31
  signal: this.abortController.signal,
@@ -47,7 +49,7 @@ export class PointerLockInput {
47
49
  }
48
50
  return result;
49
51
  }
50
- destroy() {
52
+ dispose() {
51
53
  this.abortController.abort();
52
54
  }
53
55
  }
@@ -0,0 +1,22 @@
1
+ import { Input, InputField } from './index.js';
2
+ export type ScreenJoystickInputOptions = {
3
+ screenJoystickRunDistancePx?: number;
4
+ screenJoystickDeadZonePx?: number;
5
+ };
6
+ export declare class ScreenJoystickInput implements Input {
7
+ private readonly options;
8
+ readonly root: HTMLDivElement;
9
+ private readonly handle;
10
+ private moveX;
11
+ private moveY;
12
+ private running;
13
+ private readonly joystickRadius;
14
+ private joyCenterX;
15
+ private joyCenterY;
16
+ private pointerId;
17
+ constructor(domElement: HTMLElement, options?: ScreenJoystickInputOptions);
18
+ get<T>(field: InputField<T>): T | undefined;
19
+ dispose(): void;
20
+ private updateHandle;
21
+ private resetHandle;
22
+ }
@@ -0,0 +1,127 @@
1
+ import { MoveForwardField, MoveBackwardField, MoveLeftField, MoveRightField, RunField, } from './index.js';
2
+ const DefaultDeadZonePx = 24;
3
+ const DefaultRunDistancePx = 46;
4
+ export class ScreenJoystickInput {
5
+ options;
6
+ root;
7
+ handle;
8
+ moveX = 0;
9
+ moveY = 0;
10
+ running = false;
11
+ joystickRadius = 56;
12
+ joyCenterX = 0;
13
+ joyCenterY = 0;
14
+ pointerId;
15
+ constructor(domElement, options = {}) {
16
+ this.options = options;
17
+ const parent = domElement.parentElement ?? domElement;
18
+ const joy = document.createElement('div');
19
+ joy.className = 'viverse-joystick mobile-only';
20
+ parent.appendChild(joy);
21
+ this.root = joy;
22
+ this.root.style.position = 'absolute';
23
+ this.root.style.bottom = '24px';
24
+ this.root.style.left = '24px';
25
+ this.root.style.width = '112px';
26
+ this.root.style.height = '112px';
27
+ this.root.style.borderRadius = '9999px';
28
+ this.root.style.background = 'rgba(255,255,255,0.2)';
29
+ this.root.style.pointerEvents = 'auto';
30
+ this.root.style.touchAction = 'none';
31
+ this.root.style.userSelect = 'none';
32
+ this.root.style.setProperty('-webkit-user-select', 'none');
33
+ this.root.style.setProperty('-webkit-touch-callout', 'none');
34
+ const handle = document.createElement('div');
35
+ handle.className = 'viverse-joystick-handle';
36
+ joy.appendChild(handle);
37
+ this.handle = handle;
38
+ this.handle.style.position = 'absolute';
39
+ this.handle.style.left = '50%';
40
+ this.handle.style.top = '50%';
41
+ this.handle.style.width = '56px';
42
+ this.handle.style.height = '56px';
43
+ this.handle.style.borderRadius = '9999px';
44
+ this.handle.style.background = 'rgba(0,0,0,0.3)';
45
+ this.handle.style.transform = 'translate(-50%,-50%)';
46
+ this.handle.style.willChange = 'transform';
47
+ this.handle.style.pointerEvents = 'none';
48
+ this.handle.style.touchAction = 'none';
49
+ this.handle.style.userSelect = 'none';
50
+ this.handle.style.setProperty('-webkit-user-select', 'none');
51
+ this.handle.style.setProperty('-webkit-touch-callout', 'none');
52
+ const onPointerDown = (e) => {
53
+ if (this.pointerId != null) {
54
+ return;
55
+ }
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+ joy.setPointerCapture(e.pointerId);
59
+ this.pointerId = e.pointerId;
60
+ const rect = joy.getBoundingClientRect();
61
+ this.joyCenterX = rect.left + rect.width / 2;
62
+ this.joyCenterY = rect.top + rect.height / 2;
63
+ this.updateHandle(e.clientX - this.joyCenterX, e.clientY - this.joyCenterY);
64
+ };
65
+ const onPointerMove = (e) => {
66
+ if (this.pointerId == null) {
67
+ return;
68
+ }
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ this.updateHandle(e.clientX - this.joyCenterX, e.clientY - this.joyCenterY);
72
+ };
73
+ const onPointerEnd = (e) => {
74
+ if (this.pointerId != e.pointerId) {
75
+ return;
76
+ }
77
+ this.pointerId = undefined;
78
+ joy.releasePointerCapture(e.pointerId);
79
+ e.preventDefault();
80
+ this.resetHandle();
81
+ };
82
+ joy.addEventListener('pointerdown', onPointerDown);
83
+ joy.addEventListener('pointermove', onPointerMove);
84
+ joy.addEventListener('pointerup', onPointerEnd);
85
+ joy.addEventListener('pointercancel', onPointerEnd);
86
+ }
87
+ get(field) {
88
+ switch (field) {
89
+ case MoveForwardField:
90
+ return Math.max(0, this.moveY);
91
+ case MoveBackwardField:
92
+ return Math.max(0, -this.moveY);
93
+ case MoveLeftField:
94
+ return Math.max(0, -this.moveX);
95
+ case MoveRightField:
96
+ return Math.max(0, this.moveX);
97
+ case RunField:
98
+ return this.running;
99
+ }
100
+ return undefined;
101
+ }
102
+ dispose() {
103
+ this.root.remove();
104
+ }
105
+ updateHandle(dx, dy) {
106
+ const len = Math.hypot(dx, dy) || 1;
107
+ const max = this.joystickRadius;
108
+ const clampedX = (dx / len) * Math.min(len, max);
109
+ const clampedY = (dy / len) * Math.min(len, max);
110
+ this.handle.style.transform = `translate(-50%,-50%) translate(${clampedX}px, ${clampedY}px)`;
111
+ if (len <= (this.options.screenJoystickDeadZonePx ?? DefaultDeadZonePx)) {
112
+ this.moveX = 0;
113
+ this.moveY = 0;
114
+ }
115
+ else {
116
+ this.moveX = clampedX / max;
117
+ this.moveY = -clampedY / max;
118
+ }
119
+ this.running = len > (this.options.screenJoystickRunDistancePx ?? DefaultRunDistancePx);
120
+ }
121
+ resetHandle() {
122
+ this.handle.style.transform = 'translate(-50%,-50%)';
123
+ this.moveX = 0;
124
+ this.moveY = 0;
125
+ this.running = false;
126
+ }
127
+ }
@@ -0,0 +1,8 @@
1
+ import { Input, InputField } from './index.js';
2
+ export declare class ScreenJumpButtonInput implements Input {
3
+ readonly root: HTMLDivElement;
4
+ private lastJumpTime;
5
+ constructor(domElement: HTMLElement);
6
+ get<T>(field: InputField<T>): T | undefined;
7
+ dispose(): void;
8
+ }
@@ -0,0 +1,49 @@
1
+ import { LastTimeJumpPressedField } from './index.js';
2
+ export class ScreenJumpButtonInput {
3
+ root;
4
+ lastJumpTime = null;
5
+ constructor(domElement) {
6
+ const parent = domElement.parentElement ?? domElement;
7
+ const btn = document.createElement('div');
8
+ btn.className = 'viverse-button viverse-jump mobile-only';
9
+ parent.appendChild(btn);
10
+ this.root = btn;
11
+ this.root.style.position = 'absolute';
12
+ this.root.style.bottom = '32px';
13
+ this.root.style.right = '126px';
14
+ this.root.style.minWidth = '64px';
15
+ this.root.style.height = '64px';
16
+ this.root.style.borderRadius = '9999px';
17
+ this.root.style.pointerEvents = 'auto';
18
+ this.root.style.touchAction = 'none';
19
+ this.root.style.userSelect = 'none';
20
+ this.root.style.setProperty('-webkit-user-select', 'none');
21
+ this.root.style.background = 'rgba(255,255,255,0.3)';
22
+ this.root.style.backgroundImage =
23
+ 'url("data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2024%2024%22%20fill=%22none%22%20stroke=%22%23444%22%20stroke-width=%222%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%3E%3Cpolyline%20points=%2218%2015%2012%209%206%2015%22/%3E%3C/svg%3E")';
24
+ this.root.style.backgroundRepeat = 'no-repeat';
25
+ this.root.style.backgroundPosition = 'center';
26
+ this.root.style.backgroundSize = '50%';
27
+ const onPress = (e) => {
28
+ e.preventDefault();
29
+ e.stopPropagation();
30
+ this.lastJumpTime = performance.now() / 1000;
31
+ };
32
+ const stopPropagation = (e) => {
33
+ e.stopPropagation();
34
+ e.preventDefault();
35
+ };
36
+ this.root.addEventListener('pointerdown', onPress);
37
+ this.root.addEventListener('pointermove', stopPropagation);
38
+ this.root.addEventListener('pointerup', stopPropagation);
39
+ }
40
+ get(field) {
41
+ if (field === LastTimeJumpPressedField) {
42
+ return this.lastJumpTime;
43
+ }
44
+ return undefined;
45
+ }
46
+ dispose() {
47
+ this.root.remove();
48
+ }
49
+ }
@@ -34,7 +34,7 @@ export type BvhCharacterPhysicsOptions = {
34
34
  export declare class BvhCharacterPhysics {
35
35
  private readonly character;
36
36
  private readonly world;
37
- private destroyed;
37
+ private disposed;
38
38
  private readonly stateVelocity;
39
39
  readonly inputVelocity: Vector3;
40
40
  private notGroundedSeconds;
@@ -49,7 +49,7 @@ export declare class BvhCharacterPhysics {
49
49
  */
50
50
  update(fullDelta: number, options?: BvhCharacterPhysicsOptions): void;
51
51
  private updateBoundingShapes;
52
- destroy(): void;
52
+ dispose(): void;
53
53
  shapecastCapsule(position: Vector3, maxGroundSlope: number, options: Exclude<BvhCharacterPhysicsOptions, boolean>): boolean;
54
54
  }
55
55
  export * from './world.js';
@@ -13,7 +13,7 @@ const YAxis = new Vector3(0, 1, 0);
13
13
  export class BvhCharacterPhysics {
14
14
  character;
15
15
  world;
16
- destroyed = false;
16
+ disposed = false;
17
17
  stateVelocity = new Vector3();
18
18
  inputVelocity = new Vector3();
19
19
  notGroundedSeconds = 0;
@@ -40,7 +40,7 @@ export class BvhCharacterPhysics {
40
40
  if (options === true) {
41
41
  options = {};
42
42
  }
43
- if (this.destroyed) {
43
+ if (this.disposed) {
44
44
  return;
45
45
  }
46
46
  //at max catch up to 1 second of physics in one update call (running at less then 1fps is unplayable anyways)
@@ -101,8 +101,8 @@ export class BvhCharacterPhysics {
101
101
  this.aabbox.min.addScalar(-this.radius);
102
102
  this.aabbox.max.addScalar(this.radius);
103
103
  }
104
- destroy() {
105
- this.destroyed = true;
104
+ dispose() {
105
+ this.disposed = true;
106
106
  }
107
107
  shapecastCapsule(position, maxGroundSlope, options) {
108
108
  this.updateBoundingShapes(options);
@@ -47,7 +47,7 @@ export class BvhPhysicsWorld {
47
47
  throw new Error(`cannot add InstancedMesh with children`);
48
48
  }
49
49
  const bvh = computeBoundsTree.apply(object.geometry);
50
- return new Array(object.instanceMatrix).fill(undefined).map((_, i) => ({
50
+ return new Array(object.instanceMatrix.count).fill(undefined).map((_, i) => ({
51
51
  object,
52
52
  bvh,
53
53
  instanceIndex: i,
@@ -2,7 +2,7 @@ import { VRM } from '@pixiv/three-vrm';
2
2
  import { AnimationAction, AnimationClip, AnimationMixer, Group, Object3D, Object3DEventMap, Quaternion } from 'three';
3
3
  import { simpleCharacterAnimationNames, ModelAnimationOptions } from './animation/index.js';
4
4
  import { SimpleCharacterCameraBehavior, SimpleCharacterCameraBehaviorOptions } from './camera.js';
5
- import { Input, InputSystem } from './input/index.js';
5
+ import { Input, InputSystem, ScreenJoystickInputOptions, LocomotionKeyboardInputOptions, PointerCaptureInputOptions, PointerLockInputOptions } from './input/index.js';
6
6
  import { CharacterModelOptions, loadCharacterModel } from './model/index.js';
7
7
  import { BvhCharacterPhysicsOptions, BvhCharacterPhysics, BvhPhysicsWorld } from './physics/index.js';
8
8
  export type SimpleCharacterMovementOptions = {
@@ -57,13 +57,12 @@ export type SimpleCharacterAnimationOptions = {
57
57
  */
58
58
  crossFadeDuration?: number;
59
59
  };
60
+ export type SimpleCharacterInputOptions = ScreenJoystickInputOptions & PointerCaptureInputOptions & PointerLockInputOptions & LocomotionKeyboardInputOptions;
60
61
  export type SimpleCharacterOptions = {
61
- /**
62
- * @default [LocomotionKeyboardInput,PointerCaptureInput]
63
- */
64
62
  readonly input?: ReadonlyArray<Input | {
65
63
  new (domElement: HTMLElement): Input;
66
64
  }> | InputSystem;
65
+ inputOptions?: SimpleCharacterInputOptions;
67
66
  movement?: SimpleCharacterMovementOptions;
68
67
  readonly model?: CharacterModelOptions;
69
68
  physics?: BvhCharacterPhysicsOptions;
@@ -90,6 +89,7 @@ export declare function preloadSimpleCharacterAssets(options: Pick<SimpleCharact
90
89
  export declare class SimpleCharacter extends Group<Object3DEventMap & {
91
90
  loaded: {};
92
91
  }> {
92
+ private readonly camera;
93
93
  readonly options: SimpleCharacterOptions;
94
94
  readonly cameraBehavior: SimpleCharacterCameraBehavior;
95
95
  readonly physics: BvhCharacterPhysics;
@@ -98,7 +98,8 @@ export declare class SimpleCharacter extends Group<Object3DEventMap & {
98
98
  actions?: Record<(typeof simpleCharacterAnimationNames)[number], AnimationAction> | undefined;
99
99
  model?: Awaited<Exclude<ReturnType<typeof loadCharacterModel>, undefined>>;
100
100
  private updateTimeline?;
101
- constructor(camera: Object3D, world: BvhPhysicsWorld, domElement: HTMLElement, options?: SimpleCharacterOptions);
101
+ constructor(camera: Object3D | (() => Object3D), world: BvhPhysicsWorld, domElement: HTMLElement, options?: SimpleCharacterOptions);
102
+ getCamera(): Object3D<Object3DEventMap>;
102
103
  private init;
103
104
  update(delta: number): void;
104
105
  dispose(): void;
@@ -3,9 +3,10 @@ import { action, animationFinished, start, timePassed, forever, parallel, graph,
3
3
  import { AnimationMixer, Euler, Group, LoopOnce, Quaternion, Vector3, } from 'three';
4
4
  import { simpleCharacterAnimationNames, getSimpleCharacterModelAnimationOptions as getSimpleCharacterModelAnimationOptions, loadCharacterModelAnimation as loadCharacterModelAnimation, } from './animation/index.js';
5
5
  import { SimpleCharacterCameraBehavior } from './camera.js';
6
- import { InputSystem, LocomotionKeyboardInput, MoveBackwardField, MoveForwardField, MoveLeftField, MoveRightField, PointerCaptureInput, RunField, LastTimeJumpPressedField, } from './input/index.js';
6
+ import { InputSystem, LocomotionKeyboardInput, MoveBackwardField, MoveForwardField, MoveLeftField, MoveRightField, PointerCaptureInput, RunField, LastTimeJumpPressedField, ScreenJoystickInput, ScreenJumpButtonInput, } from './input/index.js';
7
7
  import { clearCharacterModelCache, loadCharacterModel } from './model/index.js';
8
8
  import { BvhCharacterPhysics } from './physics/index.js';
9
+ import { extractProxy } from './utils.js';
9
10
  const DefaultCrossFadeDuration = 0.1;
10
11
  const DefaultJumDelay = 0.2;
11
12
  //constants
@@ -35,7 +36,7 @@ export async function preloadSimpleCharacterAssets(options) {
35
36
  }, {}),
36
37
  };
37
38
  }
38
- async function* SimpleCharacterTimeline(camera, character) {
39
+ async function* SimpleCharacterTimeline(character) {
39
40
  let lastJump = 0;
40
41
  function shouldJump() {
41
42
  let jumpOptions = character.options.movement?.jump;
@@ -55,6 +56,10 @@ async function* SimpleCharacterTimeline(camera, character) {
55
56
  if (lastJump > lastTimePressed) {
56
57
  return false;
57
58
  }
59
+ //last jump must be more then 0.3 second ago, if not, we dont jump, this is to give the character time to get off the ground
60
+ if (lastJump > performance.now() / 1000 - 0.3) {
61
+ return false;
62
+ }
58
63
  return performance.now() / 1000 - lastTimePressed < (jumpOptions?.bufferTime ?? 0.1);
59
64
  }
60
65
  function applyJumpForce() {
@@ -69,7 +74,7 @@ async function* SimpleCharacterTimeline(camera, character) {
69
74
  // character movement
70
75
  action({
71
76
  update() {
72
- cameraEuler.setFromQuaternion(camera.getWorldQuaternion(cameraRotation), 'YXZ');
77
+ cameraEuler.setFromQuaternion(character.getCamera().getWorldQuaternion(cameraRotation), 'YXZ');
73
78
  cameraEuler.x = 0;
74
79
  cameraEuler.z = 0;
75
80
  let inputSpeed = 0;
@@ -100,7 +105,7 @@ async function* SimpleCharacterTimeline(camera, character) {
100
105
  const basedOn = character.options.animation?.yawRotationBasdOn ?? 'movement';
101
106
  // compute goalTargetEuler
102
107
  if (basedOn === 'camera') {
103
- goalTargetEuler.setFromQuaternion(camera.getWorldQuaternion(quaternion), 'YXZ');
108
+ goalTargetEuler.setFromQuaternion(character.getCamera().getWorldQuaternion(quaternion), 'YXZ');
104
109
  }
105
110
  else {
106
111
  //don't rotate if not moving
@@ -269,6 +274,7 @@ async function* SimpleCharacterTimeline(camera, character) {
269
274
  }));
270
275
  }
271
276
  export class SimpleCharacter extends Group {
277
+ camera;
272
278
  options;
273
279
  cameraBehavior;
274
280
  physics;
@@ -281,20 +287,24 @@ export class SimpleCharacter extends Group {
281
287
  updateTimeline;
282
288
  constructor(camera, world, domElement, options = {}) {
283
289
  super();
290
+ this.camera = camera;
284
291
  this.options = options;
285
292
  // input system
286
293
  this.inputSystem =
287
294
  options.input instanceof InputSystem
288
295
  ? options.input
289
- : new InputSystem(domElement, options.input ?? [LocomotionKeyboardInput, PointerCaptureInput]);
296
+ : new InputSystem(domElement, options.input ?? [ScreenJoystickInput, ScreenJumpButtonInput, PointerCaptureInput, LocomotionKeyboardInput], extractProxy(options, 'inputOptions'));
290
297
  options.physics ??= {};
291
298
  // camera behavior
292
- this.cameraBehavior = new SimpleCharacterCameraBehavior(camera, this, world.raycast.bind(world));
299
+ this.cameraBehavior = new SimpleCharacterCameraBehavior(typeof camera === 'function' ? camera : () => camera, this, world.raycast.bind(world));
293
300
  // physics
294
301
  this.physics = new BvhCharacterPhysics(this, world);
295
- this.init(camera, options).catch(console.error);
302
+ this.init(options).catch(console.error);
303
+ }
304
+ getCamera() {
305
+ return typeof this.camera === 'function' ? this.camera() : this.camera;
296
306
  }
297
- async init(camera, options) {
307
+ async init(options) {
298
308
  const { model, animations } = await preloadSimpleCharacterAssets(options);
299
309
  this.model = model;
300
310
  if (model != null && animations != null) {
@@ -310,7 +320,7 @@ export class SimpleCharacter extends Group {
310
320
  this.actions.jumpForward.loop = LoopOnce;
311
321
  this.actions.jumpForward.clampWhenFinished = true;
312
322
  }
313
- this.updateTimeline = start(SimpleCharacterTimeline(camera, this));
323
+ this.updateTimeline = start(SimpleCharacterTimeline(this));
314
324
  this.dispatchEvent({ type: 'loaded' });
315
325
  }
316
326
  update(delta) {
@@ -325,6 +335,8 @@ export class SimpleCharacter extends Group {
325
335
  dispose() {
326
336
  this.parent?.remove(this);
327
337
  this.model?.scene.dispatchEvent({ type: 'dispose' });
338
+ this.inputSystem.dispose();
339
+ this.physics.dispose();
328
340
  VRMUtils.deepDispose(this);
329
341
  }
330
342
  }
package/dist/utils.d.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export declare function cached<D extends ReadonlyArray<unknown>, T>(fn: (...deps: D) => Promise<T>, dependencies: D): Promise<T>;
2
2
  export declare function clearCache(fn: Function, dependencies: Array<unknown>): void;
3
+ export declare function extractProxy<K extends string, T extends {
4
+ [key in K]?: {};
5
+ }>(value: T, key: K): Partial<Exclude<T[K], undefined>>;
6
+ export declare function getIsMobileMediaQuery(): MediaQueryList | undefined;
7
+ export declare function isMobile(): boolean;
package/dist/utils.js CHANGED
@@ -32,3 +32,17 @@ export function clearCache(fn, dependencies) {
32
32
  }
33
33
  cache.splice(index, 1);
34
34
  }
35
+ export function extractProxy(value, key) {
36
+ return new Proxy({}, {
37
+ get: (_, p) => value[key]?.[p],
38
+ });
39
+ }
40
+ export function getIsMobileMediaQuery() {
41
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
42
+ return undefined;
43
+ }
44
+ return window.matchMedia('(hover: none) and (pointer: coarse)');
45
+ }
46
+ export function isMobile() {
47
+ return getIsMobileMediaQuery()?.matches ?? false;
48
+ }
package/package.json CHANGED
@@ -21,7 +21,7 @@
21
21
  "peerDependencies": {
22
22
  "three": "*"
23
23
  },
24
- "version": "0.1.16",
24
+ "version": "0.1.18",
25
25
  "type": "module",
26
26
  "dependencies": {
27
27
  "@pixiv/three-vrm": "^3.4.2",