spoint 0.1.0 → 0.1.11

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.
Files changed (204) hide show
  1. package/README.md +134 -209
  2. package/SKILL.md +95 -0
  3. package/apps/environment/index.js +200 -1
  4. package/apps/environment/models/decorative/.gitkeep +0 -0
  5. package/apps/environment/models/hazards/.gitkeep +0 -0
  6. package/apps/environment/models/interactive/.gitkeep +0 -0
  7. package/apps/environment/models/structures/.gitkeep +0 -0
  8. package/apps/environment/smartObjects.js +114 -0
  9. package/apps/interactable/index.js +155 -0
  10. package/apps/physics-crate/index.js +15 -9
  11. package/apps/power-crate/index.js +18 -12
  12. package/apps/tps-game/$GDUPI.vrm +0 -0
  13. package/apps/tps-game/Cleetus.vrm +0 -0
  14. package/apps/tps-game/index.js +185 -27
  15. package/apps/world/index.js +68 -22
  16. package/bin/create-app.js +337 -0
  17. package/client/ARControls.js +301 -0
  18. package/client/LoadingManager.js +117 -0
  19. package/client/MobileControls.js +1122 -0
  20. package/client/anim-lib.glb +0 -0
  21. package/client/animation.js +306 -0
  22. package/client/app.js +1341 -65
  23. package/client/camera.js +191 -33
  24. package/client/createLoadingScreen.js +69 -0
  25. package/client/editor/bridge.js +113 -0
  26. package/client/editor/css/main.css +794 -0
  27. package/client/editor/images/rotate.svg +4 -0
  28. package/client/editor/images/scale.svg +4 -0
  29. package/client/editor/images/translate.svg +4 -0
  30. package/client/editor/index.html +103 -0
  31. package/client/editor/js/Command.js +41 -0
  32. package/client/editor/js/Config.js +81 -0
  33. package/client/editor/js/Editor.js +785 -0
  34. package/client/editor/js/EditorControls.js +438 -0
  35. package/client/editor/js/History.js +321 -0
  36. package/client/editor/js/Loader.js +987 -0
  37. package/client/editor/js/LoaderUtils.js +90 -0
  38. package/client/editor/js/Menubar.Add.js +510 -0
  39. package/client/editor/js/Menubar.Edit.js +145 -0
  40. package/client/editor/js/Menubar.File.js +466 -0
  41. package/client/editor/js/Menubar.Help.js +73 -0
  42. package/client/editor/js/Menubar.Status.js +51 -0
  43. package/client/editor/js/Menubar.View.js +183 -0
  44. package/client/editor/js/Menubar.js +27 -0
  45. package/client/editor/js/Player.js +53 -0
  46. package/client/editor/js/Resizer.js +58 -0
  47. package/client/editor/js/Script.js +503 -0
  48. package/client/editor/js/Selector.js +102 -0
  49. package/client/editor/js/Sidebar.Geometry.BoxGeometry.js +121 -0
  50. package/client/editor/js/Sidebar.Geometry.BufferGeometry.js +115 -0
  51. package/client/editor/js/Sidebar.Geometry.CapsuleGeometry.js +97 -0
  52. package/client/editor/js/Sidebar.Geometry.CircleGeometry.js +97 -0
  53. package/client/editor/js/Sidebar.Geometry.CylinderGeometry.js +121 -0
  54. package/client/editor/js/Sidebar.Geometry.DodecahedronGeometry.js +73 -0
  55. package/client/editor/js/Sidebar.Geometry.ExtrudeGeometry.js +196 -0
  56. package/client/editor/js/Sidebar.Geometry.IcosahedronGeometry.js +73 -0
  57. package/client/editor/js/Sidebar.Geometry.LatheGeometry.js +98 -0
  58. package/client/editor/js/Sidebar.Geometry.Modifiers.js +73 -0
  59. package/client/editor/js/Sidebar.Geometry.OctahedronGeometry.js +74 -0
  60. package/client/editor/js/Sidebar.Geometry.PlaneGeometry.js +97 -0
  61. package/client/editor/js/Sidebar.Geometry.RingGeometry.js +121 -0
  62. package/client/editor/js/Sidebar.Geometry.ShapeGeometry.js +76 -0
  63. package/client/editor/js/Sidebar.Geometry.SphereGeometry.js +133 -0
  64. package/client/editor/js/Sidebar.Geometry.TetrahedronGeometry.js +74 -0
  65. package/client/editor/js/Sidebar.Geometry.TorusGeometry.js +109 -0
  66. package/client/editor/js/Sidebar.Geometry.TorusKnotGeometry.js +121 -0
  67. package/client/editor/js/Sidebar.Geometry.TubeGeometry.js +135 -0
  68. package/client/editor/js/Sidebar.Geometry.js +332 -0
  69. package/client/editor/js/Sidebar.Material.BooleanProperty.js +60 -0
  70. package/client/editor/js/Sidebar.Material.ColorProperty.js +87 -0
  71. package/client/editor/js/Sidebar.Material.ConstantProperty.js +62 -0
  72. package/client/editor/js/Sidebar.Material.MapProperty.js +249 -0
  73. package/client/editor/js/Sidebar.Material.NumberProperty.js +60 -0
  74. package/client/editor/js/Sidebar.Material.Program.js +73 -0
  75. package/client/editor/js/Sidebar.Material.RangeValueProperty.js +63 -0
  76. package/client/editor/js/Sidebar.Material.js +751 -0
  77. package/client/editor/js/Sidebar.Object.Animation.js +102 -0
  78. package/client/editor/js/Sidebar.Object.js +898 -0
  79. package/client/editor/js/Sidebar.Project.App.js +165 -0
  80. package/client/editor/js/Sidebar.Project.Image.js +225 -0
  81. package/client/editor/js/Sidebar.Project.Materials.js +82 -0
  82. package/client/editor/js/Sidebar.Project.Renderer.js +144 -0
  83. package/client/editor/js/Sidebar.Project.Video.js +242 -0
  84. package/client/editor/js/Sidebar.Project.js +31 -0
  85. package/client/editor/js/Sidebar.Properties.js +73 -0
  86. package/client/editor/js/Sidebar.Scene.js +585 -0
  87. package/client/editor/js/Sidebar.Script.js +129 -0
  88. package/client/editor/js/Sidebar.Settings.History.js +146 -0
  89. package/client/editor/js/Sidebar.Settings.Shortcuts.js +175 -0
  90. package/client/editor/js/Sidebar.Settings.js +60 -0
  91. package/client/editor/js/Sidebar.js +41 -0
  92. package/client/editor/js/Storage.js +98 -0
  93. package/client/editor/js/Strings.js +2028 -0
  94. package/client/editor/js/Toolbar.js +84 -0
  95. package/client/editor/js/Viewport.Controls.js +92 -0
  96. package/client/editor/js/Viewport.Info.js +136 -0
  97. package/client/editor/js/Viewport.Pathtracer.js +91 -0
  98. package/client/editor/js/Viewport.ViewHelper.js +39 -0
  99. package/client/editor/js/Viewport.XR.js +222 -0
  100. package/client/editor/js/Viewport.js +900 -0
  101. package/client/editor/js/commands/AddObjectCommand.js +68 -0
  102. package/client/editor/js/commands/AddScriptCommand.js +75 -0
  103. package/client/editor/js/commands/Commands.js +23 -0
  104. package/client/editor/js/commands/MoveObjectCommand.js +111 -0
  105. package/client/editor/js/commands/MultiCmdsCommand.js +85 -0
  106. package/client/editor/js/commands/RemoveObjectCommand.js +88 -0
  107. package/client/editor/js/commands/RemoveScriptCommand.js +81 -0
  108. package/client/editor/js/commands/SetColorCommand.js +73 -0
  109. package/client/editor/js/commands/SetGeometryCommand.js +87 -0
  110. package/client/editor/js/commands/SetGeometryValueCommand.js +70 -0
  111. package/client/editor/js/commands/SetMaterialColorCommand.js +86 -0
  112. package/client/editor/js/commands/SetMaterialCommand.js +79 -0
  113. package/client/editor/js/commands/SetMaterialMapCommand.js +143 -0
  114. package/client/editor/js/commands/SetMaterialRangeCommand.js +91 -0
  115. package/client/editor/js/commands/SetMaterialValueCommand.js +90 -0
  116. package/client/editor/js/commands/SetMaterialVectorCommand.js +79 -0
  117. package/client/editor/js/commands/SetPositionCommand.js +84 -0
  118. package/client/editor/js/commands/SetRotationCommand.js +84 -0
  119. package/client/editor/js/commands/SetScaleCommand.js +84 -0
  120. package/client/editor/js/commands/SetSceneCommand.js +103 -0
  121. package/client/editor/js/commands/SetScriptValueCommand.js +80 -0
  122. package/client/editor/js/commands/SetShadowValueCommand.js +73 -0
  123. package/client/editor/js/commands/SetUuidCommand.js +70 -0
  124. package/client/editor/js/commands/SetValueCommand.js +75 -0
  125. package/client/editor/js/libs/acorn/acorn.js +3236 -0
  126. package/client/editor/js/libs/acorn/acorn_loose.js +1299 -0
  127. package/client/editor/js/libs/acorn/walk.js +344 -0
  128. package/client/editor/js/libs/app/index.html +57 -0
  129. package/client/editor/js/libs/app.js +251 -0
  130. package/client/editor/js/libs/codemirror/addon/dialog.css +32 -0
  131. package/client/editor/js/libs/codemirror/addon/dialog.js +163 -0
  132. package/client/editor/js/libs/codemirror/addon/show-hint.css +36 -0
  133. package/client/editor/js/libs/codemirror/addon/show-hint.js +529 -0
  134. package/client/editor/js/libs/codemirror/addon/tern.css +87 -0
  135. package/client/editor/js/libs/codemirror/addon/tern.js +750 -0
  136. package/client/editor/js/libs/codemirror/codemirror.css +344 -0
  137. package/client/editor/js/libs/codemirror/codemirror.js +9849 -0
  138. package/client/editor/js/libs/codemirror/mode/glsl.js +233 -0
  139. package/client/editor/js/libs/codemirror/mode/javascript.js +959 -0
  140. package/client/editor/js/libs/codemirror/theme/monokai.css +41 -0
  141. package/client/editor/js/libs/esprima.js +6401 -0
  142. package/client/editor/js/libs/jsonlint.js +453 -0
  143. package/client/editor/js/libs/signals.min.js +14 -0
  144. package/client/editor/js/libs/tern-threejs/threejs.js +5031 -0
  145. package/client/editor/js/libs/ternjs/comment.js +87 -0
  146. package/client/editor/js/libs/ternjs/def.js +588 -0
  147. package/client/editor/js/libs/ternjs/doc_comment.js +401 -0
  148. package/client/editor/js/libs/ternjs/infer.js +1635 -0
  149. package/client/editor/js/libs/ternjs/polyfill.js +80 -0
  150. package/client/editor/js/libs/ternjs/signal.js +26 -0
  151. package/client/editor/js/libs/ternjs/tern.js +993 -0
  152. package/client/editor/js/libs/ui.js +1346 -0
  153. package/client/editor/js/libs/ui.three.js +855 -0
  154. package/client/facial-animation.js +455 -0
  155. package/client/index.html +7 -4
  156. package/client/loading.css +147 -0
  157. package/client/loading.html +25 -0
  158. package/client/style.css +251 -0
  159. package/package.json +7 -3
  160. package/server.js +9 -1
  161. package/src/apps/AppContext.js +1 -1
  162. package/src/apps/AppLoader.js +50 -37
  163. package/src/apps/AppRuntime.js +32 -8
  164. package/src/client/InputHandler.js +233 -0
  165. package/src/client/JitterBuffer.js +207 -0
  166. package/src/client/KalmanFilter.js +125 -0
  167. package/src/client/MessageHandler.js +101 -0
  168. package/src/client/PhysicsNetworkClient.js +141 -68
  169. package/src/client/ReconnectManager.js +62 -0
  170. package/src/client/SmoothInterpolation.js +127 -0
  171. package/src/client/SnapshotProcessor.js +144 -0
  172. package/src/connection/ConnectionManager.js +21 -3
  173. package/src/connection/SessionStore.js +13 -3
  174. package/src/index.client.js +4 -6
  175. package/src/netcode/EventLog.js +29 -15
  176. package/src/netcode/LagCompensator.js +25 -26
  177. package/src/netcode/NetworkState.js +4 -1
  178. package/src/netcode/PhysicsIntegration.js +20 -6
  179. package/src/netcode/PlayerManager.js +10 -2
  180. package/src/netcode/SnapshotEncoder.js +66 -19
  181. package/src/netcode/TickSystem.js +13 -4
  182. package/src/physics/World.js +66 -13
  183. package/src/protocol/msgpack.js +90 -63
  184. package/src/sdk/ReloadHandlers.js +12 -2
  185. package/src/sdk/ReloadManager.js +5 -0
  186. package/src/sdk/ServerHandlers.js +50 -11
  187. package/src/sdk/StaticHandler.js +22 -6
  188. package/src/sdk/TickHandler.js +101 -34
  189. package/src/sdk/scaffold.js +31 -0
  190. package/src/sdk/server.js +59 -33
  191. package/src/shared/movement.js +2 -1
  192. package/src/spatial/Octree.js +5 -0
  193. package/apps/interactive-door/index.js +0 -33
  194. package/apps/patrol-npc/index.js +0 -37
  195. package/src/connection/QualityMonitor.js +0 -46
  196. package/src/debug/StateInspector.js +0 -42
  197. package/src/index.js +0 -1
  198. package/src/index.server.js +0 -27
  199. package/src/protocol/Codec.js +0 -60
  200. package/src/protocol/SequenceTracker.js +0 -71
  201. package/src/sdk/ClientMessageHandler.js +0 -80
  202. package/src/sdk/client.js +0 -122
  203. package/world/kaira.glb +0 -0
  204. package/world/schwust.glb +0 -0
@@ -0,0 +1,1122 @@
1
+ import * as THREE from 'three'
2
+
3
+ const isMobile = typeof window !== 'undefined' && /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
4
+
5
+ export class MobileControls {
6
+ constructor(options = {}) {
7
+ this.enabled = isMobile || options.forceEnable
8
+ this.responsive = this.calculateResponsiveSizes()
9
+ this.layout = this.calculateLayout()
10
+
11
+ this.options = {
12
+ joystickRadius: this.responsive.joystickRadius,
13
+ joystickPosition: this.layout.moveJoystickPos,
14
+ lookJoystickPosition: this.layout.lookJoystickPos,
15
+ lookJoystickRadius: this.responsive.joystickRadius,
16
+ buttonSize: this.responsive.buttonSize,
17
+ buttonSpacing: this.responsive.spacing,
18
+ deadzone: 0.12,
19
+ movementDeadzone: 0.15,
20
+ rotationSensitivity: 0.003,
21
+ zoomSensitivity: 0.008,
22
+ autoShow: true,
23
+ ...options
24
+ }
25
+
26
+ this.state = {
27
+ move: { x: 0, y: 0 },
28
+ look: { x: 0, y: 0 },
29
+ lookDelta: { yaw: 0, pitch: 0 },
30
+ jump: false,
31
+ shoot: false,
32
+ reload: false,
33
+ sprint: false,
34
+ crouch: false,
35
+ zoom: 0,
36
+ zoomDelta: 0,
37
+ interact: false,
38
+ menu: false
39
+ }
40
+
41
+ this.moveJoystick = {
42
+ active: false,
43
+ startX: 0,
44
+ startY: 0,
45
+ currentX: 0,
46
+ currentY: 0,
47
+ touchId: null,
48
+ centerX: 0,
49
+ centerY: 0,
50
+ maxHoldStart: 0
51
+ }
52
+
53
+ this.lookJoystick = {
54
+ active: false,
55
+ startX: 0,
56
+ startY: 0,
57
+ currentX: 0,
58
+ currentY: 0,
59
+ touchId: null,
60
+ centerX: 0,
61
+ centerY: 0,
62
+ lastX: 0,
63
+ lastY: 0
64
+ }
65
+
66
+ this.pinch = {
67
+ active: false,
68
+ startDist: 0,
69
+ lastDist: 0,
70
+ touchIds: []
71
+ }
72
+
73
+ this.buttons = new Map()
74
+ this.activeButtons = new Map()
75
+ this.interactableTargets = new Map()
76
+ this.initialized = false
77
+
78
+ if (this.enabled) {
79
+ this.createUI()
80
+ this.setupListeners()
81
+ this.initialized = true
82
+ }
83
+ }
84
+
85
+ calculateResponsiveSizes() {
86
+ const w = window.innerWidth
87
+ const h = window.innerHeight
88
+ const minDim = Math.min(w, h)
89
+ const diagonal = Math.sqrt(w * w + h * h)
90
+ const isPortrait = h > w
91
+ const isTablet = diagonal > 600
92
+
93
+ let baseUnit = minDim / 360
94
+ baseUnit = Math.max(0.7, Math.min(1.2, baseUnit))
95
+
96
+ let joystickRadius = 45 * baseUnit
97
+ if (isTablet && isPortrait) joystickRadius *= 1.05
98
+
99
+ let buttonSize = 44 * baseUnit
100
+ let primaryButtonSize = 54 * baseUnit
101
+
102
+ let spacing = Math.max(6, 8 * baseUnit)
103
+ let edgeMargin = Math.max(10, 14 * baseUnit)
104
+ let bottomMargin = Math.max(20, 40 * baseUnit)
105
+ let buttonAreaGap = Math.max(10, 12 * baseUnit)
106
+
107
+ return {
108
+ joystickRadius,
109
+ buttonSize,
110
+ primaryButtonSize,
111
+ spacing,
112
+ edgeMargin,
113
+ bottomMargin,
114
+ buttonAreaGap,
115
+ baseUnit,
116
+ isPortrait,
117
+ isTablet,
118
+ viewport: { w, h, diagonal }
119
+ }
120
+ }
121
+
122
+ calculateLayout() {
123
+ const w = this.responsive.viewport.w
124
+ const h = this.responsive.viewport.h
125
+ const margin = this.responsive.edgeMargin
126
+ const bottomMargin = this.responsive.bottomMargin
127
+ const joystickRadius = this.responsive.joystickRadius
128
+ const joystickDiameter = joystickRadius * 2
129
+ const buttonSize = this.responsive.buttonSize
130
+ const spacing = this.responsive.spacing
131
+
132
+ const moveJoystickPos = {
133
+ x: margin,
134
+ y: -bottomMargin - joystickDiameter / 2
135
+ }
136
+
137
+ const moveLeft = margin + 80
138
+ const moveBottom = bottomMargin + joystickDiameter / 2
139
+
140
+ const buttonAreaWidth = buttonSize * 3 + spacing * 4
141
+ const buttonsRightOffset = Math.max(10, margin)
142
+ const buttonsBottomOffset = bottomMargin + 60
143
+
144
+ const lookRight = Math.min(350, w * 0.25)
145
+ const lookBottom = bottomMargin + joystickDiameter / 2 + 20
146
+
147
+ const lookJoystickPos = {
148
+ x: w - lookRight - joystickDiameter,
149
+ y: -lookBottom
150
+ }
151
+
152
+ return {
153
+ moveJoystickPos,
154
+ lookJoystickPos,
155
+ moveLeft,
156
+ moveBottom,
157
+ lookRight,
158
+ lookBottom,
159
+ buttonsBottomOffset,
160
+ buttonsRightOffset,
161
+ buttonAreaWidth,
162
+ w,
163
+ h
164
+ }
165
+ }
166
+
167
+ createUI() {
168
+ this.container = document.createElement('div')
169
+ this.container.id = 'mobile-controls'
170
+ this.container.style.cssText = `
171
+ position: fixed;
172
+ top: 0;
173
+ left: 0;
174
+ right: 0;
175
+ bottom: 0;
176
+ pointer-events: none;
177
+ z-index: 9999;
178
+ touch-action: none;
179
+ user-select: none;
180
+ -webkit-user-select: none;
181
+ overflow: hidden;
182
+ `
183
+
184
+ this.createStyle()
185
+ this.createMovementJoystick()
186
+ this.createLookJoystick()
187
+ this.createActionButtons()
188
+ this.createZoomControls()
189
+ this.createTopBar()
190
+
191
+ document.body.appendChild(this.container)
192
+ this.updateJoystickPositions()
193
+ }
194
+
195
+ createStyle() {
196
+ const style = document.createElement('style')
197
+ style.id = 'mobile-controls-style'
198
+ style.textContent = `
199
+ @keyframes buttonPulse {
200
+ 0% { transform: scale(1); }
201
+ 50% { transform: scale(0.92); }
202
+ 100% { transform: scale(1); }
203
+ }
204
+ @keyframes joyGlow {
205
+ 0% { box-shadow: 0 0 15px rgba(100, 200, 255, 0.4), inset 0 0 20px rgba(100, 200, 255, 0.1); }
206
+ 100% { box-shadow: 0 0 25px rgba(100, 200, 255, 0.6), inset 0 0 30px rgba(100, 200, 255, 0.2); }
207
+ }
208
+ @keyframes joyGlowLook {
209
+ 0% { box-shadow: 0 0 15px rgba(255, 150, 100, 0.4), inset 0 0 20px rgba(255, 150, 100, 0.1); }
210
+ 100% { box-shadow: 0 0 25px rgba(255, 150, 100, 0.6), inset 0 0 30px rgba(255, 150, 100, 0.2); }
211
+ }
212
+ @keyframes fadeIn {
213
+ from { opacity: 0; transform: scale(0.9); }
214
+ to { opacity: 1; transform: scale(1); }
215
+ }
216
+ .mobile-joystick-container {
217
+ position: absolute;
218
+ pointer-events: auto;
219
+ touch-action: none;
220
+ opacity: 0;
221
+ animation: fadeIn 0.4s ease-out forwards;
222
+ animation-delay: 0.1s;
223
+ }
224
+ .mobile-joystick-base {
225
+ position: absolute;
226
+ width: 100%;
227
+ height: 100%;
228
+ border-radius: 50%;
229
+ background: radial-gradient(circle at 30% 30%, rgba(60, 80, 100, 0.5), rgba(20, 30, 40, 0.7));
230
+ border: 2px solid rgba(150, 200, 255, 0.25);
231
+ box-shadow:
232
+ 0 4px 20px rgba(0, 0, 0, 0.4),
233
+ inset 0 2px 10px rgba(255, 255, 255, 0.05);
234
+ transition: border-color 0.15s, box-shadow 0.15s;
235
+ }
236
+ .mobile-joystick-base.active {
237
+ border-color: rgba(100, 200, 255, 0.6);
238
+ animation: joyGlow 1.5s ease-in-out infinite;
239
+ }
240
+ .mobile-joystick-base.look-active {
241
+ border-color: rgba(255, 180, 100, 0.6);
242
+ animation: joyGlowLook 1.5s ease-in-out infinite;
243
+ }
244
+ .mobile-joystick-knob {
245
+ position: absolute;
246
+ top: 50%;
247
+ left: 50%;
248
+ width: 56px;
249
+ height: 56px;
250
+ border-radius: 50%;
251
+ background: radial-gradient(circle at 35% 35%, rgba(180, 200, 220, 0.6), rgba(100, 130, 160, 0.5));
252
+ border: 2px solid rgba(200, 220, 255, 0.4);
253
+ transform: translate(-50%, -50%);
254
+ box-shadow:
255
+ 0 3px 12px rgba(0, 0, 0, 0.3),
256
+ inset 0 2px 6px rgba(255, 255, 255, 0.2);
257
+ transition: transform 0.05s ease-out, background 0.1s;
258
+ }
259
+ .mobile-joystick-knob.active {
260
+ background: radial-gradient(circle at 35% 35%, rgba(120, 220, 255, 0.7), rgba(60, 150, 200, 0.6));
261
+ border-color: rgba(150, 220, 255, 0.7);
262
+ }
263
+ .mobile-joystick-knob.look-active {
264
+ background: radial-gradient(circle at 35% 35%, rgba(255, 180, 120, 0.7), rgba(200, 120, 60, 0.6));
265
+ border-color: rgba(255, 200, 150, 0.7);
266
+ }
267
+ .mobile-joystick-directions {
268
+ position: absolute;
269
+ width: 100%;
270
+ height: 100%;
271
+ pointer-events: none;
272
+ opacity: 0.3;
273
+ }
274
+ .mobile-joystick-directions span {
275
+ position: absolute;
276
+ font-size: 10px;
277
+ color: rgba(200, 220, 255, 0.6);
278
+ font-weight: 600;
279
+ }
280
+ .mobile-joystick-directions .dir-up { top: 8px; left: 50%; transform: translateX(-50%); }
281
+ .mobile-joystick-directions .dir-down { bottom: 8px; left: 50%; transform: translateX(-50%); }
282
+ .mobile-joystick-directions .dir-left { left: 8px; top: 50%; transform: translateY(-50%); }
283
+ .mobile-joystick-directions .dir-right { right: 8px; top: 50%; transform: translateY(-50%); }
284
+
285
+ .mobile-buttons-container {
286
+ position: absolute;
287
+ display: flex;
288
+ flex-direction: column;
289
+ align-items: flex-end;
290
+ gap: 10px;
291
+ padding: 0;
292
+ pointer-events: auto;
293
+ opacity: 0;
294
+ animation: fadeIn 0.4s ease-out forwards;
295
+ animation-delay: 0.2s;
296
+ }
297
+ .mobile-button-row {
298
+ display: flex;
299
+ gap: 10px;
300
+ align-items: flex-end;
301
+ }
302
+ .mobile-action-btn {
303
+ border-radius: 50%;
304
+ display: flex;
305
+ flex-direction: column;
306
+ align-items: center;
307
+ justify-content: center;
308
+ font-size: 11px;
309
+ font-weight: 700;
310
+ color: rgba(255, 255, 255, 0.9);
311
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
312
+ cursor: pointer;
313
+ transition: all 0.08s ease-out;
314
+ border: 2px solid rgba(200, 220, 255, 0.3);
315
+ box-shadow:
316
+ 0 4px 15px rgba(0, 0, 0, 0.35),
317
+ inset 0 1px 3px rgba(255, 255, 255, 0.15);
318
+ user-select: none;
319
+ -webkit-user-select: none;
320
+ touch-action: none;
321
+ }
322
+ .mobile-action-btn:active, .mobile-action-btn.active {
323
+ transform: scale(0.92);
324
+ border-color: rgba(255, 255, 255, 0.6);
325
+ }
326
+ .mobile-action-btn.primary {
327
+ background: linear-gradient(145deg, rgba(255, 100, 80, 0.7), rgba(200, 60, 50, 0.7));
328
+ }
329
+ .mobile-action-btn.primary:active, .mobile-action-btn.primary.active {
330
+ background: linear-gradient(145deg, rgba(255, 130, 100, 0.85), rgba(230, 80, 70, 0.85));
331
+ box-shadow: 0 2px 20px rgba(255, 100, 80, 0.5), inset 0 1px 5px rgba(255, 255, 255, 0.3);
332
+ }
333
+ .mobile-action-btn.jump {
334
+ background: linear-gradient(145deg, rgba(80, 200, 120, 0.65), rgba(50, 150, 80, 0.65));
335
+ }
336
+ .mobile-action-btn.jump:active, .mobile-action-btn.jump.active {
337
+ background: linear-gradient(145deg, rgba(100, 230, 150, 0.8), rgba(70, 180, 100, 0.8));
338
+ box-shadow: 0 2px 20px rgba(80, 200, 120, 0.4);
339
+ }
340
+ .mobile-action-btn.sprint {
341
+ background: linear-gradient(145deg, rgba(255, 200, 60, 0.65), rgba(200, 150, 30, 0.65));
342
+ }
343
+ .mobile-action-btn.sprint:active, .mobile-action-btn.sprint.active {
344
+ background: linear-gradient(145deg, rgba(255, 220, 100, 0.8), rgba(230, 180, 60, 0.8));
345
+ box-shadow: 0 2px 20px rgba(255, 200, 60, 0.4);
346
+ }
347
+ .mobile-action-btn.crouch {
348
+ background: linear-gradient(145deg, rgba(150, 130, 255, 0.65), rgba(100, 80, 200, 0.65));
349
+ }
350
+ .mobile-action-btn.crouch:active, .mobile-action-btn.crouch.active {
351
+ background: linear-gradient(145deg, rgba(180, 160, 255, 0.8), rgba(130, 110, 230, 0.8));
352
+ box-shadow: 0 2px 20px rgba(150, 130, 255, 0.4);
353
+ }
354
+ .mobile-action-btn.reload {
355
+ background: linear-gradient(145deg, rgba(100, 180, 255, 0.65), rgba(60, 130, 200, 0.65));
356
+ }
357
+ .mobile-action-btn.reload:active, .mobile-action-btn.reload.active {
358
+ background: linear-gradient(145deg, rgba(130, 210, 255, 0.8), rgba(90, 160, 230, 0.8));
359
+ box-shadow: 0 2px 20px rgba(100, 180, 255, 0.4);
360
+ }
361
+ .mobile-action-btn.weapon {
362
+ background: linear-gradient(145deg, rgba(255, 150, 50, 0.65), rgba(200, 100, 30, 0.65));
363
+ }
364
+ .mobile-action-btn.weapon:active, .mobile-action-btn.weapon.active {
365
+ background: linear-gradient(145deg, rgba(255, 180, 100, 0.8), rgba(230, 130, 60, 0.8));
366
+ box-shadow: 0 2px 20px rgba(255, 150, 50, 0.4);
367
+ }
368
+ .mobile-action-btn .btn-icon {
369
+ font-size: 18px;
370
+ line-height: 1;
371
+ }
372
+ .mobile-action-btn .btn-label {
373
+ font-size: 9px;
374
+ opacity: 0.8;
375
+ margin-top: 2px;
376
+ }
377
+ .mobile-action-btn.large {
378
+ }
379
+ .mobile-action-btn.large .btn-icon {
380
+ font-size: 24px;
381
+ }
382
+
383
+ .mobile-zoom-controls {
384
+ position: absolute;
385
+ display: flex;
386
+ flex-direction: row;
387
+ gap: 8px;
388
+ pointer-events: auto;
389
+ opacity: 0;
390
+ animation: fadeIn 0.4s ease-out forwards;
391
+ animation-delay: 0.25s;
392
+ }
393
+ .mobile-zoom-btn {
394
+ border-radius: 12px;
395
+ display: flex;
396
+ align-items: center;
397
+ justify-content: center;
398
+ background: rgba(40, 50, 60, 0.75);
399
+ border: 2px solid rgba(150, 200, 255, 0.25);
400
+ color: rgba(200, 220, 255, 0.8);
401
+ font-size: 20px;
402
+ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
403
+ transition: all 0.1s ease-out;
404
+ }
405
+ .mobile-zoom-btn:active {
406
+ background: rgba(60, 80, 100, 0.85);
407
+ border-color: rgba(150, 200, 255, 0.5);
408
+ transform: scale(0.92);
409
+ }
410
+
411
+ .mobile-top-bar {
412
+ position: absolute;
413
+ top: 0;
414
+ left: 0;
415
+ right: 0;
416
+ height: 48px;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ padding: 0 20px;
421
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.4), transparent);
422
+ pointer-events: none;
423
+ opacity: 0;
424
+ animation: fadeIn 0.4s ease-out forwards;
425
+ }
426
+ .mobile-joystick-label {
427
+ position: absolute;
428
+ bottom: -24px;
429
+ left: 50%;
430
+ transform: translateX(-50%);
431
+ font-size: 10px;
432
+ color: rgba(200, 220, 255, 0.5);
433
+ font-weight: 600;
434
+ text-transform: uppercase;
435
+ letter-spacing: 1px;
436
+ white-space: nowrap;
437
+ }
438
+
439
+ .mobile-interact-btn {
440
+ position: absolute;
441
+ left: 50%;
442
+ transform: translateX(-50%);
443
+ padding: 10px 24px;
444
+ border-radius: 24px;
445
+ background: rgba(80, 200, 150, 0.7);
446
+ border: 2px solid rgba(150, 255, 200, 0.4);
447
+ color: white;
448
+ font-size: 13px;
449
+ font-weight: 600;
450
+ pointer-events: auto;
451
+ opacity: 0;
452
+ animation: fadeIn 0.4s ease-out forwards;
453
+ animation-delay: 0.3s;
454
+ box-shadow: 0 4px 20px rgba(80, 200, 150, 0.3);
455
+ transition: all 0.1s ease-out;
456
+ }
457
+ .mobile-interact-btn:active {
458
+ transform: translateX(-50%) scale(0.95);
459
+ background: rgba(100, 220, 170, 0.85);
460
+ }
461
+ `
462
+ document.head.appendChild(style)
463
+ }
464
+
465
+ createMovementJoystick() {
466
+ const joystickRadius = this.responsive.joystickRadius
467
+ const joystickDiameter = joystickRadius * 2
468
+ const moveLeft = this.layout.moveLeft
469
+ const moveBottom = this.layout.moveBottom
470
+
471
+ this.moveJoystickContainer = document.createElement('div')
472
+ this.moveJoystickContainer.className = 'mobile-joystick-container'
473
+ this.moveJoystickContainer.id = 'move-joystick'
474
+ this.moveJoystickContainer.style.cssText = `
475
+ left: ${moveLeft}px;
476
+ bottom: ${moveBottom}px;
477
+ width: ${joystickDiameter}px;
478
+ height: ${joystickDiameter}px;
479
+ `
480
+
481
+ const base = document.createElement('div')
482
+ base.className = 'mobile-joystick-base'
483
+ base.id = 'move-joystick-base'
484
+
485
+ const directions = document.createElement('div')
486
+ directions.className = 'mobile-joystick-directions'
487
+ directions.innerHTML = `
488
+ <span class="dir-up">W</span>
489
+ <span class="dir-down">S</span>
490
+ <span class="dir-left">A</span>
491
+ <span class="dir-right">D</span>
492
+ `
493
+
494
+ this.moveJoystickKnob = document.createElement('div')
495
+ this.moveJoystickKnob.className = 'mobile-joystick-knob'
496
+ this.moveJoystickKnob.id = 'move-joystick-knob'
497
+
498
+ const label = document.createElement('div')
499
+ label.className = 'mobile-joystick-label'
500
+ label.textContent = 'MOVE'
501
+
502
+ base.appendChild(directions)
503
+ this.moveJoystickContainer.appendChild(base)
504
+ this.moveJoystickContainer.appendChild(this.moveJoystickKnob)
505
+ this.moveJoystickContainer.appendChild(label)
506
+ this.container.appendChild(this.moveJoystickContainer)
507
+
508
+ this.moveJoystick.dynamicPosition = true
509
+ }
510
+
511
+ createLookJoystick() {
512
+ const margin = this.responsive.edgeMargin
513
+ const bottomMargin = this.responsive.bottomMargin
514
+ const joystickRadius = this.responsive.joystickRadius
515
+ const joystickDiameter = joystickRadius * 2
516
+ const lookRight = this.layout.lookRight
517
+ const lookBottom = this.layout.lookBottom
518
+
519
+ this.lookJoystickContainer = document.createElement('div')
520
+ this.lookJoystickContainer.className = 'mobile-joystick-container'
521
+ this.lookJoystickContainer.id = 'look-joystick'
522
+ this.lookJoystickContainer.style.cssText = `
523
+ right: ${lookRight}px;
524
+ bottom: ${lookBottom}px;
525
+ width: ${joystickDiameter}px;
526
+ height: ${joystickDiameter}px;
527
+ `
528
+
529
+ const base = document.createElement('div')
530
+ base.className = 'mobile-joystick-base'
531
+ base.id = 'look-joystick-base'
532
+
533
+ const directions = document.createElement('div')
534
+ directions.className = 'mobile-joystick-directions'
535
+ directions.innerHTML = `
536
+ <span class="dir-up">↑</span>
537
+ <span class="dir-down">↓</span>
538
+ <span class="dir-left">←</span>
539
+ <span class="dir-right">→</span>
540
+ `
541
+
542
+ this.lookJoystickKnob = document.createElement('div')
543
+ this.lookJoystickKnob.className = 'mobile-joystick-knob'
544
+ this.lookJoystickKnob.id = 'look-joystick-knob'
545
+
546
+ const label = document.createElement('div')
547
+ label.className = 'mobile-joystick-label'
548
+ label.textContent = 'LOOK'
549
+
550
+ base.appendChild(directions)
551
+ this.lookJoystickContainer.appendChild(base)
552
+ this.lookJoystickContainer.appendChild(this.lookJoystickKnob)
553
+ this.lookJoystickContainer.appendChild(label)
554
+ this.container.appendChild(this.lookJoystickContainer)
555
+ }
556
+
557
+ createActionButtons() {
558
+ this.buttonsContainer = document.createElement('div')
559
+ this.buttonsContainer.style.cssText = `
560
+ position: absolute;
561
+ bottom: ${this.layout.buttonsBottomOffset}px;
562
+ right: ${this.layout.buttonsRightOffset}px;
563
+ pointer-events: auto;
564
+ z-index: 9999;
565
+ `
566
+
567
+ const diamondContainer = document.createElement('div')
568
+ diamondContainer.style.cssText = `
569
+ display: grid !important;
570
+ grid-template-columns: repeat(3, auto);
571
+ grid-template-rows: repeat(3, auto);
572
+ gap: 12px;
573
+ align-items: center;
574
+ justify-items: center;
575
+ width: auto;
576
+ height: auto;
577
+ `
578
+
579
+ const jumpBtn = this.createActionButton('jump', 'A', 'JUMP', 'jump')
580
+ jumpBtn.style.cssText += '; grid-column: 2; grid-row: 3;'
581
+
582
+ const crouchBtn = this.createActionButton('crouch', 'X', 'CROUCH', 'crouch')
583
+ crouchBtn.style.cssText += '; grid-column: 1; grid-row: 2;'
584
+
585
+ const shootBtn = this.createActionButton('shoot', 'B', 'SHOOT', 'weapon')
586
+ shootBtn.style.cssText += '; grid-column: 3; grid-row: 2;'
587
+
588
+ const useBtn = this.createActionButton('use', 'Y', 'RELOAD', 'reload', 'reload')
589
+ useBtn.style.cssText += '; grid-column: 2; grid-row: 1;'
590
+ this.useBtn = useBtn
591
+
592
+ diamondContainer.appendChild(crouchBtn)
593
+ diamondContainer.appendChild(jumpBtn)
594
+ diamondContainer.appendChild(useBtn)
595
+ diamondContainer.appendChild(shootBtn)
596
+
597
+ this.buttonsContainer.appendChild(diamondContainer)
598
+ this.container.appendChild(this.buttonsContainer)
599
+ }
600
+
601
+ updateUseButton() {
602
+ const hasInteractables = this.interactableTargets.size > 0
603
+ if (!this.useBtn) return
604
+
605
+ const label = hasInteractables ? 'USE' : 'RELOAD'
606
+ const action = hasInteractables ? 'interact' : 'reload'
607
+
608
+ this.useBtn.dataset.action = action
609
+ this.useBtn.className = `mobile-action-btn ${action}`
610
+ const labelSpan = this.useBtn.querySelector('.btn-label')
611
+ if (labelSpan) labelSpan.textContent = label
612
+ }
613
+
614
+ registerInteractable(id, label = 'INTERACT') {
615
+ if (this.interactableTargets.has(id)) return
616
+ this.interactableTargets.set(id, label)
617
+ this.updateUseButton()
618
+ }
619
+
620
+ unregisterInteractable(id) {
621
+ this.interactableTargets.delete(id)
622
+ this.updateUseButton()
623
+ }
624
+
625
+ createActionButton(id, icon, label, className, action) {
626
+ const btn = document.createElement('div')
627
+ btn.className = `mobile-action-btn ${className}`
628
+ btn.dataset.action = action || id
629
+
630
+ const isPrimary = className.includes('primary')
631
+ const size = isPrimary ? this.responsive.primaryButtonSize : this.responsive.buttonSize
632
+ btn.style.cssText = `width: ${size}px; height: ${size}px;`
633
+
634
+ const iconSpan = document.createElement('span')
635
+ iconSpan.className = 'btn-icon'
636
+ iconSpan.textContent = icon
637
+
638
+ const labelSpan = document.createElement('span')
639
+ labelSpan.className = 'btn-label'
640
+ labelSpan.textContent = label
641
+
642
+ btn.appendChild(iconSpan)
643
+ btn.appendChild(labelSpan)
644
+
645
+ this.buttons.set(id, btn)
646
+ return btn
647
+ }
648
+
649
+ createZoomControls() {
650
+ this.zoomContainer = document.createElement('div')
651
+ this.zoomContainer.className = 'mobile-zoom-controls'
652
+ const zoomRight = this.layout.lookRight + this.responsive.joystickRadius
653
+ this.zoomContainer.style.cssText = `bottom: ${this.responsive.bottomMargin}px; right: ${zoomRight}px; transform: translateX(50%);`
654
+
655
+ const zoomSize = Math.max(40, this.responsive.buttonSize * 0.8)
656
+
657
+ const zoomInBtn = document.createElement('div')
658
+ zoomInBtn.className = 'mobile-zoom-btn'
659
+ zoomInBtn.textContent = '+'
660
+ zoomInBtn.dataset.action = 'zoomIn'
661
+ zoomInBtn.style.cssText = `width: ${zoomSize}px; height: ${zoomSize}px;`
662
+
663
+ const zoomOutBtn = document.createElement('div')
664
+ zoomOutBtn.className = 'mobile-zoom-btn'
665
+ zoomOutBtn.textContent = '−'
666
+ zoomOutBtn.dataset.action = 'zoomOut'
667
+ zoomOutBtn.style.cssText = `width: ${zoomSize}px; height: ${zoomSize}px;`
668
+
669
+ this.zoomContainer.appendChild(zoomInBtn)
670
+ this.zoomContainer.appendChild(zoomOutBtn)
671
+ this.container.appendChild(this.zoomContainer)
672
+
673
+ this.zoomButtons = { zoomIn: zoomInBtn, zoomOut: zoomOutBtn }
674
+ this.buttons.set('zoomIn', zoomInBtn)
675
+ this.buttons.set('zoomOut', zoomOutBtn)
676
+ }
677
+
678
+ createTopBar() {
679
+ this.topBar = document.createElement('div')
680
+ this.topBar.className = 'mobile-top-bar'
681
+ this.container.appendChild(this.topBar)
682
+ }
683
+
684
+ updateJoystickPositions() {
685
+ this.responsive = this.calculateResponsiveSizes()
686
+ this.layout = this.calculateLayout()
687
+
688
+ const screenHeight = this.layout.h
689
+ const screenWidth = this.layout.w
690
+ const joystickRadius = this.responsive.joystickRadius
691
+ const joystickDiameter = joystickRadius * 2
692
+
693
+ const moveLeft = this.layout.moveLeft
694
+ const moveBottom = this.layout.moveBottom
695
+ const lookBottom = this.layout.lookBottom
696
+ const lookRight = this.layout.lookRight
697
+
698
+ if (this.moveJoystickContainer) {
699
+ this.moveJoystickContainer.style.left = `${moveLeft}px`
700
+ this.moveJoystickContainer.style.bottom = `${moveBottom}px`
701
+ this.moveJoystickContainer.style.width = `${joystickDiameter}px`
702
+ this.moveJoystickContainer.style.height = `${joystickDiameter}px`
703
+ }
704
+
705
+ if (this.lookJoystickContainer) {
706
+ this.lookJoystickContainer.style.right = `${lookRight}px`
707
+ this.lookJoystickContainer.style.bottom = `${lookBottom}px`
708
+ this.lookJoystickContainer.style.width = `${joystickDiameter}px`
709
+ this.lookJoystickContainer.style.height = `${joystickDiameter}px`
710
+ }
711
+
712
+ if (this.buttonsContainer) {
713
+ this.buttonsContainer.style.bottom = `${this.layout.buttonsBottomOffset}px`
714
+ this.buttonsContainer.style.right = `${this.layout.buttonsRightOffset}px`
715
+ }
716
+
717
+ if (this.zoomContainer) {
718
+ const zoomRight = this.layout.lookRight + this.responsive.joystickRadius
719
+ this.zoomContainer.style.bottom = `${this.responsive.bottomMargin}px`
720
+ this.zoomContainer.style.right = `${zoomRight}px`
721
+ this.zoomContainer.style.transform = 'translateX(50%)'
722
+ }
723
+
724
+ this.moveJoystick.centerX = moveLeft + joystickRadius
725
+ this.moveJoystick.centerY = screenHeight - moveBottom - joystickRadius
726
+
727
+ this.lookJoystick.centerX = screenWidth - lookRight - joystickRadius
728
+ this.lookJoystick.centerY = screenHeight - lookBottom - joystickRadius
729
+ }
730
+
731
+ setupListeners() {
732
+ document.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false })
733
+ document.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false })
734
+ document.addEventListener('touchend', this.onTouchEnd.bind(this), { passive: false })
735
+ document.addEventListener('touchcancel', this.onTouchEnd.bind(this), { passive: false })
736
+
737
+ window.addEventListener('resize', () => {
738
+ this.updateJoystickPositions()
739
+ })
740
+ window.addEventListener('orientationchange', () => {
741
+ setTimeout(() => this.updateJoystickPositions(), 100)
742
+ })
743
+ }
744
+
745
+ isTouchOnMoveJoystick(x, y) {
746
+ const screenWidth = window.innerWidth
747
+ const screenHeight = window.innerHeight
748
+ // Left half of screen, excluding top area for UI
749
+ // Only check button overlap if touch is on the right side (where buttons are)
750
+ if (x >= screenWidth / 2 && this.getButtonAtPosition(x, y)) return false
751
+ return x < screenWidth / 2 && y > screenHeight * 0.3
752
+ }
753
+
754
+ isTouchOnLookJoystick(x, y) {
755
+ const screenWidth = window.innerWidth
756
+ const screenHeight = window.innerHeight
757
+ // Only activate look joystick if not touching a button
758
+ if (this.getButtonAtPosition(x, y)) return false
759
+ return x >= screenWidth / 2 && y > screenHeight * 0.3
760
+ }
761
+
762
+ getButtonAtPosition(x, y) {
763
+ const checkButton = (btn) => {
764
+ const rect = btn.getBoundingClientRect()
765
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
766
+ }
767
+
768
+ for (const [id, btn] of this.buttons) {
769
+ if (checkButton(btn)) return id
770
+ }
771
+
772
+ return null
773
+ }
774
+
775
+ onTouchStart(e) {
776
+ if (!this.enabled) return
777
+
778
+ for (const touch of e.changedTouches) {
779
+ const x = touch.clientX
780
+ const y = touch.clientY
781
+
782
+ if (this.isTouchOnMoveJoystick(x, y)) {
783
+ this.moveJoystick.active = true
784
+ this.moveJoystick.touchId = touch.identifier
785
+ this.moveJoystick.startX = x
786
+ this.moveJoystick.startY = y
787
+ this.moveJoystick.currentX = x
788
+ this.moveJoystick.currentY = y
789
+
790
+ // Dynamic positioning - move joystick to touch location
791
+ const joystickRadius = this.options.joystickRadius || 45
792
+ this.moveJoystickContainer.style.left = `${x - joystickRadius}px`
793
+ this.moveJoystickContainer.style.top = `${y - joystickRadius}px`
794
+ this.moveJoystickContainer.style.bottom = 'auto'
795
+
796
+ this.moveJoystick.centerX = x
797
+ this.moveJoystick.centerY = y
798
+
799
+ document.getElementById('move-joystick-base')?.classList.add('active')
800
+ this.moveJoystickKnob.classList.add('active')
801
+ if (e.cancelable) e.preventDefault()
802
+ continue
803
+ }
804
+
805
+ if (this.isTouchOnLookJoystick(x, y)) {
806
+ this.lookJoystick.active = true
807
+ this.lookJoystick.touchId = touch.identifier
808
+ this.lookJoystick.startX = x
809
+ this.lookJoystick.startY = y
810
+ this.lookJoystick.currentX = x
811
+ this.lookJoystick.currentY = y
812
+ this.lookJoystick.lastX = x
813
+ this.lookJoystick.lastY = y
814
+
815
+ const rect = this.lookJoystickContainer.getBoundingClientRect()
816
+ this.lookJoystick.centerX = rect.left + rect.width / 2
817
+ this.lookJoystick.centerY = rect.top + rect.height / 2
818
+
819
+ document.getElementById('look-joystick-base')?.classList.add('look-active')
820
+ this.lookJoystickKnob.classList.add('look-active')
821
+ if (e.cancelable) e.preventDefault()
822
+ continue
823
+ }
824
+
825
+ const buttonId = this.getButtonAtPosition(x, y)
826
+ if (buttonId) {
827
+ this.activeButtons.set(touch.identifier, buttonId)
828
+ const btn = this.buttons.get(buttonId)
829
+ const action = btn?.dataset?.action || buttonId
830
+
831
+ if (buttonId === 'zoomIn') {
832
+ this.state.zoomDelta = 1
833
+ } else if (buttonId === 'zoomOut') {
834
+ this.state.zoomDelta = -1
835
+ } else {
836
+ this.state[action] = true
837
+ }
838
+ if (btn) btn.classList.add('active')
839
+ if (e.cancelable) e.preventDefault()
840
+ continue
841
+ }
842
+
843
+ if (!this.moveJoystick.active && !this.pinch.active) {
844
+ if (this.lookJoystick.active && e.touches.length >= 2) {
845
+ const secondTouch = Array.from(e.touches).find(t => t.identifier !== this.lookJoystick.touchId)
846
+ if (secondTouch) {
847
+ this.pinch.active = true
848
+ this.pinch.touchIds = [this.lookJoystick.touchId, secondTouch.identifier]
849
+ this.pinch.startDist = this.getPinchDistance(e.touches)
850
+ this.pinch.lastDist = this.pinch.startDist
851
+ this.lookJoystick.active = false
852
+ this.lookJoystick.touchId = null
853
+ document.getElementById('look-joystick-base')?.classList.remove('look-active')
854
+ this.lookJoystickKnob.classList.remove('look-active')
855
+ this.lookJoystickKnob.style.transform = 'translate(-50%, -50%)'
856
+ }
857
+ } else if (!this.lookJoystick.active) {
858
+ if (e.touches.length >= 2) {
859
+ const otherTouch = Array.from(e.touches).find(t => t.identifier !== touch.identifier)
860
+ if (otherTouch) {
861
+ this.pinch.active = true
862
+ this.pinch.touchIds = [touch.identifier, otherTouch.identifier]
863
+ this.pinch.startDist = this.getPinchDistance(e.touches)
864
+ this.pinch.lastDist = this.pinch.startDist
865
+ }
866
+ } else {
867
+ this.lookJoystick.active = true
868
+ this.lookJoystick.touchId = touch.identifier
869
+ this.lookJoystick.startX = x
870
+ this.lookJoystick.startY = y
871
+ this.lookJoystick.lastX = x
872
+ this.lookJoystick.lastY = y
873
+ this.lookJoystick.centerX = x
874
+ this.lookJoystick.centerY = y
875
+
876
+ document.getElementById('look-joystick-base')?.classList.add('look-active')
877
+ this.lookJoystickKnob.classList.add('look-active')
878
+ }
879
+ }
880
+ }
881
+ }
882
+ }
883
+
884
+ onTouchMove(e) {
885
+ if (!this.enabled) return
886
+
887
+ for (const touch of e.changedTouches) {
888
+ const x = touch.clientX
889
+ const y = touch.clientY
890
+
891
+ if (this.moveJoystick.active && touch.identifier === this.moveJoystick.touchId) {
892
+ this.moveJoystick.currentX = x
893
+ this.moveJoystick.currentY = y
894
+
895
+ let dx = x - this.moveJoystick.centerX
896
+ let dy = y - this.moveJoystick.centerY
897
+ const dist = Math.sqrt(dx * dx + dy * dy)
898
+ const maxDist = this.options.joystickRadius
899
+
900
+ const normalizedDist = Math.min(dist / maxDist, 1)
901
+ if (normalizedDist > 0.85) {
902
+ if (!this.moveJoystick.maxHoldStart) {
903
+ this.moveJoystick.maxHoldStart = Date.now()
904
+ } else if (Date.now() - this.moveJoystick.maxHoldStart > 420) {
905
+ this.state.sprint = true
906
+ }
907
+ } else {
908
+ this.moveJoystick.maxHoldStart = 0
909
+ this.state.sprint = false
910
+ }
911
+
912
+ if (dist > maxDist) {
913
+ dx = (dx / dist) * maxDist
914
+ dy = (dy / dist) * maxDist
915
+ }
916
+
917
+ this.moveJoystickKnob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`
918
+
919
+ const deadzone = this.options.movementDeadzone
920
+ if (normalizedDist < deadzone) {
921
+ this.state.move.x = 0
922
+ this.state.move.y = 0
923
+ } else {
924
+ const scale = (normalizedDist - deadzone) / (1 - deadzone)
925
+ this.state.move.x = (dx / maxDist) * scale
926
+ this.state.move.y = (dy / maxDist) * scale
927
+ }
928
+ if (e.cancelable) e.preventDefault()
929
+ }
930
+
931
+ if (this.lookJoystick.active && touch.identifier === this.lookJoystick.touchId) {
932
+ const dx = x - this.lookJoystick.lastX
933
+ const dy = y - this.lookJoystick.lastY
934
+
935
+ this.state.lookDelta.yaw -= dx * this.options.rotationSensitivity
936
+ this.state.lookDelta.pitch -= dy * this.options.rotationSensitivity
937
+ this.state.lookDelta.pitch = Math.max(-Math.PI / 2.5, Math.min(Math.PI / 2.5, this.state.lookDelta.pitch))
938
+
939
+ let lx = x - this.lookJoystick.centerX
940
+ let ly = y - this.lookJoystick.centerY
941
+ const lookDist = Math.sqrt(lx * lx + ly * ly)
942
+ const lookMaxDist = this.options.lookJoystickRadius
943
+
944
+ if (lookDist > lookMaxDist) {
945
+ lx = (lx / lookDist) * lookMaxDist
946
+ ly = (ly / lookDist) * lookMaxDist
947
+ }
948
+
949
+ this.lookJoystickKnob.style.transform = `translate(calc(-50% + ${lx}px), calc(-50% + ${ly}px))`
950
+
951
+ this.lookJoystick.lastX = x
952
+ this.lookJoystick.lastY = y
953
+ if (e.cancelable) e.preventDefault()
954
+ }
955
+ }
956
+
957
+ if (this.pinch.active && e.touches.length >= 2) {
958
+ const dist = this.getPinchDistance(e.touches)
959
+ const delta = dist - this.pinch.lastDist
960
+ if (Math.abs(delta) > 5) {
961
+ this.state.zoomDelta = delta > 0 ? 1 : -1
962
+ }
963
+ this.pinch.lastDist = dist
964
+ if (e.cancelable) e.preventDefault()
965
+ }
966
+
967
+ for (const [touchId, buttonId] of this.activeButtons) {
968
+ if (buttonId === 'zoomIn') {
969
+ this.state.zoomDelta = 1
970
+ } else if (buttonId === 'zoomOut') {
971
+ this.state.zoomDelta = -1
972
+ }
973
+ }
974
+ }
975
+
976
+ onTouchEnd(e) {
977
+ if (!this.enabled) return
978
+
979
+ for (const touch of e.changedTouches) {
980
+ if (this.moveJoystick.active && touch.identifier === this.moveJoystick.touchId) {
981
+ this.moveJoystick.active = false
982
+ this.moveJoystick.touchId = null
983
+ this.moveJoystick.maxHoldStart = 0
984
+ this.state.move.x = 0
985
+ this.state.move.y = 0
986
+ this.state.sprint = false
987
+ this.moveJoystickKnob.style.transform = 'translate(-50%, -50%)'
988
+ this.moveJoystickKnob.classList.remove('active')
989
+ document.getElementById('move-joystick-base')?.classList.remove('active')
990
+ // Reset position to default
991
+ this.moveJoystickContainer.style.left = `${this.layout.moveLeft}px`
992
+ this.moveJoystickContainer.style.bottom = `${this.layout.moveBottom}px`
993
+ this.moveJoystickContainer.style.top = 'auto'
994
+ }
995
+
996
+ if (this.lookJoystick.active && touch.identifier === this.lookJoystick.touchId) {
997
+ this.lookJoystick.active = false
998
+ this.lookJoystick.touchId = null
999
+ this.lookJoystickKnob.style.transform = 'translate(-50%, -50%)'
1000
+ this.lookJoystickKnob.classList.remove('active')
1001
+ document.getElementById('look-joystick-base')?.classList.remove('look-active')
1002
+ }
1003
+
1004
+ if (this.pinch.active) {
1005
+ const idx = this.pinch.touchIds.indexOf(touch.identifier)
1006
+ if (idx !== -1) {
1007
+ this.pinch.touchIds.splice(idx, 1)
1008
+ }
1009
+ if (this.pinch.touchIds.length < 2) {
1010
+ this.pinch.active = false
1011
+ }
1012
+ }
1013
+
1014
+ const activeButton = this.activeButtons.get(touch.identifier)
1015
+ if (activeButton) {
1016
+ const btn = this.buttons.get(activeButton)
1017
+ const action = btn?.dataset?.action || activeButton
1018
+ this.state[action] = false
1019
+ if (btn) btn.classList.remove('active')
1020
+ this.activeButtons.delete(touch.identifier)
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ getPinchDistance(touches) {
1026
+ const touchArray = Array.from(touches)
1027
+ if (touchArray.length < 2) return 0
1028
+ const dx = touchArray[0].clientX - touchArray[1].clientX
1029
+ const dy = touchArray[0].clientY - touchArray[1].clientY
1030
+ return Math.sqrt(dx * dx + dy * dy)
1031
+ }
1032
+
1033
+ getInput() {
1034
+ if (!this.enabled) return null
1035
+
1036
+ const move = this.state.move
1037
+ const deadzone = 0.3
1038
+
1039
+ return {
1040
+ forward: move.y < -deadzone,
1041
+ backward: move.y > deadzone,
1042
+ left: move.x < -deadzone,
1043
+ right: move.x > deadzone,
1044
+ jump: this.state.jump,
1045
+ shoot: this.state.shoot,
1046
+ reload: this.state.reload,
1047
+ sprint: this.state.sprint,
1048
+ crouch: this.state.crouch,
1049
+ yaw: this.state.lookDelta.yaw,
1050
+ pitch: this.state.lookDelta.pitch,
1051
+ zoom: this.state.zoomDelta,
1052
+ resetZoom: () => { this.state.zoomDelta = 0 },
1053
+ moveX: move.x,
1054
+ moveY: move.y,
1055
+ mouseX: 0,
1056
+ mouseY: 0,
1057
+ interact: this.state.interact,
1058
+ analogForward: move.y,
1059
+ analogRight: move.x
1060
+ }
1061
+ }
1062
+
1063
+ hasInteraction() {
1064
+ return this.moveJoystick.active ||
1065
+ this.lookJoystick.active ||
1066
+ this.pinch.active ||
1067
+ this.state.jump ||
1068
+ this.state.shoot ||
1069
+ this.state.reload ||
1070
+ this.state.sprint ||
1071
+ this.state.crouch ||
1072
+ this.state.interact ||
1073
+ this.state.zoomDelta !== 0
1074
+ }
1075
+
1076
+ resetLookDelta() {
1077
+ this.state.lookDelta.yaw = 0
1078
+ this.state.lookDelta.pitch = 0
1079
+ }
1080
+
1081
+ setEnabled(enabled) {
1082
+ this.enabled = enabled && (isMobile || this.options.forceEnable)
1083
+ if (this.container) {
1084
+ this.container.style.display = this.enabled ? 'block' : 'none'
1085
+ }
1086
+ }
1087
+
1088
+ show() {
1089
+ if (this.container) {
1090
+ this.container.style.display = 'block'
1091
+ }
1092
+ }
1093
+
1094
+ hide() {
1095
+ if (this.container) {
1096
+ this.container.style.display = 'none'
1097
+ }
1098
+ }
1099
+
1100
+ dispose() {
1101
+ if (this.container) {
1102
+ this.container.remove()
1103
+ this.container = null
1104
+ }
1105
+ const style = document.getElementById('mobile-controls-style')
1106
+ if (style) style.remove()
1107
+
1108
+ document.removeEventListener('touchstart', this.onTouchStart)
1109
+ document.removeEventListener('touchmove', this.onTouchMove)
1110
+ document.removeEventListener('touchend', this.onTouchEnd)
1111
+ document.removeEventListener('touchcancel', this.onTouchEnd)
1112
+ window.removeEventListener('resize', this.updateJoystickPositions)
1113
+ }
1114
+ }
1115
+
1116
+ export function detectDevice() {
1117
+ return {
1118
+ isMobile,
1119
+ isDesktop: !isMobile,
1120
+ hasGamepad: typeof navigator !== 'undefined' && 'getGamepads' in navigator
1121
+ }
1122
+ }