@rpg-engine/long-bow 0.7.97 → 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.
Files changed (53) hide show
  1. package/dist/components/DPad/JoystickDPad.d.ts +21 -0
  2. package/dist/components/InformationCenter/InformationCenter.d.ts +29 -0
  3. package/dist/components/InformationCenter/InformationCenterCell.d.ts +14 -0
  4. package/dist/components/InformationCenter/InformationCenterTabView.d.ts +19 -0
  5. package/dist/components/InformationCenter/InformationCenterTypes.d.ts +79 -0
  6. package/dist/components/InformationCenter/sections/bestiary/BestiarySection.d.ts +12 -0
  7. package/dist/components/InformationCenter/sections/bestiary/InformationCenterNPCDetails.d.ts +12 -0
  8. package/dist/components/InformationCenter/sections/bestiary/InformationCenterNPCTooltip.d.ts +9 -0
  9. package/dist/components/InformationCenter/sections/faq/FaqSection.d.ts +8 -0
  10. package/dist/components/InformationCenter/sections/items/InformationCenterItemDetails.d.ts +11 -0
  11. package/dist/components/InformationCenter/sections/items/InformationCenterItemTooltip.d.ts +7 -0
  12. package/dist/components/InformationCenter/sections/items/ItemsSection.d.ts +11 -0
  13. package/dist/components/InformationCenter/sections/tutorials/TutorialsSection.d.ts +8 -0
  14. package/dist/components/InformationCenter/shared/BaseInformationDetails.d.ts +10 -0
  15. package/dist/components/shared/BaseTooltip.d.ts +12 -0
  16. package/dist/components/shared/Collapsible/Collapsible.d.ts +9 -0
  17. package/dist/components/shared/Portal/Portal.d.ts +6 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/long-bow.cjs.development.js +271 -5
  20. package/dist/long-bow.cjs.development.js.map +1 -1
  21. package/dist/long-bow.cjs.production.min.js +1 -1
  22. package/dist/long-bow.cjs.production.min.js.map +1 -1
  23. package/dist/long-bow.esm.js +273 -8
  24. package/dist/long-bow.esm.js.map +1 -1
  25. package/dist/mocks/informationCenter.mocks.d.ts +6 -0
  26. package/dist/stories/Features/craftbook/CraftBook.stories.d.ts +2 -0
  27. package/dist/stories/UI/info/InformationCenter.stories.d.ts +7 -0
  28. package/dist/stories/UI/joystick/JoystickDPad.stories.d.ts +6 -0
  29. package/package.json +1 -1
  30. package/src/components/CraftBook/CraftBook.tsx +70 -31
  31. package/src/components/DPad/JoystickDPad.tsx +417 -0
  32. package/src/components/InformationCenter/InformationCenter.tsx +155 -0
  33. package/src/components/InformationCenter/InformationCenterCell.tsx +96 -0
  34. package/src/components/InformationCenter/InformationCenterTabView.tsx +121 -0
  35. package/src/components/InformationCenter/InformationCenterTypes.ts +87 -0
  36. package/src/components/InformationCenter/sections/bestiary/BestiarySection.tsx +170 -0
  37. package/src/components/InformationCenter/sections/bestiary/InformationCenterNPCDetails.tsx +366 -0
  38. package/src/components/InformationCenter/sections/bestiary/InformationCenterNPCTooltip.tsx +204 -0
  39. package/src/components/InformationCenter/sections/faq/FaqSection.tsx +71 -0
  40. package/src/components/InformationCenter/sections/items/InformationCenterItemDetails.tsx +323 -0
  41. package/src/components/InformationCenter/sections/items/InformationCenterItemTooltip.tsx +88 -0
  42. package/src/components/InformationCenter/sections/items/ItemsSection.tsx +180 -0
  43. package/src/components/InformationCenter/sections/tutorials/TutorialsSection.tsx +144 -0
  44. package/src/components/InformationCenter/shared/BaseInformationDetails.tsx +162 -0
  45. package/src/components/InternalTabs/InternalTabs.tsx +1 -3
  46. package/src/components/shared/BaseTooltip.tsx +60 -0
  47. package/src/components/shared/Collapsible/Collapsible.tsx +70 -0
  48. package/src/components/shared/Portal/Portal.tsx +19 -0
  49. package/src/index.tsx +1 -0
  50. package/src/mocks/informationCenter.mocks.ts +562 -0
  51. package/src/stories/Features/craftbook/CraftBook.stories.tsx +15 -1
  52. package/src/stories/UI/info/InformationCenter.stories.tsx +58 -0
  53. package/src/stories/UI/joystick/JoystickDPad.stories.tsx +52 -0
@@ -0,0 +1,6 @@
1
+ import { IFaqItem, IVideoGuide } from '../components/InformationCenter/InformationCenter';
2
+ import { IInformationCenterItem, IInformationCenterNPC } from '../components/InformationCenter/InformationCenterTypes';
3
+ export declare const mockBestiaryItems: IInformationCenterNPC[];
4
+ export declare const mockItems: IInformationCenterItem[];
5
+ export declare const mockFaqItems: IFaqItem[];
6
+ export declare const mockTutorials: IVideoGuide[];
@@ -4,3 +4,5 @@ export default meta;
4
4
  export declare const Default: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
5
5
  export declare const WithSearch: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
6
6
  export declare const WithCategory: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
7
+ export declare const Empty: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
8
+ export declare const NoSearchResults: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
@@ -0,0 +1,7 @@
1
+ import type { Meta } from '@storybook/react';
2
+ declare const meta: Meta;
3
+ export default meta;
4
+ export declare const Default: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
5
+ export declare const Loading: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
6
+ export declare const Error: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
7
+ export declare const Empty: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
@@ -0,0 +1,6 @@
1
+ declare const _default: import("@storybook/csf").ComponentAnnotations<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
2
+ export default _default;
3
+ export declare const Default: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
4
+ export declare const WithBackground: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
5
+ export declare const WithCustomOptions: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
6
+ export declare const Disabled: import("@storybook/csf").AnnotatedStoryFn<import("@storybook/react").ReactFramework, import("@storybook/react").Args>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.7.97",
3
+ "version": "0.7.99",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -7,6 +7,7 @@ import {
7
7
  } from '@rpg-engine/shared';
8
8
  import React, { useEffect, useState } from 'react';
9
9
  import {
10
+ FaBoxOpen,
10
11
  FaChevronLeft,
11
12
  FaChevronRight,
12
13
  FaSearch,
@@ -183,35 +184,42 @@ export const CraftBook: React.FC<IItemCraftSelectorProps> = ({
183
184
  )}
184
185
 
185
186
  <ContentContainer>
186
- <RadioInputScroller className="inputRadioCraftBook">
187
- {paginatedItems?.map(item => (
188
- <CraftingRecipeWrapper
189
- key={item.key}
190
- isSelected={pinnedItems.includes(item.key)}
191
- >
192
- <PinButton
193
- onClick={e => {
194
- e.stopPropagation();
195
- togglePinItem(item.key);
196
- }}
197
- isPinned={pinnedItems.includes(item.key)}
187
+ {paginatedItems.length > 0 ? (
188
+ <RadioInputScroller className="inputRadioCraftBook">
189
+ {paginatedItems?.map(item => (
190
+ <CraftingRecipeWrapper
191
+ key={item.key}
192
+ isSelected={pinnedItems.includes(item.key)}
198
193
  >
199
- <FaThumbtack size={14} />
200
- </PinButton>
201
- <CraftingRecipe
202
- atlasIMG={atlasIMG}
203
- atlasJSON={atlasJSON}
204
- equipmentSet={equipmentSet}
205
- recipe={item}
206
- scale={scale}
207
- handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
208
- selectedCraftItemKey={craftItemKey}
209
- inventory={inventory}
210
- skills={skills}
211
- />
212
- </CraftingRecipeWrapper>
213
- ))}
214
- </RadioInputScroller>
194
+ <PinButton
195
+ onClick={e => {
196
+ e.stopPropagation();
197
+ togglePinItem(item.key);
198
+ }}
199
+ isPinned={pinnedItems.includes(item.key)}
200
+ >
201
+ <FaThumbtack size={14} />
202
+ </PinButton>
203
+ <CraftingRecipe
204
+ atlasIMG={atlasIMG}
205
+ atlasJSON={atlasJSON}
206
+ equipmentSet={equipmentSet}
207
+ recipe={item}
208
+ scale={scale}
209
+ handleRecipeSelect={setCraftItemKey.bind(null, item.key)}
210
+ selectedCraftItemKey={craftItemKey}
211
+ inventory={inventory}
212
+ skills={skills}
213
+ />
214
+ </CraftingRecipeWrapper>
215
+ ))}
216
+ </RadioInputScroller>
217
+ ) : (
218
+ <EmptyState>
219
+ <FaBoxOpen size={48} />
220
+ <p>No craftable items found</p>
221
+ </EmptyState>
222
+ )}
215
223
  </ContentContainer>
216
224
 
217
225
  {totalPages > 1 && (
@@ -353,17 +361,21 @@ const SearchContainer = styled.div`
353
361
 
354
362
  const ContentContainer = styled.div`
355
363
  flex: 1;
356
- min-height: 0;
364
+ display: flex;
365
+ flex-direction: column;
357
366
  padding: 16px;
358
367
  padding-right: 0;
359
368
  padding-bottom: 0;
360
- overflow: hidden;
361
369
  width: 100%;
370
+ position: relative;
371
+ min-height: 300px;
372
+ overflow: hidden;
362
373
  `;
363
374
 
364
375
  const RadioInputScroller = styled.div`
365
376
  height: 100%;
366
- overflow-y: scroll;
377
+ min-height: 300px;
378
+ overflow-y: auto;
367
379
  overflow-x: hidden;
368
380
  padding: 8px 16px;
369
381
  padding-right: 24px;
@@ -464,3 +476,30 @@ const PageInfo = styled.div`
464
476
  font-size: 0.8rem;
465
477
  font-family: 'Press Start 2P', cursive;
466
478
  `;
479
+
480
+ const EmptyState = styled.div`
481
+ position: absolute;
482
+ top: 50%;
483
+ left: 50%;
484
+ transform: translate(-50%, -50%);
485
+ display: flex;
486
+ flex-direction: column;
487
+ align-items: center;
488
+ justify-content: center;
489
+ text-align: center;
490
+ color: ${uiColors.lightGray};
491
+ width: 100%;
492
+ padding: 2rem;
493
+
494
+ svg {
495
+ font-size: 3rem;
496
+ margin-bottom: 1rem;
497
+ opacity: 0.7;
498
+ }
499
+
500
+ p {
501
+ font-family: 'Press Start 2P', cursive;
502
+ font-size: 0.9rem;
503
+ margin: 0;
504
+ }
505
+ `;
@@ -0,0 +1,417 @@
1
+ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
2
+ import styled from 'styled-components';
3
+
4
+ interface IDPadContainerProps {
5
+ opacity?: number;
6
+ showBackground?: boolean;
7
+ size?: number;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ interface IDPadButtonProps {
12
+ size?: number;
13
+ isPressed?: boolean;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ interface IDPadOptions {
18
+ /** Opacity of the entire component (0-1) */
19
+ opacity?: number;
20
+ /** Show the silver background behind the controller (default: false) */
21
+ showBackground?: boolean;
22
+ /** Size in pixels (default: 100) */
23
+ size?: number;
24
+ /** Interval in ms for continuous press events (default: 500) */
25
+ pressInterval?: number;
26
+ }
27
+
28
+ interface IDPadProps {
29
+ /** Callback fired when a direction is pressed */
30
+ onDirectionPress?: (direction: 'up' | 'down' | 'left' | 'right') => void;
31
+ /** Whether the component is disabled */
32
+ disabled?: boolean;
33
+ /** Additional options for customizing the D-pad */
34
+ options?: IDPadOptions;
35
+ }
36
+
37
+ // Memoize the styled components since they don't depend on props that change frequently
38
+ const DPadButton = memo(styled.div<IDPadButtonProps>`
39
+ position: absolute;
40
+ background: ${props => (props.isPressed ? '#363636' : '#424242')};
41
+ box-shadow: ${props =>
42
+ props.isPressed
43
+ ? 'inset 0 0 12px rgba(0, 0, 0, 0.8), 0 0 2px rgba(0, 0, 0, 0.3)'
44
+ : 'inset 0 0 5px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.2)'};
45
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
46
+ user-select: none;
47
+ transition: all 0.08s cubic-bezier(0.4, 0, 0.2, 1);
48
+ touch-action: none;
49
+ -webkit-tap-highlight-color: transparent;
50
+ transform-origin: center center;
51
+
52
+ &:hover:not(:active) {
53
+ @media (hover: hover) {
54
+ filter: ${props =>
55
+ !props.disabled && !props.isPressed ? 'brightness(1.1)' : 'none'};
56
+ }
57
+ }
58
+
59
+ &::after {
60
+ content: '';
61
+ position: absolute;
62
+ width: 0;
63
+ height: 0;
64
+ border: ${props => (props.size ?? 100) * 0.05}px solid transparent;
65
+ pointer-events: none;
66
+ opacity: ${props => (props.isPressed ? 0.7 : 1)};
67
+ transition: all 0.08s cubic-bezier(0.4, 0, 0.2, 1);
68
+ }
69
+
70
+ &.up,
71
+ &.down {
72
+ width: ${props => (props.size ?? 100) * 0.3}px;
73
+ height: ${props => (props.size ?? 100) * 0.4}px;
74
+ left: 50%;
75
+ transform: translateX(-50%) scale(${props => (props.isPressed ? 0.95 : 1)});
76
+ }
77
+
78
+ &.left,
79
+ &.right {
80
+ width: ${props => (props.size ?? 100) * 0.4}px;
81
+ height: ${props => (props.size ?? 100) * 0.3}px;
82
+ top: 50%;
83
+ transform: translateY(-50%) scale(${props => (props.isPressed ? 0.95 : 1)});
84
+ }
85
+
86
+ &.up {
87
+ top: 0;
88
+ border-radius: 5px 5px 0 0;
89
+ &::after {
90
+ border-bottom-color: #2a2a2a;
91
+ top: 45%;
92
+ left: 50%;
93
+ transform: translate(-50%, -50%);
94
+ }
95
+ }
96
+
97
+ &.down {
98
+ bottom: 0;
99
+ border-radius: 0 0 5px 5px;
100
+ &::after {
101
+ border-top-color: #2a2a2a;
102
+ bottom: 45%;
103
+ left: 50%;
104
+ transform: translate(-50%, 50%);
105
+ }
106
+ }
107
+
108
+ &.left {
109
+ left: 0;
110
+ border-radius: 5px 0 0 5px;
111
+ &::after {
112
+ border-right-color: #2a2a2a;
113
+ left: 45%;
114
+ top: 50%;
115
+ transform: translate(-50%, -50%);
116
+ }
117
+ }
118
+
119
+ &.right {
120
+ right: 0;
121
+ border-radius: 0 5px 5px 0;
122
+ &::after {
123
+ border-left-color: #2a2a2a;
124
+ right: 45%;
125
+ top: 50%;
126
+ transform: translate(50%, -50%);
127
+ }
128
+ }
129
+ `);
130
+
131
+ const DPadCenter = memo(styled.div<IDPadButtonProps>`
132
+ position: absolute;
133
+ width: ${props => (props.size ?? 100) * 0.3}px;
134
+ height: ${props => (props.size ?? 100) * 0.3}px;
135
+ background: #424242;
136
+ top: 50%;
137
+ left: 50%;
138
+ transform: translate(-50%, -50%);
139
+ box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.6);
140
+ border-radius: 50%;
141
+ user-select: none;
142
+ cursor: ${props => (props.disabled ? 'not-allowed' : 'default')};
143
+ touch-action: none;
144
+ -webkit-tap-highlight-color: transparent;
145
+
146
+ &::after {
147
+ content: '';
148
+ position: absolute;
149
+ width: ${props => (props.size ?? 100) * 0.08}px;
150
+ height: ${props => (props.size ?? 100) * 0.08}px;
151
+ background: #2a2a2a;
152
+ border-radius: 50%;
153
+ top: 50%;
154
+ left: 50%;
155
+ transform: translate(-50%, -50%);
156
+ pointer-events: none;
157
+ box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.8);
158
+ }
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';