three-player-controller 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -5,6 +5,17 @@ import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeom
5
5
  import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
6
6
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
7
7
  import * as BufferGeometryUtils from "three/examples/jsm/utils/BufferGeometryUtils.js";
8
+
9
+ // assets/imgs/fly.png
10
+ var fly_default = "./fly-MARLZTYI.png";
11
+
12
+ // assets/imgs/jump.png
13
+ var jump_default = "./jump-QUC4EGFV.png";
14
+
15
+ // assets/imgs/view.png
16
+ var view_default = "./view-WKETVFPK.png";
17
+
18
+ // src/playerController.ts
8
19
  THREE.Mesh.prototype.raycast = acceleratedRaycast;
9
20
  var controllerInstance = null;
10
21
  var clock = new THREE.Clock();
@@ -20,15 +31,15 @@ var PlayerController = class {
20
31
  this.displayPlayer = false;
21
32
  this.displayCollider = false;
22
33
  this.displayVisualizer = false;
23
- // 场景对象
34
+ // 场景对象
24
35
  this.collider = null;
25
36
  this.visualizer = null;
26
37
  this.person = null;
27
- // 状态开关
38
+ // 状态开关
28
39
  this.playerIsOnGround = false;
29
40
  this.isupdate = true;
30
41
  this.isFlying = false;
31
- // 输入状态
42
+ // 输入状态
32
43
  this.fwdPressed = false;
33
44
  this.bkdPressed = false;
34
45
  this.lftPressed = false;
@@ -36,6 +47,19 @@ var PlayerController = class {
36
47
  this.spacePressed = false;
37
48
  this.ctPressed = false;
38
49
  this.shiftPressed = false;
50
+ // 移动端输入
51
+ this.prevJoyState = { dirX: 0, dirY: 0, shift: false };
52
+ // 移动控制相关
53
+ this.joystickManager = null;
54
+ this.joystickZoneEl = null;
55
+ this.lookAreaEl = null;
56
+ this.jumpBtnEl = null;
57
+ this.flyBtnEl = null;
58
+ this.viewBtnEl = null;
59
+ this.lookPointerId = null;
60
+ this.isLookDown = false;
61
+ this.lastTouchX = 0;
62
+ this.lastTouchY = 0;
39
63
  // 第三人称
40
64
  this._camCollisionLerp = 0.18;
41
65
  // 平滑系数
@@ -46,7 +70,7 @@ var PlayerController = class {
46
70
  this._maxCamDistance = 4.4;
47
71
  // 摄像机最大距离
48
72
  this.orginMaxCamDistance = 4.4;
49
- // 物理/运动
73
+ // 物理/运动
50
74
  this.playerVelocity = new THREE.Vector3();
51
75
  // 玩家速度向量
52
76
  this.upVector = new THREE.Vector3(0, 1, 0);
@@ -56,6 +80,8 @@ var PlayerController = class {
56
80
  this.tempBox = new THREE.Box3();
57
81
  this.tempMat = new THREE.Matrix4();
58
82
  this.tempSegment = new THREE.Line3();
83
+ // 检测动画定时
84
+ this.recheckAnimTimer = null;
59
85
  // 复用向量:用于相机朝向 / 移动
60
86
  this.camDir = new THREE.Vector3();
61
87
  this.moveDir = new THREE.Vector3();
@@ -106,6 +132,8 @@ var PlayerController = class {
106
132
  case "Space":
107
133
  this.spacePressed = true;
108
134
  if (!this.playerIsOnGround || this.isFlying) return;
135
+ const next = this.personActions?.get("jumping");
136
+ if (next && this.actionState === next) return;
109
137
  this.playPersonAnimationByName("jumping");
110
138
  this.playerVelocity.y = this.jumpHeight;
111
139
  this.playerIsOnGround = false;
@@ -119,6 +147,8 @@ var PlayerController = class {
119
147
  case "KeyF":
120
148
  this.isFlying = !this.isFlying;
121
149
  this.setAnimationByPressed();
150
+ if (!this.isFlying && !this.playerIsOnGround)
151
+ this.playPersonAnimationByName("jumping");
122
152
  break;
123
153
  }
124
154
  };
@@ -198,49 +228,51 @@ var PlayerController = class {
198
228
  this.playPersonAnimationByName("walking_backward");
199
229
  return;
200
230
  }
201
- } else {
202
- this.playPersonAnimationByName("jumping");
203
231
  }
232
+ if (this.recheckAnimTimer !== null) {
233
+ clearTimeout(this.recheckAnimTimer);
234
+ }
235
+ this.recheckAnimTimer = setTimeout(() => {
236
+ this.setAnimationByPressed();
237
+ this.recheckAnimTimer = null;
238
+ }, 200);
204
239
  };
205
240
  // 鼠标移动事件
206
241
  this._mouseMove = (e) => {
207
242
  if (document.pointerLockElement !== document.body) return;
208
- if (this.isFirstPerson) {
209
- const yaw = -e.movementX * 1e-4 * this.mouseSensity;
210
- const pitch = -e.movementY * 1e-4 * this.mouseSensity;
211
- this.player.rotateY(yaw);
212
- this.camera.rotation.x = THREE.MathUtils.clamp(
213
- this.camera.rotation.x + pitch,
214
- -1.3,
215
- 1.4
216
- );
217
- } else {
218
- const sensitivity = 1e-4 * this.mouseSensity;
219
- const deltaX = -e.movementX * sensitivity;
220
- const deltaY = -e.movementY * sensitivity;
221
- const target = this.player.position.clone();
222
- const distance = this.camera.position.distanceTo(target);
223
- const currentPosition = this.camera.position.clone().sub(target);
224
- let theta = Math.atan2(currentPosition.x, currentPosition.z);
225
- let phi = Math.acos(currentPosition.y / distance);
226
- theta += deltaX;
227
- phi += deltaY;
228
- phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
229
- const newX = distance * Math.sin(phi) * Math.sin(theta);
230
- const newY = distance * Math.cos(phi);
231
- const newZ = distance * Math.sin(phi) * Math.cos(theta);
232
- this.camera.position.set(
233
- target.x + newX,
234
- target.y + newY,
235
- target.z + newZ
236
- );
237
- this.camera.lookAt(target);
238
- }
243
+ this.setToward(e.movementX, e.movementY, 1e-4);
239
244
  };
240
245
  this._mouseClick = (e) => {
241
246
  if (document.pointerLockElement !== document.body)
242
247
  document.body.requestPointerLock();
243
248
  };
249
+ this.onPointerDown = (e) => {
250
+ if (e.pointerType !== "touch") return;
251
+ this.isLookDown = true;
252
+ this.lookPointerId = e.pointerId;
253
+ this.lastTouchX = e.clientX;
254
+ this.lastTouchY = e.clientY;
255
+ this.lookAreaEl?.setPointerCapture && this.lookAreaEl.setPointerCapture(e.pointerId);
256
+ e.preventDefault();
257
+ };
258
+ this.onPointerMove = (e) => {
259
+ if (!this.isLookDown || e.pointerId !== this.lookPointerId) return;
260
+ const dx = e.clientX - this.lastTouchX;
261
+ const dy = e.clientY - this.lastTouchY;
262
+ this.lastTouchX = e.clientX;
263
+ this.lastTouchY = e.clientY;
264
+ this.setInput({
265
+ lookDeltaX: dx,
266
+ lookDeltaY: dy
267
+ });
268
+ e.preventDefault();
269
+ };
270
+ this.onPointerUp = (e) => {
271
+ if (e.pointerId !== this.lookPointerId) return;
272
+ this.isLookDown = false;
273
+ this.lookPointerId = null;
274
+ this.lookAreaEl?.releasePointerCapture && this.lookAreaEl.releasePointerCapture(e.pointerId);
275
+ };
244
276
  this._raycaster.firstHitOnly = true;
245
277
  this._raycasterPersonToCam.firstHitOnly = true;
246
278
  }
@@ -264,11 +296,18 @@ var PlayerController = class {
264
296
  this._minCamDistance = opts.minCamDistance ? opts.minCamDistance * s : 100 * s;
265
297
  this._maxCamDistance = opts.maxCamDistance ? opts.maxCamDistance * s : 440 * s;
266
298
  this.orginMaxCamDistance = this._maxCamDistance;
299
+ this.isShowMobileControls = opts.isShowMobileControls ?? true;
300
+ function isMobileDevice() {
301
+ return navigator.maxTouchPoints && navigator.maxTouchPoints > 0 || "ontouchstart" in window || /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
302
+ }
303
+ if (isMobileDevice() && this.isShowMobileControls) {
304
+ this.initMobileControls();
305
+ }
267
306
  await this.createBVH(opts.colliderMeshUrl);
268
307
  this.createPlayer();
269
308
  await this.loadPersonGLB();
270
- if (this.isFirstPerson && this.player) {
271
- this.player.add(this.camera);
309
+ if (this.isFirstPerson && this.person) {
310
+ this.person.add(this.camera);
272
311
  }
273
312
  this.onAllEvent();
274
313
  this.setCameraPos();
@@ -367,11 +406,7 @@ var PlayerController = class {
367
406
  this.person.traverse((child) => {
368
407
  if (child.isMesh) {
369
408
  child.castShadow = true;
370
- const mat = child.material;
371
- if (!mat) return;
372
- const mats = Array.isArray(mat) ? mat : [mat];
373
- mats.forEach((m) => {
374
- });
409
+ child.receiveShadow = true;
375
410
  }
376
411
  });
377
412
  this.player.add(this.person);
@@ -754,6 +789,7 @@ var PlayerController = class {
754
789
  this.scene.remove(this.collider);
755
790
  this.collider = null;
756
791
  }
792
+ this.destroyMobileControls();
757
793
  controllerInstance = null;
758
794
  }
759
795
  // 事件绑定
@@ -891,6 +927,305 @@ var PlayerController = class {
891
927
  }
892
928
  this.boundingBoxMinY = this.collider.geometry.boundingBox.min.y;
893
929
  }
930
+ // 设置朝向
931
+ setToward(dx, dy, speed) {
932
+ if (this.isFirstPerson) {
933
+ const yaw = -dx * speed * this.mouseSensity;
934
+ const pitch = -dy * speed * this.mouseSensity;
935
+ this.player.rotateY(yaw);
936
+ this.camera.rotation.x = THREE.MathUtils.clamp(
937
+ this.camera.rotation.x + pitch,
938
+ -1.1,
939
+ 1.4
940
+ );
941
+ } else {
942
+ const sensitivity = this.mouseSensity;
943
+ const deltaX = -dx * speed * sensitivity;
944
+ const deltaY = -dy * speed * sensitivity;
945
+ const target = this.player.position.clone();
946
+ const distance = this.camera.position.distanceTo(target);
947
+ const currentPosition = this.camera.position.clone().sub(target);
948
+ let theta = Math.atan2(currentPosition.x, currentPosition.z);
949
+ let phi = Math.acos(currentPosition.y / distance);
950
+ theta += deltaX;
951
+ phi += deltaY;
952
+ phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
953
+ const newX = distance * Math.sin(phi) * Math.sin(theta);
954
+ const newY = distance * Math.cos(phi);
955
+ const newZ = distance * Math.sin(phi) * Math.cos(theta);
956
+ this.camera.position.set(
957
+ target.x + newX,
958
+ target.y + newY,
959
+ target.z + newZ
960
+ );
961
+ this.camera.lookAt(target);
962
+ }
963
+ }
964
+ // 设置输入
965
+ setInput(input) {
966
+ if (typeof input.moveX === "number") {
967
+ this.lftPressed = input.moveX == -1;
968
+ this.rgtPressed = input.moveX == 1;
969
+ this.setAnimationByPressed();
970
+ }
971
+ if (typeof input.moveY === "number") {
972
+ this.fwdPressed = input.moveY == 1;
973
+ this.bkdPressed = input.moveY == -1;
974
+ this.setAnimationByPressed();
975
+ }
976
+ if (typeof input.lookDeltaX === "number" && typeof input.lookDeltaY === "number") {
977
+ this.setToward(input.lookDeltaX, input.lookDeltaY, 2e-3);
978
+ }
979
+ if (typeof input.jump === "boolean") {
980
+ if (input.jump) {
981
+ this.spacePressed = true;
982
+ if (!this.playerIsOnGround || this.isFlying) return;
983
+ this.playPersonAnimationByName("jumping");
984
+ this.playerVelocity.y = this.jumpHeight;
985
+ this.playerIsOnGround = false;
986
+ } else {
987
+ this.spacePressed = false;
988
+ }
989
+ }
990
+ if (typeof input.shift === "boolean") {
991
+ this.shiftPressed = input.shift;
992
+ }
993
+ if (input.toggleView) {
994
+ this.changeView();
995
+ }
996
+ if (input.toggleFly) {
997
+ this.isFlying = !this.isFlying;
998
+ this.setAnimationByPressed();
999
+ if (!this.isFlying && !this.playerIsOnGround)
1000
+ this.playPersonAnimationByName("jumping");
1001
+ }
1002
+ }
1003
+ // 初始化移动端摇杆控制
1004
+ async initMobileControls() {
1005
+ this.controls.maxPolarAngle = Math.PI * (300 / 360);
1006
+ this.controls.touches = { ONE: null, TWO: null };
1007
+ const mod = (await import("nipplejs")).default;
1008
+ const nipple = mod;
1009
+ const JOY_SIZE = 120;
1010
+ const container = document.body;
1011
+ this.joystickZoneEl = document.createElement("div");
1012
+ this.joystickZoneEl.id = "joy-zone";
1013
+ Object.assign(this.joystickZoneEl.style, {
1014
+ position: "absolute",
1015
+ left: "16px",
1016
+ bottom: "16px",
1017
+ width: `${JOY_SIZE + 40}px`,
1018
+ height: `${JOY_SIZE + 40}px`,
1019
+ touchAction: "none",
1020
+ zIndex: "999",
1021
+ pointerEvents: "auto",
1022
+ WebkitUserSelect: "none",
1023
+ userSelect: "none"
1024
+ });
1025
+ container.appendChild(this.joystickZoneEl);
1026
+ ["touchstart", "touchmove", "touchend", "touchcancel"].forEach(
1027
+ (evtName) => {
1028
+ this.joystickZoneEl?.addEventListener(
1029
+ evtName,
1030
+ (e) => {
1031
+ e.preventDefault();
1032
+ },
1033
+ { passive: false }
1034
+ );
1035
+ }
1036
+ );
1037
+ this.joystickManager = nipple.create({
1038
+ zone: this.joystickZoneEl,
1039
+ mode: "static",
1040
+ position: {
1041
+ left: `${(JOY_SIZE + 40) / 2}px`,
1042
+ bottom: `${(JOY_SIZE + 40) / 2}px`
1043
+ },
1044
+ color: "#ffffff",
1045
+ size: JOY_SIZE,
1046
+ multitouch: true,
1047
+ maxNumberOfNipples: 1
1048
+ });
1049
+ this.joystickManager.on("move", (_evt, data) => {
1050
+ if (!data) return;
1051
+ const rawX = data.vector?.x ?? 0;
1052
+ const rawY = data.vector?.y ?? 0;
1053
+ const distance = data.distance ?? 0;
1054
+ const deadzone = 0.5;
1055
+ const dirX = rawX > deadzone ? 1 : rawX < -deadzone ? -1 : 0;
1056
+ const dirY = rawY > deadzone ? 1 : rawY < -deadzone ? -1 : 0;
1057
+ const sprintThreshold = JOY_SIZE / 2;
1058
+ const isSprinting = distance >= sprintThreshold;
1059
+ const prev = this.prevJoyState || { dirX: 0, dirY: 0, shift: false };
1060
+ if (dirX === prev.dirX && dirY === prev.dirY && isSprinting === prev.shift) {
1061
+ return;
1062
+ }
1063
+ this.prevJoyState = { dirX, dirY, shift: isSprinting };
1064
+ this.setInput({ moveX: dirX, moveY: dirY, shift: isSprinting });
1065
+ });
1066
+ this.joystickManager.on("end", () => {
1067
+ const prev = this.prevJoyState || { dirX: 0, dirY: 0, shift: false };
1068
+ if (prev.dirX !== 0 || prev.dirY !== 0 || prev.shift !== false) {
1069
+ this.prevJoyState = { dirX: 0, dirY: 0, shift: false };
1070
+ this.setInput({ moveX: 0, moveY: 0, shift: false });
1071
+ }
1072
+ });
1073
+ this.lookAreaEl = document.createElement("div");
1074
+ Object.assign(this.lookAreaEl.style, {
1075
+ position: "absolute",
1076
+ right: "0",
1077
+ bottom: "0",
1078
+ width: "50%",
1079
+ height: "100%",
1080
+ zIndex: "998",
1081
+ touchAction: "none",
1082
+ WebkitUserSelect: "none",
1083
+ userSelect: "none"
1084
+ });
1085
+ container.appendChild(this.lookAreaEl);
1086
+ ["touchstart", "touchmove", "touchend", "touchcancel"].forEach(
1087
+ (evtName) => {
1088
+ this.lookAreaEl?.addEventListener(
1089
+ evtName,
1090
+ (e) => {
1091
+ e.preventDefault();
1092
+ },
1093
+ { passive: false }
1094
+ );
1095
+ }
1096
+ );
1097
+ this.lookAreaEl.addEventListener("pointerdown", this.onPointerDown, {
1098
+ passive: false
1099
+ });
1100
+ this.lookAreaEl.addEventListener("pointermove", this.onPointerMove, {
1101
+ passive: false
1102
+ });
1103
+ this.lookAreaEl.addEventListener("pointerup", this.onPointerUp, {
1104
+ passive: false
1105
+ });
1106
+ this.lookAreaEl.addEventListener("pointercancel", this.onPointerUp, {
1107
+ passive: false
1108
+ });
1109
+ const createBtn = (rightPx, bottomPx, bgUrl) => {
1110
+ const btn = document.createElement("button");
1111
+ const styles = {
1112
+ position: "absolute",
1113
+ right: `${rightPx}px`,
1114
+ bottom: `${bottomPx}px`,
1115
+ width: "56px",
1116
+ height: "56px",
1117
+ zIndex: "1000",
1118
+ borderRadius: "50%",
1119
+ border: "2px solid black",
1120
+ background: "rgba(0,0,0)",
1121
+ padding: "20px",
1122
+ opacity: "0.95",
1123
+ touchAction: "none",
1124
+ fontSize: "14px",
1125
+ userSelect: "none",
1126
+ overflow: "hidden",
1127
+ boxSizing: "border-box",
1128
+ backgroundColor: "transparent",
1129
+ backgroundRepeat: "no-repeat, no-repeat",
1130
+ backgroundPosition: "center center, center center",
1131
+ backgroundSize: "100% 100%, 80% 80%"
1132
+ };
1133
+ if (bgUrl) {
1134
+ const overlayColor = "rgba(0,0,0,0.5)";
1135
+ styles.backgroundImage = `linear-gradient(${overlayColor}, ${overlayColor}), url("${bgUrl}")`;
1136
+ }
1137
+ Object.assign(btn.style, styles);
1138
+ container.appendChild(btn);
1139
+ ["touchstart", "touchend", "touchcancel"].forEach((evtName) => {
1140
+ btn.addEventListener(
1141
+ evtName,
1142
+ (e) => {
1143
+ e.preventDefault();
1144
+ },
1145
+ { passive: false }
1146
+ );
1147
+ });
1148
+ return btn;
1149
+ };
1150
+ this.jumpBtnEl = createBtn(14, 14, jump_default);
1151
+ this.jumpBtnEl.addEventListener(
1152
+ "touchstart",
1153
+ (e) => {
1154
+ e.preventDefault();
1155
+ this.setInput({ jump: true });
1156
+ },
1157
+ { passive: false }
1158
+ );
1159
+ this.jumpBtnEl.addEventListener(
1160
+ "touchend",
1161
+ (e) => {
1162
+ e.preventDefault();
1163
+ this.setInput({ jump: false });
1164
+ },
1165
+ { passive: false }
1166
+ );
1167
+ this.jumpBtnEl.addEventListener(
1168
+ "touchcancel",
1169
+ (e) => {
1170
+ e.preventDefault();
1171
+ this.setInput({ jump: false });
1172
+ },
1173
+ { passive: false }
1174
+ );
1175
+ this.flyBtnEl = createBtn(14, 14 + 80, fly_default);
1176
+ this.flyBtnEl.addEventListener(
1177
+ "touchstart",
1178
+ (e) => {
1179
+ e.preventDefault();
1180
+ this.setInput({ toggleFly: true });
1181
+ },
1182
+ { passive: false }
1183
+ );
1184
+ this.viewBtnEl = createBtn(14, 14 + 200, view_default);
1185
+ this.viewBtnEl.addEventListener(
1186
+ "touchstart",
1187
+ (e) => {
1188
+ e.preventDefault();
1189
+ this.setInput({ toggleView: true });
1190
+ },
1191
+ { passive: false }
1192
+ );
1193
+ }
1194
+ // 销毁移动端摇杆控制
1195
+ destroyMobileControls() {
1196
+ try {
1197
+ if (this.joystickManager && this.joystickManager.destroy) {
1198
+ this.joystickManager.destroy();
1199
+ this.joystickManager = null;
1200
+ }
1201
+ if (this.joystickZoneEl?.parentElement) {
1202
+ this.joystickZoneEl.parentElement.removeChild(this.joystickZoneEl);
1203
+ this.joystickZoneEl = null;
1204
+ }
1205
+ if (this.lookAreaEl?.parentElement) {
1206
+ this.lookAreaEl.parentElement.removeChild(this.lookAreaEl);
1207
+ this.lookAreaEl = null;
1208
+ }
1209
+ if (this.jumpBtnEl?.parentElement) {
1210
+ this.jumpBtnEl.parentElement.removeChild(this.jumpBtnEl);
1211
+ this.jumpBtnEl = null;
1212
+ }
1213
+ if (this.flyBtnEl?.parentElement) {
1214
+ this.flyBtnEl.parentElement.removeChild(this.flyBtnEl);
1215
+ this.flyBtnEl = null;
1216
+ }
1217
+ if (this.viewBtnEl?.parentElement) {
1218
+ this.viewBtnEl.parentElement.removeChild(this.viewBtnEl);
1219
+ this.viewBtnEl = null;
1220
+ }
1221
+ this.lookAreaEl?.removeEventListener("pointerdown", this.onPointerDown);
1222
+ this.lookAreaEl?.removeEventListener("pointermove", this.onPointerMove);
1223
+ this.lookAreaEl?.removeEventListener("pointerup", this.onPointerUp);
1224
+ this.lookAreaEl?.removeEventListener("pointercancel", this.onPointerUp);
1225
+ } catch (e) {
1226
+ console.warn("\u9500\u6BC1\u79FB\u52A8\u7AEF\u6447\u6746\u63A7\u5236\u65F6\u51FA\u9519\uFF1A", e);
1227
+ }
1228
+ }
894
1229
  };
895
1230
  function playerController() {
896
1231
  if (!controllerInstance) controllerInstance = new PlayerController();
@@ -900,7 +1235,8 @@ function playerController() {
900
1235
  changeView: () => c.changeView(),
901
1236
  reset: (pos) => c.reset(pos),
902
1237
  update: (dt) => c.update(dt),
903
- destroy: () => c.destroy()
1238
+ destroy: () => c.destroy(),
1239
+ setInput: (i) => c.setInput(i)
904
1240
  };
905
1241
  }
906
1242
  function onAllEvent() {