@rpg-engine/long-bow 0.7.98 → 0.7.99

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.7.98",
3
+ "version": "0.7.99",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
2
2
  import styled from 'styled-components';
3
3
 
4
4
  interface IDPadContainerProps {
@@ -8,6 +8,12 @@ interface IDPadContainerProps {
8
8
  disabled?: boolean;
9
9
  }
10
10
 
11
+ interface IDPadButtonProps {
12
+ size?: number;
13
+ isPressed?: boolean;
14
+ disabled?: boolean;
15
+ }
16
+
11
17
  interface IDPadOptions {
12
18
  /** Opacity of the entire component (0-1) */
13
19
  opacity?: number;
@@ -28,173 +34,8 @@ interface IDPadProps {
28
34
  options?: IDPadOptions;
29
35
  }
30
36
 
31
- export const JoystickDPad = ({
32
- onDirectionPress,
33
- disabled = false,
34
- options = {},
35
- }: IDPadProps): JSX.Element => {
36
- const {
37
- opacity = 1,
38
- showBackground = false,
39
- size = 100,
40
- pressInterval = 500,
41
- } = options;
42
-
43
- const [pressedButtons, setPressedButtons] = useState<Set<string>>(new Set());
44
- const intervalRef = useRef<number | null>(null);
45
- const activeDirectionRef = useRef<'up' | 'down' | 'left' | 'right' | null>(
46
- null
47
- );
48
-
49
- const clearPressInterval = useCallback(() => {
50
- if (intervalRef.current) {
51
- window.clearInterval(intervalRef.current);
52
- intervalRef.current = null;
53
- }
54
- activeDirectionRef.current = null;
55
- }, []);
56
-
57
- const handleDirectionPress = useCallback(
58
- (direction: 'up' | 'down' | 'left' | 'right') => {
59
- if (disabled) return;
60
-
61
- // Clear any existing interval
62
- clearPressInterval();
63
-
64
- // Set the active direction
65
- activeDirectionRef.current = direction;
66
- setPressedButtons(prev => new Set(prev).add(direction));
67
-
68
- // Trigger first press immediately
69
- onDirectionPress?.(direction);
70
-
71
- // Set up the interval for continuous press
72
- intervalRef.current = window.setInterval(() => {
73
- if (activeDirectionRef.current === direction) {
74
- onDirectionPress?.(direction);
75
- }
76
- }, pressInterval);
77
- },
78
- [disabled, onDirectionPress, pressInterval, clearPressInterval]
79
- );
80
-
81
- const handleDirectionRelease = useCallback(
82
- (direction: 'up' | 'down' | 'left' | 'right') => {
83
- setPressedButtons(prev => {
84
- const next = new Set(prev);
85
- next.delete(direction);
86
- return next;
87
- });
88
-
89
- if (activeDirectionRef.current === direction) {
90
- clearPressInterval();
91
- }
92
- },
93
- [clearPressInterval]
94
- );
95
-
96
- // Cleanup on unmount
97
- useEffect(() => {
98
- return () => {
99
- clearPressInterval();
100
- };
101
- }, [clearPressInterval]);
102
-
103
- const preventDefault = (e: React.MouseEvent | React.TouchEvent) => {
104
- e.preventDefault();
105
- e.stopPropagation();
106
- };
107
-
108
- return (
109
- <DPadContainer
110
- opacity={opacity}
111
- showBackground={showBackground}
112
- size={size}
113
- disabled={disabled}
114
- onContextMenu={preventDefault}
115
- >
116
- <DPadButton
117
- className="up"
118
- onMouseDown={() => handleDirectionPress('up')}
119
- onMouseUp={() => handleDirectionRelease('up')}
120
- onMouseLeave={() => handleDirectionRelease('up')}
121
- onTouchStart={() => handleDirectionPress('up')}
122
- onTouchEnd={() => handleDirectionRelease('up')}
123
- onContextMenu={preventDefault}
124
- size={size}
125
- isPressed={pressedButtons.has('up')}
126
- disabled={disabled}
127
- />
128
- <DPadButton
129
- className="right"
130
- onMouseDown={() => handleDirectionPress('right')}
131
- onMouseUp={() => handleDirectionRelease('right')}
132
- onMouseLeave={() => handleDirectionRelease('right')}
133
- onTouchStart={() => handleDirectionPress('right')}
134
- onTouchEnd={() => handleDirectionRelease('right')}
135
- onContextMenu={preventDefault}
136
- size={size}
137
- isPressed={pressedButtons.has('right')}
138
- disabled={disabled}
139
- />
140
- <DPadButton
141
- className="down"
142
- onMouseDown={() => handleDirectionPress('down')}
143
- onMouseUp={() => handleDirectionRelease('down')}
144
- onMouseLeave={() => handleDirectionRelease('down')}
145
- onTouchStart={() => handleDirectionPress('down')}
146
- onTouchEnd={() => handleDirectionRelease('down')}
147
- onContextMenu={preventDefault}
148
- size={size}
149
- isPressed={pressedButtons.has('down')}
150
- disabled={disabled}
151
- />
152
- <DPadButton
153
- className="left"
154
- onMouseDown={() => handleDirectionPress('left')}
155
- onMouseUp={() => handleDirectionRelease('left')}
156
- onMouseLeave={() => handleDirectionRelease('left')}
157
- onTouchStart={() => handleDirectionPress('left')}
158
- onTouchEnd={() => handleDirectionRelease('left')}
159
- onContextMenu={preventDefault}
160
- size={size}
161
- isPressed={pressedButtons.has('left')}
162
- disabled={disabled}
163
- />
164
- <DPadCenter
165
- size={size}
166
- disabled={disabled}
167
- onContextMenu={preventDefault}
168
- />
169
- </DPadContainer>
170
- );
171
- };
172
-
173
- const DPadContainer = styled.div<IDPadContainerProps>`
174
- width: ${props => props.size ?? 100}px;
175
- height: ${props => props.size ?? 100}px;
176
- position: relative;
177
- background: ${props => (props.showBackground ? '#b8b8b8' : 'transparent')};
178
- border-radius: 50%;
179
- box-shadow: ${props =>
180
- props.showBackground
181
- ? 'inset 0 0 10px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2)'
182
- : 'none'};
183
- opacity: ${props => (props.disabled ? 0.5 : props.opacity ?? 1)};
184
- user-select: none;
185
- cursor: ${props => (props.disabled ? 'not-allowed' : 'default')};
186
- transition: opacity 0.2s ease;
187
- touch-action: none;
188
- -webkit-tap-highlight-color: transparent;
189
- `;
190
-
191
- interface IDPadButtonProps {
192
- size?: number;
193
- isPressed?: boolean;
194
- disabled?: boolean;
195
- }
196
-
197
- const DPadButton = styled.div<IDPadButtonProps>`
37
+ // Memoize the styled components since they don't depend on props that change frequently
38
+ const DPadButton = memo(styled.div<IDPadButtonProps>`
198
39
  position: absolute;
199
40
  background: ${props => (props.isPressed ? '#363636' : '#424242')};
200
41
  box-shadow: ${props =>
@@ -285,9 +126,9 @@ const DPadButton = styled.div<IDPadButtonProps>`
285
126
  transform: translate(50%, -50%);
286
127
  }
287
128
  }
288
- `;
129
+ `);
289
130
 
290
- const DPadCenter = styled.div<IDPadButtonProps>`
131
+ const DPadCenter = memo(styled.div<IDPadButtonProps>`
291
132
  position: absolute;
292
133
  width: ${props => (props.size ?? 100) * 0.3}px;
293
134
  height: ${props => (props.size ?? 100) * 0.3}px;
@@ -315,4 +156,262 @@ const DPadCenter = styled.div<IDPadButtonProps>`
315
156
  pointer-events: none;
316
157
  box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.8);
317
158
  }
318
- `;
159
+ `);
160
+
161
+ const DPadContainer = memo(styled.div<IDPadContainerProps>`
162
+ width: ${props => props.size ?? 100}px;
163
+ height: ${props => props.size ?? 100}px;
164
+ position: relative;
165
+ background: ${props => (props.showBackground ? '#b8b8b8' : 'transparent')};
166
+ border-radius: 50%;
167
+ box-shadow: ${props =>
168
+ props.showBackground
169
+ ? 'inset 0 0 10px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2)'
170
+ : 'none'};
171
+ opacity: ${props => (props.disabled ? 0.5 : props.opacity ?? 1)};
172
+ user-select: none;
173
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'default')};
174
+ transition: opacity 0.2s ease;
175
+ touch-action: none;
176
+ -webkit-tap-highlight-color: transparent;
177
+ `);
178
+
179
+ export const JoystickDPad = memo(
180
+ ({
181
+ onDirectionPress,
182
+ disabled = false,
183
+ options = {},
184
+ }: IDPadProps): JSX.Element => {
185
+ const {
186
+ opacity = 1,
187
+ showBackground = false,
188
+ size = 100,
189
+ pressInterval = 500,
190
+ } = options;
191
+
192
+ // Use refs for values that don't need to trigger re-renders
193
+ const [pressedButtons, setPressedButtons] = useState<Set<string>>(
194
+ new Set()
195
+ );
196
+ const intervalRef = useRef<number | null>(null);
197
+ const activeDirectionRef = useRef<'up' | 'down' | 'left' | 'right' | null>(
198
+ null
199
+ );
200
+ const touchStartRef = useRef<{ x: number; y: number } | null>(null);
201
+ const isPressedRef = useRef<boolean>(false);
202
+
203
+ const clearPressInterval = useCallback(() => {
204
+ if (intervalRef.current !== null) {
205
+ window.clearInterval(intervalRef.current);
206
+ intervalRef.current = null;
207
+ }
208
+ activeDirectionRef.current = null;
209
+ }, []);
210
+
211
+ const clearAllPresses = useCallback(() => {
212
+ clearPressInterval();
213
+ setPressedButtons(new Set());
214
+ activeDirectionRef.current = null;
215
+ isPressedRef.current = false;
216
+ }, [clearPressInterval]);
217
+
218
+ const handleDirectionPress = useCallback(
219
+ (direction: 'up' | 'down' | 'left' | 'right') => {
220
+ if (disabled) return;
221
+
222
+ // Clear any existing presses first
223
+ clearAllPresses();
224
+
225
+ // Set new direction
226
+ activeDirectionRef.current = direction;
227
+ isPressedRef.current = true;
228
+ setPressedButtons(new Set([direction]));
229
+ onDirectionPress?.(direction);
230
+
231
+ intervalRef.current = window.setInterval(() => {
232
+ if (activeDirectionRef.current === direction) {
233
+ onDirectionPress?.(direction);
234
+ } else {
235
+ clearPressInterval();
236
+ }
237
+ }, pressInterval);
238
+ },
239
+ [
240
+ disabled,
241
+ onDirectionPress,
242
+ pressInterval,
243
+ clearPressInterval,
244
+ clearAllPresses,
245
+ ]
246
+ );
247
+
248
+ const handleDirectionRelease = useCallback(
249
+ (direction: 'up' | 'down' | 'left' | 'right') => {
250
+ if (activeDirectionRef.current === direction) {
251
+ clearAllPresses();
252
+ }
253
+ },
254
+ [clearAllPresses]
255
+ );
256
+
257
+ const handleTouchStart = useCallback(
258
+ (e: React.TouchEvent, direction: 'up' | 'down' | 'left' | 'right') => {
259
+ const touch = e.touches[0];
260
+ if (touch) {
261
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
262
+ handleDirectionPress(direction);
263
+ }
264
+ },
265
+ [handleDirectionPress]
266
+ );
267
+
268
+ const handleTouchMove = useCallback(
269
+ (e: React.TouchEvent) => {
270
+ const touch = e.touches[0];
271
+ if (!touch || !touchStartRef.current) return;
272
+
273
+ const { x: startX, y: startY } = touchStartRef.current;
274
+ const deltaX = touch.clientX - startX;
275
+ const deltaY = touch.clientY - startY;
276
+
277
+ // Calculate angle and distance
278
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
279
+ const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
280
+
281
+ // Only trigger if we've moved enough
282
+ const threshold = size * 0.15; // Adaptive threshold based on d-pad size
283
+ if (distance < threshold) return;
284
+
285
+ let newDirection: 'up' | 'down' | 'left' | 'right' | null = null;
286
+
287
+ // Determine direction based on angle
288
+ if (angle > -45 && angle <= 45) newDirection = 'right';
289
+ else if (angle > 45 && angle <= 135) newDirection = 'down';
290
+ else if (angle > 135 || angle <= -135) newDirection = 'left';
291
+ else if (angle > -135 && angle <= -45) newDirection = 'up';
292
+
293
+ if (newDirection && newDirection !== activeDirectionRef.current) {
294
+ handleDirectionPress(newDirection);
295
+ // Update touch start to current position to prevent jitter
296
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
297
+ }
298
+ },
299
+ [handleDirectionPress, size]
300
+ );
301
+
302
+ // Add a new cleanup function for touch events
303
+ const cleanupTouchEvents = useCallback(() => {
304
+ touchStartRef.current = null;
305
+ if (activeDirectionRef.current) {
306
+ handleDirectionRelease(activeDirectionRef.current);
307
+ }
308
+ }, [handleDirectionRelease]);
309
+
310
+ // Enhance the touch end handler
311
+ const handleTouchEnd = useCallback(() => {
312
+ cleanupTouchEvents();
313
+ }, [cleanupTouchEvents]);
314
+
315
+ // Add touch cancel handler
316
+ const handleTouchCancel = useCallback(() => {
317
+ cleanupTouchEvents();
318
+ }, [cleanupTouchEvents]);
319
+
320
+ // Enhance cleanup effect
321
+ useEffect(() => {
322
+ if (disabled) {
323
+ clearAllPresses();
324
+ }
325
+
326
+ const handleBlur = () => {
327
+ clearAllPresses();
328
+ };
329
+
330
+ const handleVisibilityChange = () => {
331
+ if (document.hidden) {
332
+ clearAllPresses();
333
+ }
334
+ };
335
+
336
+ const handlePointerUp = () => {
337
+ // Global pointer up as fallback for stuck buttons
338
+ if (isPressedRef.current) {
339
+ clearAllPresses();
340
+ }
341
+ };
342
+
343
+ window.addEventListener('blur', handleBlur);
344
+ window.addEventListener('pointerup', handlePointerUp);
345
+ document.addEventListener('visibilitychange', handleVisibilityChange);
346
+
347
+ return () => {
348
+ clearAllPresses();
349
+ window.removeEventListener('blur', handleBlur);
350
+ window.removeEventListener('pointerup', handlePointerUp);
351
+ document.removeEventListener(
352
+ 'visibilitychange',
353
+ handleVisibilityChange
354
+ );
355
+ };
356
+ }, [disabled, clearAllPresses]);
357
+
358
+ // Memoize the preventDefault handler
359
+ const preventDefault = useCallback(
360
+ (e: React.MouseEvent | React.TouchEvent) => {
361
+ e.preventDefault();
362
+ e.stopPropagation();
363
+ },
364
+ []
365
+ );
366
+
367
+ // Memoize button props to prevent unnecessary re-renders
368
+ const buttonProps = useCallback(
369
+ (direction: 'up' | 'down' | 'left' | 'right') => ({
370
+ onMouseDown: () => handleDirectionPress(direction),
371
+ onMouseUp: () => handleDirectionRelease(direction),
372
+ onMouseLeave: () => handleDirectionRelease(direction),
373
+ onTouchStart: (e: React.TouchEvent) => handleTouchStart(e, direction),
374
+ onTouchMove: handleTouchMove,
375
+ onTouchEnd: handleTouchEnd,
376
+ onContextMenu: preventDefault,
377
+ size,
378
+ isPressed: pressedButtons.has(direction),
379
+ disabled,
380
+ }),
381
+ [
382
+ handleDirectionPress,
383
+ handleDirectionRelease,
384
+ handleTouchStart,
385
+ handleTouchMove,
386
+ handleTouchEnd,
387
+ preventDefault,
388
+ size,
389
+ pressedButtons,
390
+ disabled,
391
+ ]
392
+ );
393
+
394
+ return (
395
+ <DPadContainer
396
+ opacity={opacity}
397
+ showBackground={showBackground}
398
+ size={size}
399
+ disabled={disabled}
400
+ onContextMenu={preventDefault}
401
+ onTouchCancel={handleTouchCancel}
402
+ >
403
+ <DPadButton className="up" {...buttonProps('up')} />
404
+ <DPadButton className="right" {...buttonProps('right')} />
405
+ <DPadButton className="down" {...buttonProps('down')} />
406
+ <DPadButton className="left" {...buttonProps('left')} />
407
+ <DPadCenter
408
+ size={size}
409
+ disabled={disabled}
410
+ onContextMenu={preventDefault}
411
+ />
412
+ </DPadContainer>
413
+ );
414
+ }
415
+ );
416
+
417
+ JoystickDPad.displayName = 'JoystickDPad';