@pmndrs/viverse 0.1.17 → 0.1.19

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.
@@ -0,0 +1,4 @@
1
+ import { AnimationClip } from 'three';
2
+ import { loadCharacterModel } from '../model/index.js';
3
+ import type { VRMHumanBoneName } from '@pixiv/three-vrm';
4
+ export declare function loadVrmModelFbxAnimations(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, url: string, removeXZMovement: boolean, boneMap?: Record<string, VRMHumanBoneName>): Promise<Array<AnimationClip>>;
@@ -1,9 +1,8 @@
1
1
  import { FBXLoader } from 'three/examples/jsm/Addons.js';
2
2
  import { fixModelAnimationClip } from './index.js';
3
- import mixamoBoneMap from './mixamo-bone-map.json';
4
3
  const loader = new FBXLoader();
5
- export async function loadVrmModelMixamoAnimations(model, url, removeXZMovement) {
4
+ export async function loadVrmModelFbxAnimations(model, url, removeXZMovement, boneMap) {
6
5
  const clipScene = await loader.loadAsync(url);
7
- clipScene.animations.forEach((clip) => fixModelAnimationClip(model, clip, clipScene, removeXZMovement, mixamoBoneMap));
6
+ clipScene.animations.forEach((clip) => fixModelAnimationClip(model, clip, clipScene, removeXZMovement, boneMap));
8
7
  return clipScene.animations;
9
8
  }
@@ -1,3 +1,3 @@
1
1
  import { AnimationClip } from 'three';
2
- import { loadCharacterModel } from '../model/index.js';
3
- export declare function loadVrmModelGltfAnimations(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, url: string, removeXZMovement: boolean): Promise<Array<AnimationClip>>;
2
+ import { loadCharacterModel, VRMHumanBoneName } from '../model/index.js';
3
+ export declare function loadVrmModelGltfAnimations(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, url: string, removeXZMovement: boolean, boneMap?: Record<string, VRMHumanBoneName>): Promise<Array<AnimationClip>>;
@@ -1,8 +1,8 @@
1
1
  import { GLTFLoader } from 'three/examples/jsm/Addons.js';
2
2
  import { fixModelAnimationClip } from './index.js';
3
3
  const loader = new GLTFLoader();
4
- export async function loadVrmModelGltfAnimations(model, url, removeXZMovement) {
4
+ export async function loadVrmModelGltfAnimations(model, url, removeXZMovement, boneMap) {
5
5
  const { animations, scene: clipScene } = await loader.loadAsync(url);
6
- animations.forEach((clip) => fixModelAnimationClip(model, clip, clipScene, removeXZMovement));
6
+ animations.forEach((clip) => fixModelAnimationClip(model, clip, clipScene, removeXZMovement, boneMap));
7
7
  return animations;
8
8
  }
@@ -3,17 +3,18 @@ import { AnimationClip, Object3D } from 'three';
3
3
  import { loadCharacterModel } from '../model/index.js';
4
4
  export declare function fixModelAnimationClip(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, clip: AnimationClip, clipScene: Object3D, removeXZMovement: boolean, boneMap?: Record<string, VRMHumanBoneName>): void;
5
5
  export * from './gltf.js';
6
- export * from './mixamo.js';
6
+ export * from './fbx.js';
7
7
  export * from './vrma.js';
8
8
  export * from './utils.js';
9
9
  export type ModelAnimationOptions = {
10
- type: 'mixamo' | 'gltf' | 'vrma';
10
+ type: 'mixamo' | 'gltf' | 'vrma' | 'fbx';
11
11
  url: string;
12
12
  removeXZMovement?: boolean;
13
13
  trimTime?: {
14
14
  start?: number;
15
15
  end?: number;
16
16
  };
17
+ boneMap?: Record<string, VRMHumanBoneName>;
17
18
  scaleTime?: number;
18
19
  };
19
20
  export declare function loadCharacterModelAnimation(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, options: ModelAnimationOptions): Promise<AnimationClip>;
@@ -28,3 +29,4 @@ declare const simpleCharacterAnimationUrls: {
28
29
  };
29
30
  export declare const simpleCharacterAnimationNames: Array<keyof typeof simpleCharacterAnimationUrls>;
30
31
  export declare function getSimpleCharacterModelAnimationOptions(animationName: keyof typeof simpleCharacterAnimationUrls): Promise<ModelAnimationOptions>;
32
+ export declare const mixamoBoneMap: Record<string, VRMHumanBoneName>;
@@ -1,21 +1,31 @@
1
1
  import { VRM } from '@pixiv/three-vrm';
2
2
  import { Euler, Quaternion, QuaternionKeyframeTrack, Vector3, VectorKeyframeTrack, } from 'three';
3
- import { loadVrmModelGltfAnimations as loadModelGltfAnimations } from './gltf.js';
4
- import { loadVrmModelMixamoAnimations as loadModelMixamoAnimations } from './mixamo.js';
3
+ import { loadVrmModelFbxAnimations } from './fbx.js';
4
+ import { loadVrmModelGltfAnimations } from './gltf.js';
5
5
  import { scaleAnimationClipTime, trimAnimationClip } from './utils.js';
6
6
  import { loadVrmModelVrmaAnimations } from './vrma.js';
7
7
  import { cached } from '../utils.js';
8
- const restRotationInverse = new Quaternion();
9
- const parentRestWorldRotation = new Quaternion();
10
- const quaternion = new Quaternion();
11
- const vector = new Vector3();
8
+ import _mixamoBoneMap from './mixamo-bone-map.json';
9
+ //helper variables for the quaternion retargeting
10
+ const baseThisLocalRestRotation_inverse = new Quaternion();
11
+ const baseThisLocalCurrentRotation = new Quaternion();
12
+ const baseParentWorldRestRotation = new Quaternion();
13
+ const baseParentWorldRestRotation_inverse = new Quaternion();
14
+ const targetParentWorldRestRotation = new Quaternion();
15
+ const targetParentWorldRestRotation_inverse = new Quaternion();
16
+ const targetThisLocalRestRotation = new Quaternion();
17
+ const targetThisLocalCurrentRotation = new Quaternion();
18
+ //helper variables for the position retargeting
19
+ const position = new Vector3();
12
20
  const nonVrmRotationOffset = new Quaternion().setFromEuler(new Euler(0, Math.PI, 0));
21
+ //TODO: currently assumes the model is not yet transformed - loaded for the first time
13
22
  export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement, boneMap) {
14
23
  const hipsBoneName = boneMap == null ? 'hips' : Object.entries(boneMap).find(([, vrmBoneName]) => vrmBoneName === 'hips')?.[0];
15
24
  if (hipsBoneName == null) {
16
25
  throw new Error('Failed to determine hips bone name for VRM animation. Please check the bone map or animation file.');
17
26
  }
18
27
  const clipSceneHips = clipScene.getObjectByName(hipsBoneName);
28
+ clipSceneHips?.parent?.updateMatrixWorld();
19
29
  const vrmHipsPosition = model instanceof VRM
20
30
  ? model.humanoid.normalizedRestPose.hips?.position
21
31
  : model.scene.getObjectByName('hips')?.position.toArray();
@@ -23,82 +33,111 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
23
33
  throw new Error('Failed to load VRM animation: missing animation hips object or VRM hips position.');
24
34
  }
25
35
  // Adjust with reference to hips height.
26
- const motionHipsHeight = clipSceneHips.position.y;
36
+ const motionHipsHeight = clipSceneHips.getWorldPosition(position).y;
27
37
  const [_, vrmHipsHeight] = vrmHipsPosition;
28
- const hipsPositionScale = vrmHipsHeight / motionHipsHeight;
38
+ const positionScale = vrmHipsHeight / motionHipsHeight;
29
39
  for (const track of clip.tracks) {
30
40
  // Convert each tracks for VRM use, and push to `tracks`
31
41
  const [boneName, propertyName] = track.name.split('.');
32
42
  const vrmBoneName = boneMap?.[boneName] ?? boneName;
33
- const vrmNodeName = model instanceof VRM ? model.humanoid.getNormalizedBoneNode(vrmBoneName)?.name : vrmBoneName;
34
- const bone = clipScene.getObjectByName(boneName);
35
- if (vrmNodeName == null || bone == null) {
43
+ const targetBone = model instanceof VRM
44
+ ? model.humanoid.getNormalizedBoneNode(vrmBoneName)
45
+ : model.scene.getObjectByName(vrmBoneName);
46
+ if (targetBone == null) {
47
+ continue;
48
+ }
49
+ const vrmNodeName = model instanceof VRM ? targetBone.name : vrmBoneName;
50
+ const baseBone = clipScene.getObjectByName(boneName);
51
+ if (vrmNodeName == null || baseBone == null) {
36
52
  continue;
37
53
  }
38
54
  if (track instanceof QuaternionKeyframeTrack) {
39
- if (bone.parent != null) {
40
- bone.getWorldQuaternion(restRotationInverse).invert();
41
- bone.parent.getWorldQuaternion(parentRestWorldRotation);
55
+ // Store rotations of rest-pose.
56
+ baseThisLocalRestRotation_inverse.copy(baseBone.quaternion).invert();
57
+ if (baseBone.parent != null) {
58
+ baseBone.parent.getWorldQuaternion(baseParentWorldRestRotation);
59
+ baseParentWorldRestRotation_inverse.copy(baseParentWorldRestRotation).invert();
42
60
  }
43
61
  else {
44
- restRotationInverse.identity();
45
- parentRestWorldRotation.identity();
62
+ baseParentWorldRestRotation.identity();
63
+ baseParentWorldRestRotation_inverse.identity();
64
+ }
65
+ targetThisLocalRestRotation.copy(targetBone.quaternion);
66
+ if (targetBone.parent != null) {
67
+ targetBone.parent.getWorldQuaternion(targetParentWorldRestRotation);
68
+ targetParentWorldRestRotation_inverse.copy(targetParentWorldRestRotation).invert();
69
+ }
70
+ else {
71
+ targetParentWorldRestRotation.identity();
72
+ targetParentWorldRestRotation_inverse.identity();
46
73
  }
47
- // Store rotations of rest-pose.
48
74
  for (let i = 0; i < track.values.length; i += 4) {
49
- quaternion.fromArray(track.values, i);
50
- if (model instanceof VRM) {
51
- quaternion.premultiply(parentRestWorldRotation).multiply(restRotationInverse);
52
- if (model.meta.metaVersion === '0') {
53
- quaternion.x *= -1;
54
- quaternion.z *= -1;
55
- }
75
+ baseThisLocalCurrentRotation.fromArray(track.values, i);
76
+ targetThisLocalCurrentRotation
77
+ .copy(targetParentWorldRestRotation_inverse)
78
+ .multiply(baseParentWorldRestRotation)
79
+ .multiply(baseThisLocalCurrentRotation)
80
+ .multiply(baseThisLocalRestRotation_inverse)
81
+ .multiply(baseParentWorldRestRotation_inverse)
82
+ .multiply(targetParentWorldRestRotation)
83
+ .multiply(targetThisLocalRestRotation);
84
+ if (model instanceof VRM && model.meta.metaVersion === '0') {
85
+ targetThisLocalCurrentRotation.x *= -1;
86
+ targetThisLocalCurrentRotation.z *= -1;
56
87
  }
57
- if (vrmBoneName === 'root') {
58
- quaternion.multiply(nonVrmRotationOffset);
88
+ if (!(model instanceof VRM) && vrmBoneName === 'hips') {
89
+ targetThisLocalCurrentRotation.premultiply(nonVrmRotationOffset);
59
90
  }
60
- if (removeXZMovement) {
61
- //TODO
62
- }
63
- quaternion.toArray(track.values, i);
91
+ targetThisLocalCurrentRotation.toArray(track.values, i);
64
92
  }
65
93
  track.name = `${vrmNodeName}.${propertyName}`;
66
94
  }
67
95
  else if (track instanceof VectorKeyframeTrack) {
68
96
  track.name = `${vrmNodeName}.${propertyName}`;
69
- if (propertyName === 'scale') {
97
+ if (propertyName != 'position') {
70
98
  continue;
71
99
  }
72
100
  for (let i = 0; i < track.values.length; i += 3) {
73
- vector.fromArray(track.values, i);
74
- vector.multiplyScalar(hipsPositionScale);
101
+ position.fromArray(track.values, i);
102
+ if (clipSceneHips.parent != null) {
103
+ if (vrmBoneName === 'hips') {
104
+ position.applyMatrix4(clipSceneHips.parent.matrixWorld);
105
+ }
106
+ else {
107
+ position.multiplyScalar(clipSceneHips.parent.matrixWorld.getMaxScaleOnAxis());
108
+ }
109
+ }
110
+ position.multiplyScalar(positionScale);
75
111
  if (model instanceof VRM) {
76
112
  if (model.meta.metaVersion === '0') {
77
- vector.negate();
78
- vector.y *= -1;
113
+ position.negate();
114
+ position.y *= -1;
79
115
  }
80
116
  }
81
117
  if (vrmBoneName === 'hips' && removeXZMovement) {
82
- vector.x = 0;
83
- vector.z = 0;
118
+ position.x = 0;
119
+ position.z = 0;
84
120
  }
85
- vector.toArray(track.values, i);
121
+ position.toArray(track.values, i);
86
122
  }
87
123
  }
88
124
  }
89
125
  }
90
126
  export * from './gltf.js';
91
- export * from './mixamo.js';
127
+ export * from './fbx.js';
92
128
  export * from './vrma.js';
93
129
  export * from './utils.js';
94
- async function uncachedLoadModelAnimation(model, type, url, removeXZMovement, trimStartTime, trimEndTime, scaleTime) {
130
+ async function uncachedLoadModelAnimation(model, type, url, removeXZMovement, trimStartTime, trimEndTime, boneMap, scaleTime) {
95
131
  let clips;
96
132
  switch (type) {
97
133
  case 'gltf':
98
- clips = await loadModelGltfAnimations(model, url, removeXZMovement);
134
+ clips = await loadVrmModelGltfAnimations(model, url, removeXZMovement, boneMap);
135
+ break;
136
+ case 'fbx':
137
+ clips = await loadVrmModelFbxAnimations(model, url, removeXZMovement, boneMap);
99
138
  break;
100
139
  case 'mixamo':
101
- clips = await loadModelMixamoAnimations(model, url, removeXZMovement);
140
+ clips = await loadVrmModelFbxAnimations(model, url, removeXZMovement, boneMap ?? mixamoBoneMap);
102
141
  break;
103
142
  case 'vrma':
104
143
  if (!(model instanceof VRM)) {
@@ -127,6 +166,7 @@ export function loadCharacterModelAnimation(model, options) {
127
166
  options.removeXZMovement ?? false,
128
167
  options.trimTime?.start,
129
168
  options.trimTime?.end,
169
+ options.boneMap,
130
170
  options.scaleTime,
131
171
  ]);
132
172
  }
@@ -152,3 +192,4 @@ export async function getSimpleCharacterModelAnimationOptions(animationName) {
152
192
  url: (await simpleCharacterAnimationUrls[animationName]()).url,
153
193
  };
154
194
  }
195
+ export const mixamoBoneMap = _mixamoBoneMap;
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.17",
24
+ "version": "0.1.19",
25
25
  "type": "module",
26
26
  "dependencies": {
27
27
  "@pixiv/three-vrm": "^3.4.2",
@@ -1,3 +0,0 @@
1
- import { AnimationClip } from 'three';
2
- import { loadCharacterModel } from '../model/index.js';
3
- export declare function loadVrmModelMixamoAnimations(model: Exclude<Awaited<ReturnType<typeof loadCharacterModel>>, undefined>, url: string, removeXZMovement: boolean): Promise<Array<AnimationClip>>;