@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.
- package/dist/components/DPad/JoystickDPad.d.ts +21 -0
- package/dist/components/InformationCenter/InformationCenter.d.ts +29 -0
- package/dist/components/InformationCenter/InformationCenterCell.d.ts +14 -0
- package/dist/components/InformationCenter/InformationCenterTabView.d.ts +19 -0
- package/dist/components/InformationCenter/InformationCenterTypes.d.ts +79 -0
- package/dist/components/InformationCenter/sections/bestiary/BestiarySection.d.ts +12 -0
- package/dist/components/InformationCenter/sections/bestiary/InformationCenterNPCDetails.d.ts +12 -0
- package/dist/components/InformationCenter/sections/bestiary/InformationCenterNPCTooltip.d.ts +9 -0
- package/dist/components/InformationCenter/sections/faq/FaqSection.d.ts +8 -0
- package/dist/components/InformationCenter/sections/items/InformationCenterItemDetails.d.ts +11 -0
- package/dist/components/InformationCenter/sections/items/InformationCenterItemTooltip.d.ts +7 -0
- package/dist/components/InformationCenter/sections/items/ItemsSection.d.ts +11 -0
- package/dist/components/InformationCenter/sections/tutorials/TutorialsSection.d.ts +8 -0
- package/dist/components/InformationCenter/shared/BaseInformationDetails.d.ts +10 -0
- package/dist/components/shared/BaseTooltip.d.ts +12 -0
- package/dist/components/shared/Collapsible/Collapsible.d.ts +9 -0
- package/dist/components/shared/Portal/Portal.d.ts +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/long-bow.cjs.development.js +271 -5
- package/dist/long-bow.cjs.development.js.map +1 -1
- package/dist/long-bow.cjs.production.min.js +1 -1
- package/dist/long-bow.cjs.production.min.js.map +1 -1
- package/dist/long-bow.esm.js +273 -8
- package/dist/long-bow.esm.js.map +1 -1
- package/dist/mocks/informationCenter.mocks.d.ts +6 -0
- package/dist/stories/Features/craftbook/CraftBook.stories.d.ts +2 -0
- package/dist/stories/UI/info/InformationCenter.stories.d.ts +7 -0
- package/dist/stories/UI/joystick/JoystickDPad.stories.d.ts +6 -0
- package/package.json +1 -1
- package/src/components/CraftBook/CraftBook.tsx +70 -31
- package/src/components/DPad/JoystickDPad.tsx +417 -0
- package/src/components/InformationCenter/InformationCenter.tsx +155 -0
- package/src/components/InformationCenter/InformationCenterCell.tsx +96 -0
- package/src/components/InformationCenter/InformationCenterTabView.tsx +121 -0
- package/src/components/InformationCenter/InformationCenterTypes.ts +87 -0
- package/src/components/InformationCenter/sections/bestiary/BestiarySection.tsx +170 -0
- package/src/components/InformationCenter/sections/bestiary/InformationCenterNPCDetails.tsx +366 -0
- package/src/components/InformationCenter/sections/bestiary/InformationCenterNPCTooltip.tsx +204 -0
- package/src/components/InformationCenter/sections/faq/FaqSection.tsx +71 -0
- package/src/components/InformationCenter/sections/items/InformationCenterItemDetails.tsx +323 -0
- package/src/components/InformationCenter/sections/items/InformationCenterItemTooltip.tsx +88 -0
- package/src/components/InformationCenter/sections/items/ItemsSection.tsx +180 -0
- package/src/components/InformationCenter/sections/tutorials/TutorialsSection.tsx +144 -0
- package/src/components/InformationCenter/shared/BaseInformationDetails.tsx +162 -0
- package/src/components/InternalTabs/InternalTabs.tsx +1 -3
- package/src/components/shared/BaseTooltip.tsx +60 -0
- package/src/components/shared/Collapsible/Collapsible.tsx +70 -0
- package/src/components/shared/Portal/Portal.tsx +19 -0
- package/src/index.tsx +1 -0
- package/src/mocks/informationCenter.mocks.ts +562 -0
- package/src/stories/Features/craftbook/CraftBook.stories.tsx +15 -1
- package/src/stories/UI/info/InformationCenter.stories.tsx +58 -0
- 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
|
@@ -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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|