@rpg-engine/long-bow 0.7.98 → 0.8.0

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.8.0",
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;
@@ -22,179 +28,16 @@ interface IDPadOptions {
22
28
  interface IDPadProps {
23
29
  /** Callback fired when a direction is pressed */
24
30
  onDirectionPress?: (direction: 'up' | 'down' | 'left' | 'right') => void;
31
+ /** Callback fired when a direction is released */
32
+ onDirectionRelease?: (direction: 'up' | 'down' | 'left' | 'right') => void;
25
33
  /** Whether the component is disabled */
26
34
  disabled?: boolean;
27
35
  /** Additional options for customizing the D-pad */
28
36
  options?: IDPadOptions;
29
37
  }
30
38
 
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>`
39
+ // Memoize the styled components since they don't depend on props that change frequently
40
+ const DPadButton = memo(styled.div<IDPadButtonProps>`
198
41
  position: absolute;
199
42
  background: ${props => (props.isPressed ? '#363636' : '#424242')};
200
43
  box-shadow: ${props =>
@@ -285,9 +128,9 @@ const DPadButton = styled.div<IDPadButtonProps>`
285
128
  transform: translate(50%, -50%);
286
129
  }
287
130
  }
288
- `;
131
+ `);
289
132
 
290
- const DPadCenter = styled.div<IDPadButtonProps>`
133
+ const DPadCenter = memo(styled.div<IDPadButtonProps>`
291
134
  position: absolute;
292
135
  width: ${props => (props.size ?? 100) * 0.3}px;
293
136
  height: ${props => (props.size ?? 100) * 0.3}px;
@@ -315,4 +158,266 @@ const DPadCenter = styled.div<IDPadButtonProps>`
315
158
  pointer-events: none;
316
159
  box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.8);
317
160
  }
318
- `;
161
+ `);
162
+
163
+ const DPadContainer = memo(styled.div<IDPadContainerProps>`
164
+ width: ${props => props.size ?? 100}px;
165
+ height: ${props => props.size ?? 100}px;
166
+ position: relative;
167
+ background: ${props => (props.showBackground ? '#b8b8b8' : 'transparent')};
168
+ border-radius: 50%;
169
+ box-shadow: ${props =>
170
+ props.showBackground
171
+ ? '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)'
172
+ : 'none'};
173
+ opacity: ${props => (props.disabled ? 0.5 : props.opacity ?? 1)};
174
+ user-select: none;
175
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'default')};
176
+ transition: opacity 0.2s ease;
177
+ touch-action: none;
178
+ -webkit-tap-highlight-color: transparent;
179
+ `);
180
+
181
+ export const JoystickDPad = memo(
182
+ ({
183
+ onDirectionPress,
184
+ onDirectionRelease,
185
+ disabled = false,
186
+ options = {},
187
+ }: IDPadProps): JSX.Element => {
188
+ const {
189
+ opacity = 1,
190
+ showBackground = false,
191
+ size = 100,
192
+ pressInterval = 500,
193
+ } = options;
194
+
195
+ // Use refs for values that don't need to trigger re-renders
196
+ const [pressedButtons, setPressedButtons] = useState<Set<string>>(
197
+ new Set()
198
+ );
199
+ const intervalRef = useRef<number | null>(null);
200
+ const activeDirectionRef = useRef<'up' | 'down' | 'left' | 'right' | null>(
201
+ null
202
+ );
203
+ const touchStartRef = useRef<{ x: number; y: number } | null>(null);
204
+ const isPressedRef = useRef<boolean>(false);
205
+
206
+ const clearPressInterval = useCallback(() => {
207
+ if (intervalRef.current !== null) {
208
+ window.clearInterval(intervalRef.current);
209
+ intervalRef.current = null;
210
+ }
211
+ activeDirectionRef.current = null;
212
+ }, []);
213
+
214
+ const clearAllPresses = useCallback(() => {
215
+ if (activeDirectionRef.current && onDirectionRelease) {
216
+ onDirectionRelease(activeDirectionRef.current);
217
+ }
218
+ clearPressInterval();
219
+ setPressedButtons(new Set());
220
+ activeDirectionRef.current = null;
221
+ isPressedRef.current = false;
222
+ }, [clearPressInterval, onDirectionRelease]);
223
+
224
+ const handleDirectionPress = useCallback(
225
+ (direction: 'up' | 'down' | 'left' | 'right') => {
226
+ if (disabled) return;
227
+
228
+ // Clear any existing presses first
229
+ clearAllPresses();
230
+
231
+ // Set new direction
232
+ activeDirectionRef.current = direction;
233
+ isPressedRef.current = true;
234
+ setPressedButtons(new Set([direction]));
235
+ onDirectionPress?.(direction);
236
+
237
+ intervalRef.current = window.setInterval(() => {
238
+ if (activeDirectionRef.current === direction) {
239
+ onDirectionPress?.(direction);
240
+ } else {
241
+ clearPressInterval();
242
+ }
243
+ }, pressInterval);
244
+ },
245
+ [
246
+ disabled,
247
+ onDirectionPress,
248
+ pressInterval,
249
+ clearPressInterval,
250
+ clearAllPresses,
251
+ ]
252
+ );
253
+
254
+ const handleDirectionRelease = useCallback(
255
+ (direction: 'up' | 'down' | 'left' | 'right') => {
256
+ if (activeDirectionRef.current === direction) {
257
+ clearAllPresses();
258
+ }
259
+ },
260
+ [clearAllPresses]
261
+ );
262
+
263
+ const handleTouchStart = useCallback(
264
+ (e: React.TouchEvent, direction: 'up' | 'down' | 'left' | 'right') => {
265
+ const touch = e.touches[0];
266
+ if (touch) {
267
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
268
+ handleDirectionPress(direction);
269
+ }
270
+ },
271
+ [handleDirectionPress]
272
+ );
273
+
274
+ const handleTouchMove = useCallback(
275
+ (e: React.TouchEvent) => {
276
+ const touch = e.touches[0];
277
+ if (!touch || !touchStartRef.current) return;
278
+
279
+ const { x: startX, y: startY } = touchStartRef.current;
280
+ const deltaX = touch.clientX - startX;
281
+ const deltaY = touch.clientY - startY;
282
+
283
+ // Calculate angle and distance
284
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
285
+ const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
286
+
287
+ // Only trigger if we've moved enough
288
+ const threshold = size * 0.15; // Adaptive threshold based on d-pad size
289
+ if (distance < threshold) return;
290
+
291
+ let newDirection: 'up' | 'down' | 'left' | 'right' | null = null;
292
+
293
+ // Determine direction based on angle
294
+ if (angle > -45 && angle <= 45) newDirection = 'right';
295
+ else if (angle > 45 && angle <= 135) newDirection = 'down';
296
+ else if (angle > 135 || angle <= -135) newDirection = 'left';
297
+ else if (angle > -135 && angle <= -45) newDirection = 'up';
298
+
299
+ if (newDirection && newDirection !== activeDirectionRef.current) {
300
+ handleDirectionPress(newDirection);
301
+ // Update touch start to current position to prevent jitter
302
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY };
303
+ }
304
+ },
305
+ [handleDirectionPress, size]
306
+ );
307
+
308
+ // Add a new cleanup function for touch events
309
+ const cleanupTouchEvents = useCallback(() => {
310
+ touchStartRef.current = null;
311
+ if (activeDirectionRef.current) {
312
+ handleDirectionRelease(activeDirectionRef.current);
313
+ }
314
+ }, [handleDirectionRelease]);
315
+
316
+ // Enhance the touch end handler
317
+ const handleTouchEnd = useCallback(() => {
318
+ cleanupTouchEvents();
319
+ }, [cleanupTouchEvents]);
320
+
321
+ // Add touch cancel handler
322
+ const handleTouchCancel = useCallback(() => {
323
+ cleanupTouchEvents();
324
+ }, [cleanupTouchEvents]);
325
+
326
+ // Enhance cleanup effect
327
+ useEffect(() => {
328
+ if (disabled) {
329
+ clearAllPresses();
330
+ }
331
+
332
+ const handleBlur = () => {
333
+ clearAllPresses();
334
+ };
335
+
336
+ const handleVisibilityChange = () => {
337
+ if (document.hidden) {
338
+ clearAllPresses();
339
+ }
340
+ };
341
+
342
+ const handlePointerUp = () => {
343
+ // Global pointer up as fallback for stuck buttons
344
+ if (isPressedRef.current) {
345
+ clearAllPresses();
346
+ }
347
+ };
348
+
349
+ window.addEventListener('blur', handleBlur);
350
+ window.addEventListener('pointerup', handlePointerUp);
351
+ document.addEventListener('visibilitychange', handleVisibilityChange);
352
+
353
+ return () => {
354
+ clearAllPresses();
355
+ window.removeEventListener('blur', handleBlur);
356
+ window.removeEventListener('pointerup', handlePointerUp);
357
+ document.removeEventListener(
358
+ 'visibilitychange',
359
+ handleVisibilityChange
360
+ );
361
+ };
362
+ }, [disabled, clearAllPresses]);
363
+
364
+ // Memoize the preventDefault handler
365
+ const preventDefault = useCallback(
366
+ (e: React.MouseEvent | React.TouchEvent) => {
367
+ e.preventDefault();
368
+ e.stopPropagation();
369
+ },
370
+ []
371
+ );
372
+
373
+ // Memoize button props to prevent unnecessary re-renders
374
+ const buttonProps = useCallback(
375
+ (direction: 'up' | 'down' | 'left' | 'right') => ({
376
+ onMouseDown: () => handleDirectionPress(direction),
377
+ onMouseUp: () => handleDirectionRelease(direction),
378
+ onMouseLeave: () => handleDirectionRelease(direction),
379
+ onTouchStart: (e: React.TouchEvent) => handleTouchStart(e, direction),
380
+ onTouchMove: handleTouchMove,
381
+ onTouchEnd: handleTouchEnd,
382
+ onContextMenu: preventDefault,
383
+ size,
384
+ isPressed: pressedButtons.has(direction),
385
+ disabled,
386
+ }),
387
+ [
388
+ handleDirectionPress,
389
+ handleDirectionRelease,
390
+ handleTouchStart,
391
+ handleTouchMove,
392
+ handleTouchEnd,
393
+ preventDefault,
394
+ size,
395
+ pressedButtons,
396
+ disabled,
397
+ ]
398
+ );
399
+
400
+ return (
401
+ <DPadContainer
402
+ opacity={opacity}
403
+ showBackground={showBackground}
404
+ size={size}
405
+ disabled={disabled}
406
+ onContextMenu={preventDefault}
407
+ onTouchCancel={handleTouchCancel}
408
+ >
409
+ <DPadButton className="up" {...buttonProps('up')} />
410
+ <DPadButton className="right" {...buttonProps('right')} />
411
+ <DPadButton className="down" {...buttonProps('down')} />
412
+ <DPadButton className="left" {...buttonProps('left')} />
413
+ <DPadCenter
414
+ size={size}
415
+ disabled={disabled}
416
+ onContextMenu={preventDefault}
417
+ />
418
+ </DPadContainer>
419
+ );
420
+ }
421
+ );
422
+
423
+ JoystickDPad.displayName = 'JoystickDPad';