minecraft-inventory 0.1.2 → 0.1.4
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 +2 -2
- package/src/assets/entities/horse.png +0 -0
- package/src/assets/entities/llama.png +0 -0
- package/src/assets/entities/player.png +0 -0
- package/src/components/InventoryOverlay/InventoryOverlay.tsx +56 -9
- package/src/components/InventoryWindow/AnvilInput.tsx +102 -0
- package/src/components/InventoryWindow/EntityDisplay.tsx +46 -0
- package/src/components/InventoryWindow/InventoryBackground.tsx +59 -3
- package/src/components/InventoryWindow/InventoryWindow.tsx +17 -1
- package/src/components/ItemCanvas/ItemCanvas.tsx +10 -7
- package/src/components/JEI/JEI.tsx +7 -1
- package/src/components/Slot/Slot.tsx +108 -11
- package/src/connector/mineflayer.ts +80 -25
- package/src/context/InventoryContext.tsx +143 -9
- package/src/context/TextureContext.tsx +9 -2
- package/src/generated/localTextures.ts +24 -15
- package/src/hooks/useKeyboardShortcuts.ts +61 -6
- package/src/index.tsx +4 -0
- package/src/registry/inventories.ts +98 -6
- package/src/styles/tokens.css +6 -0
- package/src/types.ts +24 -0
- package/src/utils/isItemEqual.ts +41 -0
|
@@ -42,12 +42,17 @@ export function Slot({
|
|
|
42
42
|
isDragging,
|
|
43
43
|
dragSlots,
|
|
44
44
|
dragButton,
|
|
45
|
+
dragPreview,
|
|
45
46
|
startDrag,
|
|
46
47
|
addDragSlot,
|
|
47
48
|
endDrag,
|
|
48
49
|
hoveredSlot,
|
|
49
50
|
setHoveredSlot,
|
|
50
51
|
activeNumberKey,
|
|
52
|
+
pKeyActive,
|
|
53
|
+
setPKeyActive,
|
|
54
|
+
focusedSlot,
|
|
55
|
+
setFocusedSlot,
|
|
51
56
|
} = useInventoryContext()
|
|
52
57
|
|
|
53
58
|
const { contentSize } = useScale()
|
|
@@ -89,6 +94,10 @@ export function Slot({
|
|
|
89
94
|
|
|
90
95
|
const isHovered = hoveredSlot === index
|
|
91
96
|
const isDragTarget = dragSlots.includes(index)
|
|
97
|
+
const dragPreviewEntry = dragPreview.get(index)
|
|
98
|
+
const isFocused = focusedSlot === index
|
|
99
|
+
const showPKeyNumber = pKeyActive && index >= 0 && index <= 99
|
|
100
|
+
const isInFocusSwapMode = focusedSlot !== null || pKeyActive
|
|
92
101
|
|
|
93
102
|
// Keyboard number key while hovering
|
|
94
103
|
useEffect(() => {
|
|
@@ -129,22 +138,44 @@ export function Slot({
|
|
|
129
138
|
(e: React.MouseEvent) => {
|
|
130
139
|
if (isMobile || disabled) return
|
|
131
140
|
e.preventDefault()
|
|
141
|
+
e.stopPropagation()
|
|
132
142
|
const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
|
|
133
143
|
if (isDragging && dragSlots.length > 1) {
|
|
134
144
|
endDrag()
|
|
135
145
|
return
|
|
136
146
|
}
|
|
147
|
+
|
|
148
|
+
// Focus/swap logic — active in P mode OR when a slot is already focused
|
|
149
|
+
if (button === 'left' && (pKeyActive || focusedSlot !== null)) {
|
|
150
|
+
if (pKeyActive) setPKeyActive(false)
|
|
151
|
+
if (focusedSlot === null) {
|
|
152
|
+
setFocusedSlot(index)
|
|
153
|
+
} else if (focusedSlot === index) {
|
|
154
|
+
setFocusedSlot(null)
|
|
155
|
+
} else {
|
|
156
|
+
sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
|
|
157
|
+
sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
|
|
158
|
+
sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
|
|
159
|
+
setFocusedSlot(null)
|
|
160
|
+
}
|
|
161
|
+
if (isDragging) endDrag()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
137
165
|
const mode = e.shiftKey ? 'shift' : 'normal'
|
|
138
166
|
if (onClickOverride) {
|
|
139
167
|
onClickOverride(button, mode)
|
|
140
168
|
} else {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
169
|
+
if (resultSlot && heldItem && !item && mode === 'normal') {
|
|
170
|
+
// Cannot place items into result/output slots
|
|
171
|
+
} else {
|
|
172
|
+
if (button === 'left' || button === 'right') setShowTooltip(false)
|
|
173
|
+
sendAction({ type: 'click', slotIndex: index, button, mode })
|
|
174
|
+
}
|
|
144
175
|
}
|
|
145
176
|
if (isDragging) endDrag()
|
|
146
177
|
},
|
|
147
|
-
[isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride],
|
|
178
|
+
[isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
|
|
148
179
|
)
|
|
149
180
|
|
|
150
181
|
const handleDoubleClick = useCallback(
|
|
@@ -212,17 +243,30 @@ export function Slot({
|
|
|
212
243
|
touchStartRef.current = null
|
|
213
244
|
const touch = e.changedTouches[0]
|
|
214
245
|
if (Math.abs(touch.clientX - start.x) > 10 || Math.abs(touch.clientY - start.y) > 10) return
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
|
|
248
|
+
if (pKeyActive) setPKeyActive(false)
|
|
215
249
|
|
|
216
250
|
if (heldItem) {
|
|
251
|
+
// When holding an item, place it (standard behavior, no focus needed)
|
|
252
|
+
sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// On mobile, tapping always uses the focus/swap mechanism:
|
|
257
|
+
// first tap focuses, second tap on a different slot swaps, same slot clears.
|
|
258
|
+
if (focusedSlot === null) {
|
|
259
|
+
setFocusedSlot(index)
|
|
260
|
+
} else if (focusedSlot === index) {
|
|
261
|
+
setFocusedSlot(null)
|
|
262
|
+
} else {
|
|
263
|
+
sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
|
|
217
264
|
sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (rect) setMobileTouchPos({ x: rect.right, y: rect.top })
|
|
221
|
-
setMobileMenuOpen(true)
|
|
222
|
-
setShowTooltip(true)
|
|
265
|
+
sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
|
|
266
|
+
setFocusedSlot(null)
|
|
223
267
|
}
|
|
224
268
|
},
|
|
225
|
-
[isMobile, disabled, heldItem,
|
|
269
|
+
[isMobile, disabled, heldItem, sendAction, index, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
|
|
226
270
|
)
|
|
227
271
|
|
|
228
272
|
const handleMobilePickAll = useCallback(() => {
|
|
@@ -274,11 +318,13 @@ export function Slot({
|
|
|
274
318
|
]
|
|
275
319
|
.filter(Boolean)
|
|
276
320
|
.join(' ')}
|
|
321
|
+
tabIndex={index >= 0 ? 0 : undefined}
|
|
277
322
|
style={{
|
|
278
323
|
width: renderSize,
|
|
279
324
|
height: renderSize,
|
|
280
325
|
position: 'relative',
|
|
281
326
|
flexShrink: 0,
|
|
327
|
+
...(isFocused ? { outline: `2px dashed #ff0`, outlineOffset: -2, animation: 'mc-inv-focus-dash 0.5s linear infinite' } : {}),
|
|
282
328
|
...style,
|
|
283
329
|
}}
|
|
284
330
|
onMouseEnter={handleMouseEnter}
|
|
@@ -314,7 +360,58 @@ export function Slot({
|
|
|
314
360
|
</div>
|
|
315
361
|
)}
|
|
316
362
|
|
|
317
|
-
{
|
|
363
|
+
{showPKeyNumber && (
|
|
364
|
+
<div
|
|
365
|
+
className="mc-inv-pkey-overlay"
|
|
366
|
+
style={{
|
|
367
|
+
position: 'absolute',
|
|
368
|
+
inset: 0,
|
|
369
|
+
background: 'rgba(255, 0, 0, 0.2)',
|
|
370
|
+
border: '1px solid rgba(255, 0, 0, 0.5)',
|
|
371
|
+
display: 'flex',
|
|
372
|
+
alignItems: 'center',
|
|
373
|
+
justifyContent: 'center',
|
|
374
|
+
paddingLeft: 4,
|
|
375
|
+
pointerEvents: 'none',
|
|
376
|
+
zIndex: 4,
|
|
377
|
+
boxSizing: 'border-box',
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
<span
|
|
381
|
+
style={{
|
|
382
|
+
fontSize: Math.round(renderSize * 0.4),
|
|
383
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
384
|
+
color: '#ffffff',
|
|
385
|
+
textShadow: '1px 1px 0 rgba(0,0,0,0.7)',
|
|
386
|
+
lineHeight: 1,
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{String(index).padStart(2, '0')}
|
|
390
|
+
</span>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{dragPreviewEntry && (
|
|
395
|
+
<div
|
|
396
|
+
className="mc-inv-drag-preview-count"
|
|
397
|
+
style={{
|
|
398
|
+
position: 'absolute',
|
|
399
|
+
right: 1,
|
|
400
|
+
bottom: 1,
|
|
401
|
+
fontSize: Math.round(renderSize * 0.45),
|
|
402
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
403
|
+
color: '#ffff00',
|
|
404
|
+
textShadow: '1px 1px 0 #3f3f00',
|
|
405
|
+
lineHeight: 1,
|
|
406
|
+
pointerEvents: 'none',
|
|
407
|
+
zIndex: 3,
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{dragPreviewEntry.count}
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{item && showTooltip && !mobileMenuOpen && !heldItem && (
|
|
318
415
|
<Tooltip item={item} visible />
|
|
319
416
|
)}
|
|
320
417
|
|
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types'
|
|
2
2
|
import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for {@link createMineflayerConnector}.
|
|
8
|
+
*/
|
|
9
|
+
export interface MineflayerConnectorOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Custom item mapper called for every slot conversion from raw mineflayer data to
|
|
12
|
+
* {@link ItemStack}. Receives the raw slot data and the default-mapped stack.
|
|
13
|
+
* Return a modified stack to override fields (e.g. `name`, `textureKey`, `displayName`),
|
|
14
|
+
* or return the second argument unchanged to use the default mapping.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* createMineflayerConnector(bot, {
|
|
19
|
+
* itemMapper: (raw, mapped) => ({
|
|
20
|
+
* ...mapped,
|
|
21
|
+
* // Override texture for specific numeric type IDs:
|
|
22
|
+
* textureKey: raw.type === 438 ? 'item/potion_water' : mapped.textureKey,
|
|
23
|
+
* }),
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
itemMapper?: (raw: RawSlot, mapped: ItemStack) => ItemStack
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeSlotConverter(itemMapper?: MineflayerConnectorOptions['itemMapper']) {
|
|
31
|
+
return function botSlotToItemStack(slot: RawSlot | null | undefined): ItemStack | null {
|
|
32
|
+
if (!slot || slot.type === -1 || slot.type === 0) return null
|
|
33
|
+
const mapped: ItemStack = {
|
|
34
|
+
type: slot.type,
|
|
35
|
+
count: slot.count,
|
|
36
|
+
metadata: slot.metadata,
|
|
37
|
+
nbt: slot.nbt as Record<string, unknown> | undefined,
|
|
38
|
+
}
|
|
39
|
+
return itemMapper ? itemMapper(slot, mapped) : mapped
|
|
11
40
|
}
|
|
12
41
|
}
|
|
13
42
|
|
|
14
|
-
function botSlotsToSlotStates(
|
|
43
|
+
function botSlotsToSlotStates(
|
|
44
|
+
slots: MineflayerBot['inventory']['slots'],
|
|
45
|
+
convert: (slot: RawSlot | null | undefined) => ItemStack | null,
|
|
46
|
+
): SlotState[] {
|
|
15
47
|
return slots.map((slot, index) => ({
|
|
16
48
|
index,
|
|
17
|
-
item:
|
|
49
|
+
item: convert(slot),
|
|
18
50
|
}))
|
|
19
51
|
}
|
|
20
52
|
|
|
@@ -67,23 +99,47 @@ function isBeaconWindow(win: unknown): win is { setBeaconEffects?: (primary: num
|
|
|
67
99
|
return win != null && (typeof (win as Record<string, unknown>).setBeaconEffects === 'function' || /beacon/i.test(String((win as Record<string, unknown>).type)))
|
|
68
100
|
}
|
|
69
101
|
|
|
70
|
-
export function createMineflayerConnector(bot: MineflayerBot): InventoryConnector {
|
|
102
|
+
export function createMineflayerConnector(bot: MineflayerBot, options?: MineflayerConnectorOptions): InventoryConnector {
|
|
71
103
|
const listeners = new Set<ConnectorListener>()
|
|
72
104
|
const ext = bot as MineflayerBotExtended
|
|
105
|
+
const convert = makeSlotConverter(options?.itemMapper)
|
|
73
106
|
|
|
74
107
|
function emit(event: ConnectorEvent) {
|
|
75
108
|
listeners.forEach((l) => l(event))
|
|
76
109
|
}
|
|
77
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Builds a window state from the currently open window, OR from `bot.inventory`
|
|
113
|
+
* when no container is open (exposing the player's own inventory as a synthetic
|
|
114
|
+
* 'player' window with windowId = 0).
|
|
115
|
+
*/
|
|
78
116
|
function buildWindowState(): InventoryWindowState | null {
|
|
79
117
|
const win = bot.currentWindow
|
|
80
|
-
if (
|
|
118
|
+
if (win) {
|
|
119
|
+
return {
|
|
120
|
+
windowId: win.id,
|
|
121
|
+
type: win.type ?? 'unknown',
|
|
122
|
+
title: win.title,
|
|
123
|
+
slots: botSlotsToSlotStates(win.slots, convert),
|
|
124
|
+
heldItem: convert(bot.heldItem),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// No open container — expose the player inventory as a synthetic 'player' window.
|
|
128
|
+
const invSlots: SlotState[] = []
|
|
129
|
+
// Slots 0–8: crafting/armour — leave empty (not accessible from bot.inventory directly)
|
|
130
|
+
for (let i = 0; i < 9; i++) invSlots.push({ index: i, item: null })
|
|
131
|
+
// Slots 9–35: main inventory
|
|
132
|
+
for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
|
|
133
|
+
// Slots 36–44: hotbar
|
|
134
|
+
for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
|
|
135
|
+
// Slot 45: offhand
|
|
136
|
+
invSlots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
|
|
81
137
|
return {
|
|
82
|
-
windowId:
|
|
83
|
-
type:
|
|
84
|
-
title:
|
|
85
|
-
slots:
|
|
86
|
-
heldItem:
|
|
138
|
+
windowId: 0,
|
|
139
|
+
type: 'player',
|
|
140
|
+
title: undefined,
|
|
141
|
+
slots: invSlots,
|
|
142
|
+
heldItem: convert(bot.heldItem),
|
|
87
143
|
}
|
|
88
144
|
}
|
|
89
145
|
|
|
@@ -91,7 +147,7 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
|
|
|
91
147
|
const inv = bot.inventory.slots
|
|
92
148
|
return {
|
|
93
149
|
activeHotbarSlot: bot.quickBarSlot,
|
|
94
|
-
inventory: botSlotsToSlotStates(inv),
|
|
150
|
+
inventory: botSlotsToSlotStates(inv, convert),
|
|
95
151
|
}
|
|
96
152
|
}
|
|
97
153
|
|
|
@@ -135,21 +191,20 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
|
|
|
135
191
|
// Llama inventory structure (per registry):
|
|
136
192
|
// Slot 0: Carpet (saddle) - empty since we don't have entity data
|
|
137
193
|
slots.push({ index: 0, item: null })
|
|
138
|
-
// Slot 1: skipped (not used)
|
|
139
194
|
// Slots 2-16: Llama chest (5×3 grid = 15 slots) - empty since we don't have entity data
|
|
140
195
|
for (let i = 2; i <= 16; i++) {
|
|
141
196
|
slots.push({ index: i, item: null })
|
|
142
197
|
}
|
|
143
198
|
// Slots 17-43: Player inventory (bot.inventory.slots indices 9-35 map to window slots 17-43)
|
|
144
199
|
for (let i = 9; i <= 35; i++) {
|
|
145
|
-
slots.push({ index: i + 8, item:
|
|
200
|
+
slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) })
|
|
146
201
|
}
|
|
147
202
|
// Slots 44-52: Hotbar (bot.inventory.slots indices 36-44 map to window slots 44-52)
|
|
148
203
|
for (let i = 36; i <= 44; i++) {
|
|
149
|
-
slots.push({ index: i + 8, item:
|
|
204
|
+
slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) })
|
|
150
205
|
}
|
|
151
206
|
// Slot 53: Offhand (bot.inventory slot 45 maps to window slot 53)
|
|
152
|
-
slots.push({ index: 53, item:
|
|
207
|
+
slots.push({ index: 53, item: convert(bot.inventory.slots[45]) })
|
|
153
208
|
} else {
|
|
154
209
|
// Player inventory window structure (per registry):
|
|
155
210
|
// Slots 0-8: Crafting result + grid + armor (not in bot.inventory.slots, leave empty)
|
|
@@ -158,14 +213,14 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
|
|
|
158
213
|
}
|
|
159
214
|
// Slots 9-35: Player inventory (bot.inventory.slots indices 9-35)
|
|
160
215
|
for (let i = 9; i <= 35; i++) {
|
|
161
|
-
slots.push({ index: i, item:
|
|
216
|
+
slots.push({ index: i, item: convert(bot.inventory.slots[i]) })
|
|
162
217
|
}
|
|
163
218
|
// Slots 36-44: Hotbar (bot.inventory.slots indices 36-44)
|
|
164
219
|
for (let i = 36; i <= 44; i++) {
|
|
165
|
-
slots.push({ index: i, item:
|
|
220
|
+
slots.push({ index: i, item: convert(bot.inventory.slots[i]) })
|
|
166
221
|
}
|
|
167
222
|
// Slot 45: Offhand (bot.inventory slot 45)
|
|
168
|
-
slots.push({ index: 45, item:
|
|
223
|
+
slots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
|
|
169
224
|
}
|
|
170
225
|
|
|
171
226
|
const windowState: InventoryWindowState = {
|
|
@@ -173,7 +228,7 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
|
|
|
173
228
|
type: inventoryType,
|
|
174
229
|
title: inventoryType === 'llama' ? 'Llama' : undefined,
|
|
175
230
|
slots,
|
|
176
|
-
heldItem:
|
|
231
|
+
heldItem: convert(bot.heldItem),
|
|
177
232
|
}
|
|
178
233
|
|
|
179
234
|
emit({ type: 'windowOpen', state: windowState })
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
|
1
|
+
import React, { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
|
2
2
|
import type { InventoryWindowState, PlayerState, ItemStack, SlotState } from '../types'
|
|
3
3
|
import type { InventoryConnector } from '../connector/types'
|
|
4
|
+
import { isItemEqual, getMaxStackSize } from '../utils/isItemEqual'
|
|
5
|
+
|
|
6
|
+
export interface DragPreviewEntry {
|
|
7
|
+
count: number
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export interface InventoryContextValue {
|
|
6
11
|
windowState: InventoryWindowState | null
|
|
@@ -12,6 +17,8 @@ export interface InventoryContextValue {
|
|
|
12
17
|
isDragging: boolean
|
|
13
18
|
dragSlots: number[]
|
|
14
19
|
dragButton: 'left' | 'right' | null
|
|
20
|
+
/** Client-side preview of item counts for each slot in the current drag */
|
|
21
|
+
dragPreview: Map<number, DragPreviewEntry>
|
|
15
22
|
startDrag: (slotIndex: number, button: 'left' | 'right') => void
|
|
16
23
|
addDragSlot: (slotIndex: number) => void
|
|
17
24
|
endDrag: () => void
|
|
@@ -24,6 +31,17 @@ export interface InventoryContextValue {
|
|
|
24
31
|
/** Pixel offset from the cursor item's top-left to the grab point, preserving pick-up position */
|
|
25
32
|
grabOffset: { x: number; y: number }
|
|
26
33
|
setGrabOffset: (offset: { x: number; y: number }) => void
|
|
34
|
+
/** Whether drag/spread operations are disabled */
|
|
35
|
+
noDragSpread: boolean
|
|
36
|
+
/** Whether P-key slot numbering mode is active */
|
|
37
|
+
pKeyActive: boolean
|
|
38
|
+
setPKeyActive: (v: boolean) => void
|
|
39
|
+
/** Currently focused slot index (via P-key number entry) */
|
|
40
|
+
focusedSlot: number | null
|
|
41
|
+
setFocusedSlot: (slot: number | null) => void
|
|
42
|
+
/** Pending first digit for P-key slot number entry */
|
|
43
|
+
pKeyDigit: string
|
|
44
|
+
setPKeyDigit: (d: string) => void
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
const InventoryContext = createContext<InventoryContextValue | null>(null)
|
|
@@ -37,9 +55,10 @@ export function useInventoryContext(): InventoryContextValue {
|
|
|
37
55
|
interface InventoryProviderProps {
|
|
38
56
|
connector: InventoryConnector | null
|
|
39
57
|
children: React.ReactNode
|
|
58
|
+
noDragSpread?: boolean
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
export function InventoryProvider({ connector, children }: InventoryProviderProps) {
|
|
61
|
+
export function InventoryProvider({ connector, children, noDragSpread = false }: InventoryProviderProps) {
|
|
43
62
|
const [windowState, setWindowState] = useState<InventoryWindowState | null>(
|
|
44
63
|
() => connector?.getWindowState() ?? null,
|
|
45
64
|
)
|
|
@@ -53,12 +72,24 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
|
|
|
53
72
|
const [isDragging, setIsDragging] = useState(false)
|
|
54
73
|
const [dragSlots, setDragSlots] = useState<number[]>([])
|
|
55
74
|
const [dragButton, setDragButton] = useState<'left' | 'right' | null>(null)
|
|
75
|
+
const [dragPreview, setDragPreview] = useState<Map<number, DragPreviewEntry>>(new Map())
|
|
56
76
|
const [activeNumberKey, setActiveNumberKey] = useState<number | null>(null)
|
|
57
77
|
const [grabOffset, setGrabOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
|
|
78
|
+
const [pKeyActive, setPKeyActive] = useState(false)
|
|
79
|
+
const [focusedSlot, setFocusedSlot] = useState<number | null>(null)
|
|
80
|
+
const [pKeyDigit, setPKeyDigit] = useState('')
|
|
58
81
|
|
|
59
82
|
const connectorRef = useRef(connector)
|
|
60
83
|
connectorRef.current = connector
|
|
61
84
|
|
|
85
|
+
// Refs so endDrag can read current values without being in its dep array
|
|
86
|
+
const heldItemRef = useRef(heldItem)
|
|
87
|
+
heldItemRef.current = heldItem
|
|
88
|
+
const windowStateRef = useRef(windowState)
|
|
89
|
+
windowStateRef.current = windowState
|
|
90
|
+
const dragButtonRef = useRef(dragButton)
|
|
91
|
+
dragButtonRef.current = dragButton
|
|
92
|
+
|
|
62
93
|
useEffect(() => {
|
|
63
94
|
if (!connector) return
|
|
64
95
|
setWindowState(connector.getWindowState())
|
|
@@ -92,35 +123,130 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
|
|
|
92
123
|
[],
|
|
93
124
|
)
|
|
94
125
|
|
|
126
|
+
const computeDragPreview = useCallback((slots: number[], button: 'left' | 'right', held: ItemStack | null, ws: InventoryWindowState | null) => {
|
|
127
|
+
const preview = new Map<number, DragPreviewEntry>()
|
|
128
|
+
if (!held || slots.length === 0) return preview
|
|
129
|
+
|
|
130
|
+
const maxStack = getMaxStackSize(held)
|
|
131
|
+
|
|
132
|
+
if (button === 'left') {
|
|
133
|
+
// Only spread into compatible slots (empty or same item type)
|
|
134
|
+
const compatibleSlots = slots.filter((idx) => {
|
|
135
|
+
const existingItem = ws?.slots.find((s) => s.index === idx)?.item
|
|
136
|
+
return !existingItem || isItemEqual(existingItem, held)
|
|
137
|
+
})
|
|
138
|
+
if (compatibleSlots.length === 0) return preview
|
|
139
|
+
const perSlot = Math.floor(held.count / compatibleSlots.length)
|
|
140
|
+
let remainder = held.count % compatibleSlots.length
|
|
141
|
+
for (const idx of compatibleSlots) {
|
|
142
|
+
const existingCount = ws?.slots.find((s) => s.index === idx)?.item?.count ?? 0
|
|
143
|
+
const add = perSlot + (remainder > 0 ? 1 : 0)
|
|
144
|
+
if (remainder > 0) remainder--
|
|
145
|
+
const total = Math.min(existingCount + add, maxStack)
|
|
146
|
+
preview.set(idx, { count: total })
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
for (const idx of slots) {
|
|
150
|
+
const existing = ws?.slots.find((s) => s.index === idx)
|
|
151
|
+
const existingItem = existing?.item
|
|
152
|
+
if (existingItem && !isItemEqual(existingItem, held)) continue
|
|
153
|
+
const existingCount = existingItem ? existingItem.count : 0
|
|
154
|
+
const total = Math.min(existingCount + 1, maxStack)
|
|
155
|
+
preview.set(idx, { count: total })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return preview
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
95
161
|
const startDrag = useCallback((slotIndex: number, button: 'left' | 'right') => {
|
|
162
|
+
if (noDragSpread) return
|
|
96
163
|
setIsDragging(true)
|
|
97
164
|
setDragButton(button)
|
|
98
165
|
setDragSlots([slotIndex])
|
|
99
|
-
|
|
100
|
-
}, [])
|
|
166
|
+
setDragPreview(new Map())
|
|
167
|
+
}, [noDragSpread])
|
|
101
168
|
|
|
102
169
|
const addDragSlot = useCallback((slotIndex: number) => {
|
|
103
170
|
setDragSlots((prev) => {
|
|
104
171
|
if (prev.includes(slotIndex)) return prev
|
|
105
|
-
|
|
172
|
+
const next = [...prev, slotIndex]
|
|
173
|
+
setDragPreview(computeDragPreview(next, dragButton!, heldItem, windowState))
|
|
174
|
+
return next
|
|
106
175
|
})
|
|
107
|
-
}, [])
|
|
176
|
+
}, [computeDragPreview, dragButton, heldItem, windowState])
|
|
108
177
|
|
|
109
178
|
const endDrag = useCallback(() => {
|
|
110
179
|
setDragSlots((slots) => {
|
|
111
|
-
|
|
112
|
-
|
|
180
|
+
const button = dragButtonRef.current
|
|
181
|
+
const held = heldItemRef.current
|
|
182
|
+
const ws = windowStateRef.current
|
|
183
|
+
|
|
184
|
+
// Only send drag action if multiple slots were involved (single slot = normal click)
|
|
185
|
+
if (slots.length > 1 && button && held) {
|
|
186
|
+
connectorRef.current?.sendAction({ type: 'drag', slots, button })
|
|
187
|
+
|
|
188
|
+
// Optimistic client-side update: apply item distribution immediately
|
|
189
|
+
// so slots visually update before server responds.
|
|
190
|
+
const maxStack = getMaxStackSize(held)
|
|
191
|
+
const newSlots = ws ? [...ws.slots] : []
|
|
192
|
+
|
|
193
|
+
if (button === 'left') {
|
|
194
|
+
const compatibleSlots = slots.filter((idx) => {
|
|
195
|
+
const existing = newSlots.find((s) => s.index === idx)?.item
|
|
196
|
+
return !existing || isItemEqual(existing, held)
|
|
197
|
+
})
|
|
198
|
+
if (compatibleSlots.length > 0) {
|
|
199
|
+
const perSlot = Math.floor(held.count / compatibleSlots.length)
|
|
200
|
+
let remainder = held.count % compatibleSlots.length
|
|
201
|
+
for (const idx of compatibleSlots) {
|
|
202
|
+
const existingIdx = newSlots.findIndex((s) => s.index === idx)
|
|
203
|
+
const existingCount = existingIdx >= 0 ? (newSlots[existingIdx].item?.count ?? 0) : 0
|
|
204
|
+
const add = perSlot + (remainder > 0 ? 1 : 0)
|
|
205
|
+
if (remainder > 0) remainder--
|
|
206
|
+
const newCount = Math.min(existingCount + add, maxStack)
|
|
207
|
+
if (existingIdx >= 0) {
|
|
208
|
+
newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } }
|
|
209
|
+
} else {
|
|
210
|
+
newSlots.push({ index: idx, item: { ...held, count: newCount } })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (ws) setWindowState({ ...ws, slots: newSlots })
|
|
214
|
+
setHeldItemState(null)
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Right-click drag: place 1 per slot
|
|
218
|
+
let remaining = held.count
|
|
219
|
+
for (const idx of slots) {
|
|
220
|
+
if (remaining <= 0) break
|
|
221
|
+
const existingIdx = newSlots.findIndex((s) => s.index === idx)
|
|
222
|
+
const existingItem = existingIdx >= 0 ? newSlots[existingIdx].item : null
|
|
223
|
+
if (existingItem && !isItemEqual(existingItem, held)) continue
|
|
224
|
+
const existingCount = existingItem?.count ?? 0
|
|
225
|
+
const newCount = Math.min(existingCount + 1, maxStack)
|
|
226
|
+
if (existingIdx >= 0) {
|
|
227
|
+
newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } }
|
|
228
|
+
} else {
|
|
229
|
+
newSlots.push({ index: idx, item: { ...held, count: newCount } })
|
|
230
|
+
}
|
|
231
|
+
remaining--
|
|
232
|
+
}
|
|
233
|
+
if (ws) setWindowState({ ...ws, slots: newSlots })
|
|
234
|
+
if (remaining <= 0) setHeldItemState(null)
|
|
235
|
+
else setHeldItemState({ ...held, count: remaining })
|
|
236
|
+
}
|
|
113
237
|
}
|
|
114
238
|
return []
|
|
115
239
|
})
|
|
116
240
|
setIsDragging(false)
|
|
117
241
|
setDragButton(null)
|
|
118
|
-
|
|
242
|
+
setDragPreview(new Map())
|
|
243
|
+
}, [])
|
|
119
244
|
|
|
120
245
|
const cancelDrag = useCallback(() => {
|
|
121
246
|
setIsDragging(false)
|
|
122
247
|
setDragSlots([])
|
|
123
248
|
setDragButton(null)
|
|
249
|
+
setDragPreview(new Map())
|
|
124
250
|
}, [])
|
|
125
251
|
|
|
126
252
|
const getSlot = useCallback(
|
|
@@ -140,6 +266,7 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
|
|
|
140
266
|
isDragging,
|
|
141
267
|
dragSlots,
|
|
142
268
|
dragButton,
|
|
269
|
+
dragPreview,
|
|
143
270
|
startDrag,
|
|
144
271
|
addDragSlot,
|
|
145
272
|
endDrag,
|
|
@@ -151,6 +278,13 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
|
|
|
151
278
|
getSlot,
|
|
152
279
|
grabOffset,
|
|
153
280
|
setGrabOffset,
|
|
281
|
+
noDragSpread,
|
|
282
|
+
pKeyActive,
|
|
283
|
+
setPKeyActive,
|
|
284
|
+
focusedSlot,
|
|
285
|
+
setFocusedSlot,
|
|
286
|
+
pKeyDigit,
|
|
287
|
+
setPKeyDigit,
|
|
154
288
|
}
|
|
155
289
|
|
|
156
290
|
return <InventoryContext.Provider value={value}>{children}</InventoryContext.Provider>
|
|
@@ -9,7 +9,13 @@ export const MC_GUI_BASE = MC_ASSETS_BASE
|
|
|
9
9
|
|
|
10
10
|
export interface TextureConfig {
|
|
11
11
|
baseUrl: string
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Resolve item texture URL.
|
|
14
|
+
* When `item.textureKey` is set it is used as the path relative to the items texture root
|
|
15
|
+
* (e.g. `"item/dye_black"` → `<base>/item/dye_black.png`).
|
|
16
|
+
* Otherwise falls back to `name` then `type`.
|
|
17
|
+
*/
|
|
18
|
+
getItemTextureUrl(item: { type: number; name?: string; textureKey?: string }): string
|
|
13
19
|
getBlockTextureUrl(item: { type: number; name?: string }): string
|
|
14
20
|
/** Supports full mc-assets paths (e.g. "1.21.11/textures/gui/container/anvil.png") */
|
|
15
21
|
getGuiTextureUrl(path: string, version?: string): string
|
|
@@ -23,8 +29,9 @@ function buildDefault(base: string): TextureConfig {
|
|
|
23
29
|
return {
|
|
24
30
|
baseUrl: base,
|
|
25
31
|
|
|
26
|
-
getItemTextureUrl({ type, name }) {
|
|
32
|
+
getItemTextureUrl({ type, name, textureKey }) {
|
|
27
33
|
const root = isRemote ? MC_ITEMS_BASE : base
|
|
34
|
+
if (textureKey) return `${root}/${textureKey}.png`
|
|
28
35
|
if (name) return `${root}/item/${name}.png`
|
|
29
36
|
return `${root}/item/${type}.png`
|
|
30
37
|
},
|