minecraft-inventory 0.1.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/README.md +566 -0
- package/package.json +37 -0
- package/src/InventoryGUI.tsx +108 -0
- package/src/cache/textureCache.ts +95 -0
- package/src/components/CursorItem/CursorItem.tsx +94 -0
- package/src/components/CursorItem/index.ts +1 -0
- package/src/components/HUD/HUD.tsx +11 -0
- package/src/components/HUD/index.ts +1 -0
- package/src/components/Hotbar/Hotbar.tsx +180 -0
- package/src/components/Hotbar/index.ts +1 -0
- package/src/components/InventoryOverlay/InventoryOverlay.tsx +196 -0
- package/src/components/InventoryOverlay/index.ts +2 -0
- package/src/components/InventoryWindow/EnchantmentOptions.tsx +109 -0
- package/src/components/InventoryWindow/InventoryBackground.tsx +110 -0
- package/src/components/InventoryWindow/InventoryWindow.tsx +120 -0
- package/src/components/InventoryWindow/ProgressBar.tsx +78 -0
- package/src/components/InventoryWindow/VillagerTradeList.tsx +136 -0
- package/src/components/InventoryWindow/index.ts +5 -0
- package/src/components/ItemCanvas/ItemCanvas.tsx +154 -0
- package/src/components/ItemCanvas/index.ts +1 -0
- package/src/components/JEI/JEI.module.css +37 -0
- package/src/components/JEI/JEI.tsx +303 -0
- package/src/components/JEI/index.ts +2 -0
- package/src/components/RecipeGuide/RecipeInventoryView.tsx +293 -0
- package/src/components/RecipeGuide/index.ts +1 -0
- package/src/components/Slot/Slot.module.css +111 -0
- package/src/components/Slot/Slot.tsx +363 -0
- package/src/components/Slot/index.ts +1 -0
- package/src/components/Text/MessageFormatted.css +5 -0
- package/src/components/Text/MessageFormatted.tsx +79 -0
- package/src/components/Text/MessageFormattedString.tsx +74 -0
- package/src/components/Text/chatUtils.ts +172 -0
- package/src/components/Tooltip/Tooltip.module.css +56 -0
- package/src/components/Tooltip/Tooltip.tsx +130 -0
- package/src/components/Tooltip/index.ts +1 -0
- package/src/connector/demo.ts +213 -0
- package/src/connector/index.ts +4 -0
- package/src/connector/mineflayer.ts +113 -0
- package/src/connector/types.ts +41 -0
- package/src/context/InventoryContext.tsx +157 -0
- package/src/context/ScaleContext.tsx +73 -0
- package/src/context/TextureContext.tsx +70 -0
- package/src/globals.d.ts +4 -0
- package/src/hooks/useKeyboardShortcuts.ts +41 -0
- package/src/hooks/useMobile.ts +28 -0
- package/src/index.tsx +65 -0
- package/src/mount.tsx +52 -0
- package/src/registry/index.ts +21 -0
- package/src/registry/inventories.ts +612 -0
- package/src/styles/tokens.css +47 -0
- package/src/types.ts +176 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Cached data URLs keyed by the original URL */
|
|
4
|
+
const dataUrlCache = new Map<string, string>()
|
|
5
|
+
/** URLs that failed to load (404 / network error) */
|
|
6
|
+
const failedUrls = new Set<string>()
|
|
7
|
+
/** In-flight fetch promises */
|
|
8
|
+
const inflight = new Map<string, Promise<void>>()
|
|
9
|
+
|
|
10
|
+
async function fetchAsDataUrl(url: string): Promise<string | null> {
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(url)
|
|
13
|
+
if (!res.ok) return null
|
|
14
|
+
const blob = await res.blob()
|
|
15
|
+
return await new Promise<string>((resolve, reject) => {
|
|
16
|
+
const reader = new FileReader()
|
|
17
|
+
reader.onload = () => resolve(reader.result as string)
|
|
18
|
+
reader.onerror = () => reject(new Error('FileReader error'))
|
|
19
|
+
reader.readAsDataURL(blob)
|
|
20
|
+
})
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scheduleLoad(url: string): Promise<void> {
|
|
27
|
+
if (inflight.has(url)) return inflight.get(url)!
|
|
28
|
+
const p = fetchAsDataUrl(url).then((dataUrl) => {
|
|
29
|
+
inflight.delete(url)
|
|
30
|
+
if (dataUrl) {
|
|
31
|
+
dataUrlCache.set(url, dataUrl)
|
|
32
|
+
} else {
|
|
33
|
+
failedUrls.add(url)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
inflight.set(url, p)
|
|
37
|
+
return p
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getCachedDataUrl(url: string): string | undefined {
|
|
41
|
+
return dataUrlCache.get(url)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isTextureFailed(url: string): boolean {
|
|
45
|
+
return failedUrls.has(url)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook that resolves a URL to a cached base64 data URL.
|
|
50
|
+
* Returns:
|
|
51
|
+
* - `string` — loaded (data URL)
|
|
52
|
+
* - `null` — permanently failed (404 / network error)
|
|
53
|
+
* - `undefined` — still loading
|
|
54
|
+
*/
|
|
55
|
+
export function useDataUrl(url: string | null): string | null | undefined {
|
|
56
|
+
const urlRef = useRef(url)
|
|
57
|
+
|
|
58
|
+
const [state, setState] = useState<string | null | undefined>(() => {
|
|
59
|
+
if (!url) return undefined
|
|
60
|
+
const cached = dataUrlCache.get(url)
|
|
61
|
+
if (cached !== undefined) return cached
|
|
62
|
+
if (failedUrls.has(url)) return null
|
|
63
|
+
return undefined
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
urlRef.current = url
|
|
68
|
+
|
|
69
|
+
if (!url) {
|
|
70
|
+
setState(undefined)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Synchronous fast-path: already in cache
|
|
75
|
+
const cached = dataUrlCache.get(url)
|
|
76
|
+
if (cached !== undefined) {
|
|
77
|
+
setState(cached)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (failedUrls.has(url)) {
|
|
81
|
+
setState(null)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch asynchronously
|
|
86
|
+
setState(undefined) // mark loading
|
|
87
|
+
scheduleLoad(url).then(() => {
|
|
88
|
+
if (urlRef.current !== url) return // stale, ignore
|
|
89
|
+
const result = dataUrlCache.get(url)
|
|
90
|
+
setState(result !== undefined ? result : null)
|
|
91
|
+
})
|
|
92
|
+
}, [url])
|
|
93
|
+
|
|
94
|
+
return state
|
|
95
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { useInventoryContext } from '../../context/InventoryContext'
|
|
3
|
+
import { useScale } from '../../context/ScaleContext'
|
|
4
|
+
import { ItemCanvas } from '../ItemCanvas'
|
|
5
|
+
import { useMobile } from '../../hooks/useMobile'
|
|
6
|
+
|
|
7
|
+
// Global mouse tracker — always knows cursor position, even before item is picked up
|
|
8
|
+
const globalMouse = { x: -9999, y: -9999 }
|
|
9
|
+
|
|
10
|
+
if (typeof window !== 'undefined') {
|
|
11
|
+
window.addEventListener('mousemove', (e) => {
|
|
12
|
+
globalMouse.x = e.clientX
|
|
13
|
+
globalMouse.y = e.clientY
|
|
14
|
+
}, { passive: true, capture: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* CursorItem — optimized to bypass React re-renders on mouse move.
|
|
19
|
+
* Uses direct DOM manipulation via refs to update position every frame.
|
|
20
|
+
* Only re-renders when heldItem or contentSize changes (for ItemCanvas updates).
|
|
21
|
+
*/
|
|
22
|
+
export function CursorItem() {
|
|
23
|
+
const { heldItem } = useInventoryContext()
|
|
24
|
+
const { contentSize } = useScale()
|
|
25
|
+
const isMobile = useMobile()
|
|
26
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
27
|
+
const rafRef = useRef(0)
|
|
28
|
+
const halfSizeRef = useRef(Math.round(contentSize / 2))
|
|
29
|
+
|
|
30
|
+
// Update halfSize when contentSize changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
halfSizeRef.current = Math.round(contentSize / 2)
|
|
33
|
+
}, [contentSize])
|
|
34
|
+
|
|
35
|
+
// Direct DOM position updates — no React state, no re-renders
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (isMobile || !heldItem || !containerRef.current) {
|
|
38
|
+
// Cancel RAF if item dropped or mobile
|
|
39
|
+
if (rafRef.current) {
|
|
40
|
+
cancelAnimationFrame(rafRef.current)
|
|
41
|
+
rafRef.current = 0
|
|
42
|
+
}
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const container = containerRef.current
|
|
47
|
+
|
|
48
|
+
// Immediately position at current cursor
|
|
49
|
+
const half = halfSizeRef.current
|
|
50
|
+
container.style.left = `${globalMouse.x - half}px`
|
|
51
|
+
container.style.top = `${globalMouse.y - half}px`
|
|
52
|
+
|
|
53
|
+
// RAF loop — direct DOM updates, no React state
|
|
54
|
+
const update = () => {
|
|
55
|
+
if (!containerRef.current) return
|
|
56
|
+
const half = halfSizeRef.current
|
|
57
|
+
container.style.left = `${globalMouse.x - half}px`
|
|
58
|
+
container.style.top = `${globalMouse.y - half}px`
|
|
59
|
+
rafRef.current = requestAnimationFrame(update)
|
|
60
|
+
}
|
|
61
|
+
rafRef.current = requestAnimationFrame(update)
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
if (rafRef.current) {
|
|
65
|
+
cancelAnimationFrame(rafRef.current)
|
|
66
|
+
rafRef.current = 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, [isMobile, heldItem])
|
|
70
|
+
|
|
71
|
+
// Only re-render when heldItem or contentSize changes (for ItemCanvas)
|
|
72
|
+
if (!heldItem || isMobile) return null
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
ref={containerRef}
|
|
77
|
+
className="mc-inv-cursor-item"
|
|
78
|
+
style={{
|
|
79
|
+
position: 'fixed',
|
|
80
|
+
width: contentSize,
|
|
81
|
+
height: contentSize,
|
|
82
|
+
pointerEvents: 'none',
|
|
83
|
+
zIndex: 9999,
|
|
84
|
+
// left/top set directly by RAF, not via React style prop
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<ItemCanvas
|
|
88
|
+
item={heldItem}
|
|
89
|
+
size={contentSize}
|
|
90
|
+
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CursorItem } from './CursorItem'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
interface HUDProps {
|
|
4
|
+
className?: string
|
|
5
|
+
style?: React.CSSProperties
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function HUD({ className, style }: HUDProps) {
|
|
9
|
+
// HUD removed - inventory GUI doesn't need health/food/armor/XP/air display
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HUD } from './HUD'
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useInventoryContext } from '../../context/InventoryContext'
|
|
3
|
+
import { useScale } from '../../context/ScaleContext'
|
|
4
|
+
import { useTextures } from '../../context/TextureContext'
|
|
5
|
+
import { Slot } from '../Slot'
|
|
6
|
+
import { ItemCanvas } from '../ItemCanvas'
|
|
7
|
+
import type { SlotState } from '../../types'
|
|
8
|
+
|
|
9
|
+
interface HotbarProps {
|
|
10
|
+
slots?: SlotState[]
|
|
11
|
+
activeSlot?: number
|
|
12
|
+
showOffhand?: boolean
|
|
13
|
+
offhandSlot?: SlotState
|
|
14
|
+
className?: string
|
|
15
|
+
style?: React.CSSProperties
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Hotbar({
|
|
19
|
+
slots: slotsProp,
|
|
20
|
+
activeSlot: activeSlotProp,
|
|
21
|
+
showOffhand = true,
|
|
22
|
+
offhandSlot: offhandProp,
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
}: HotbarProps) {
|
|
26
|
+
const { playerState, windowState, sendAction } = useInventoryContext()
|
|
27
|
+
const { scale } = useScale()
|
|
28
|
+
const textures = useTextures()
|
|
29
|
+
|
|
30
|
+
const hotbarSlots = slotsProp ?? (
|
|
31
|
+
playerState?.inventory
|
|
32
|
+
.filter((s) => s.index >= 36 && s.index <= 44)
|
|
33
|
+
.sort((a, b) => a.index - b.index) ?? []
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const activeSlot = activeSlotProp ?? playerState?.activeHotbarSlot ?? 0
|
|
37
|
+
const offhandItem = offhandProp ?? playerState?.inventory.find((s) => s.index === 45) ?? null
|
|
38
|
+
|
|
39
|
+
const SLOT_SIZE = 20 * scale
|
|
40
|
+
const HOTBAR_H = 22 * scale
|
|
41
|
+
const OFFHAND_W = 24 * scale
|
|
42
|
+
|
|
43
|
+
const hotbarUrl = textures.getGuiTextureUrl('gui/widgets')
|
|
44
|
+
|
|
45
|
+
const totalWidth = (showOffhand ? OFFHAND_W + 4 * scale : 0) + 182 * scale
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={['mc-inv-root', className].filter(Boolean).join(' ')}
|
|
50
|
+
style={{
|
|
51
|
+
position: 'relative',
|
|
52
|
+
width: totalWidth,
|
|
53
|
+
height: HOTBAR_H + 2 * scale,
|
|
54
|
+
display: 'flex',
|
|
55
|
+
alignItems: 'flex-end',
|
|
56
|
+
...style,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{/* Offhand slot */}
|
|
60
|
+
{showOffhand && (
|
|
61
|
+
<div
|
|
62
|
+
className="mc-inv-hotbar-offhand"
|
|
63
|
+
style={{
|
|
64
|
+
position: 'relative',
|
|
65
|
+
width: OFFHAND_W,
|
|
66
|
+
height: HOTBAR_H,
|
|
67
|
+
flexShrink: 0,
|
|
68
|
+
marginRight: 4 * scale,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<img
|
|
72
|
+
className="mc-inv-hotbar-offhand-bg"
|
|
73
|
+
src={hotbarUrl}
|
|
74
|
+
alt=""
|
|
75
|
+
aria-hidden
|
|
76
|
+
style={{
|
|
77
|
+
position: 'absolute',
|
|
78
|
+
top: 0,
|
|
79
|
+
left: 0,
|
|
80
|
+
width: OFFHAND_W,
|
|
81
|
+
height: HOTBAR_H,
|
|
82
|
+
objectFit: 'none',
|
|
83
|
+
objectPosition: `-${24 * scale}px -${22 * scale}px`,
|
|
84
|
+
imageRendering: 'pixelated',
|
|
85
|
+
display: 'block',
|
|
86
|
+
}}
|
|
87
|
+
draggable={false}
|
|
88
|
+
/>
|
|
89
|
+
{offhandItem?.item && (
|
|
90
|
+
<div
|
|
91
|
+
className="mc-inv-hotbar-offhand-item"
|
|
92
|
+
style={{
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
top: 3 * scale,
|
|
95
|
+
left: 3 * scale,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<ItemCanvas item={offhandItem.item} size={16 * scale} />
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Main hotbar */}
|
|
105
|
+
<div
|
|
106
|
+
className="mc-inv-hotbar-main"
|
|
107
|
+
style={{
|
|
108
|
+
position: 'relative',
|
|
109
|
+
width: 182 * scale,
|
|
110
|
+
height: HOTBAR_H + 2 * scale,
|
|
111
|
+
flexShrink: 0,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{/* Hotbar background */}
|
|
115
|
+
<img
|
|
116
|
+
className="mc-inv-hotbar-bg"
|
|
117
|
+
src={hotbarUrl}
|
|
118
|
+
alt=""
|
|
119
|
+
aria-hidden
|
|
120
|
+
style={{
|
|
121
|
+
position: 'absolute',
|
|
122
|
+
top: scale,
|
|
123
|
+
left: 0,
|
|
124
|
+
width: 182 * scale,
|
|
125
|
+
height: HOTBAR_H,
|
|
126
|
+
objectFit: 'none',
|
|
127
|
+
objectPosition: `0 0`,
|
|
128
|
+
imageRendering: 'pixelated',
|
|
129
|
+
display: 'block',
|
|
130
|
+
}}
|
|
131
|
+
draggable={false}
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
{/* Active slot indicator */}
|
|
135
|
+
<img
|
|
136
|
+
className="mc-inv-hotbar-indicator"
|
|
137
|
+
src={hotbarUrl}
|
|
138
|
+
alt=""
|
|
139
|
+
aria-hidden
|
|
140
|
+
style={{
|
|
141
|
+
position: 'absolute',
|
|
142
|
+
top: 0,
|
|
143
|
+
left: (activeSlot * 20 - 1) * scale,
|
|
144
|
+
width: 24 * scale,
|
|
145
|
+
height: 24 * scale,
|
|
146
|
+
objectFit: 'none',
|
|
147
|
+
objectPosition: `0 -${22 * scale}px`,
|
|
148
|
+
imageRendering: 'pixelated',
|
|
149
|
+
display: 'block',
|
|
150
|
+
}}
|
|
151
|
+
draggable={false}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
{/* Items */}
|
|
155
|
+
{hotbarSlots.map((slot, i) => (
|
|
156
|
+
<div
|
|
157
|
+
key={slot.index}
|
|
158
|
+
className="mc-inv-hotbar-slot"
|
|
159
|
+
style={{
|
|
160
|
+
position: 'absolute',
|
|
161
|
+
top: 4 * scale,
|
|
162
|
+
left: (3 + i * 20) * scale,
|
|
163
|
+
cursor: 'pointer',
|
|
164
|
+
}}
|
|
165
|
+
onClick={() => {
|
|
166
|
+
sendAction({
|
|
167
|
+
type: 'click',
|
|
168
|
+
slotIndex: slot.index,
|
|
169
|
+
button: 'left',
|
|
170
|
+
mode: 'normal',
|
|
171
|
+
})
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{slot.item && <ItemCanvas item={slot.item} size={16 * scale} />}
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Hotbar } from './Hotbar'
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { useCallback, useState } from 'react'
|
|
2
|
+
import { useInventoryContext } from '../../context/InventoryContext'
|
|
3
|
+
import { useScale } from '../../context/ScaleContext'
|
|
4
|
+
import { InventoryWindow } from '../InventoryWindow'
|
|
5
|
+
import { CursorItem } from '../CursorItem'
|
|
6
|
+
import { Hotbar } from '../Hotbar'
|
|
7
|
+
import { JEI } from '../JEI'
|
|
8
|
+
import type { JEIItem } from '../JEI'
|
|
9
|
+
import { RecipeInventoryView } from '../RecipeGuide'
|
|
10
|
+
import type { RecipeGuide, RecipeNavFrame } from '../../types'
|
|
11
|
+
|
|
12
|
+
export interface InventoryOverlayProps {
|
|
13
|
+
type: string
|
|
14
|
+
title?: string
|
|
15
|
+
/** Show semi-transparent backdrop behind inventory (default: true) */
|
|
16
|
+
showBackdrop?: boolean
|
|
17
|
+
/** Backdrop color (default: 'rgba(0,0,0,0.5)') */
|
|
18
|
+
backdropColor?: string
|
|
19
|
+
/** Called when clicking outside the inventory (or pressing Esc) */
|
|
20
|
+
onClose?: () => void
|
|
21
|
+
/** Show JEI sidebar */
|
|
22
|
+
showJEI?: boolean
|
|
23
|
+
jeiItems?: JEIItem[]
|
|
24
|
+
jeiPosition?: 'left' | 'right'
|
|
25
|
+
jeiOnGetRecipes?: (item: JEIItem) => RecipeGuide[]
|
|
26
|
+
jeiOnGetUsages?: (item: JEIItem) => RecipeGuide[]
|
|
27
|
+
/** Show hotbar below inventory */
|
|
28
|
+
showHotbar?: boolean
|
|
29
|
+
className?: string
|
|
30
|
+
style?: React.CSSProperties
|
|
31
|
+
/** Style applied to the inner content container (around inventory + JEI) */
|
|
32
|
+
contentStyle?: React.CSSProperties
|
|
33
|
+
children?: React.ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function InventoryOverlay({
|
|
37
|
+
type,
|
|
38
|
+
title,
|
|
39
|
+
showBackdrop = true,
|
|
40
|
+
backdropColor = 'rgba(0,0,0,0.5)',
|
|
41
|
+
onClose,
|
|
42
|
+
showJEI = false,
|
|
43
|
+
jeiItems = [],
|
|
44
|
+
jeiPosition = 'right',
|
|
45
|
+
jeiOnGetRecipes,
|
|
46
|
+
jeiOnGetUsages,
|
|
47
|
+
showHotbar = true,
|
|
48
|
+
className,
|
|
49
|
+
style,
|
|
50
|
+
contentStyle,
|
|
51
|
+
children,
|
|
52
|
+
}: InventoryOverlayProps) {
|
|
53
|
+
const { heldItem, sendAction, setHeldItem } = useInventoryContext()
|
|
54
|
+
const { scale } = useScale()
|
|
55
|
+
|
|
56
|
+
// Recipe navigation stack — when non-empty, RecipeInventoryView replaces InventoryWindow
|
|
57
|
+
const [recipeNavStack, setRecipeNavStack] = useState<RecipeNavFrame[]>([])
|
|
58
|
+
|
|
59
|
+
const pushRecipeFrame = useCallback((frame: RecipeNavFrame) => {
|
|
60
|
+
setRecipeNavStack((s) => [...s, frame])
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
const popRecipeFrame = useCallback(() => {
|
|
64
|
+
setRecipeNavStack((s) => s.slice(0, -1))
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const handleGuideIndexChange = useCallback((idx: number) => {
|
|
68
|
+
setRecipeNavStack((s) => {
|
|
69
|
+
const next = [...s]
|
|
70
|
+
next[next.length - 1] = { ...next[next.length - 1], guideIndex: idx }
|
|
71
|
+
return next
|
|
72
|
+
})
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
// Called by RecipeInventoryView when R/U is pressed over a recipe ingredient
|
|
76
|
+
const handleRecipePushFrame = useCallback((item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => {
|
|
77
|
+
const getter = mode === 'recipes' ? jeiOnGetRecipes : jeiOnGetUsages
|
|
78
|
+
if (!getter) return
|
|
79
|
+
const jeiItem: JEIItem = { type: item.type, name: item.name, displayName: item.displayName, count: item.count, metadata: item.metadata }
|
|
80
|
+
const guides = getter(jeiItem)
|
|
81
|
+
if (guides.length === 0) return
|
|
82
|
+
pushRecipeFrame({ item, mode, guides, guideIndex: 0 })
|
|
83
|
+
}, [jeiOnGetRecipes, jeiOnGetUsages, pushRecipeFrame])
|
|
84
|
+
|
|
85
|
+
// Fires for any click that isn't stopped by an interactive panel (inventory, hotbar, JEI, etc.)
|
|
86
|
+
const handleBackdropClick = useCallback(() => {
|
|
87
|
+
if (heldItem) {
|
|
88
|
+
sendAction({ type: 'drop', slotIndex: -1, all: true })
|
|
89
|
+
setHeldItem(null)
|
|
90
|
+
} else {
|
|
91
|
+
onClose?.()
|
|
92
|
+
}
|
|
93
|
+
}, [heldItem, sendAction, setHeldItem, onClose])
|
|
94
|
+
|
|
95
|
+
const jeiPanel = showJEI ? (
|
|
96
|
+
<JEI
|
|
97
|
+
items={jeiItems}
|
|
98
|
+
position={jeiPosition}
|
|
99
|
+
onGetRecipes={jeiOnGetRecipes}
|
|
100
|
+
onGetUsages={jeiOnGetUsages}
|
|
101
|
+
onPushRecipeFrame={pushRecipeFrame}
|
|
102
|
+
/>
|
|
103
|
+
) : null
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<div
|
|
108
|
+
className={['mc-inv-overlay', className].filter(Boolean).join(' ')}
|
|
109
|
+
onClick={handleBackdropClick}
|
|
110
|
+
style={{
|
|
111
|
+
position: 'absolute',
|
|
112
|
+
inset: 0,
|
|
113
|
+
background: showBackdrop ? backdropColor : 'transparent',
|
|
114
|
+
cursor: 'default',
|
|
115
|
+
zIndex: 1,
|
|
116
|
+
...style,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
{/* Centered anchor — inventory always stays centered */}
|
|
120
|
+
<div
|
|
121
|
+
className="mc-inv-overlay-center"
|
|
122
|
+
style={{
|
|
123
|
+
position: 'absolute',
|
|
124
|
+
left: '50%',
|
|
125
|
+
top: '50%',
|
|
126
|
+
transform: 'translate(-50%, -50%)',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<div className="mc-inv-overlay-content" style={{ position: 'relative', ...contentStyle }}>
|
|
130
|
+
{/* JEI on left — stopPropagation so clicks on JEI don't close the overlay */}
|
|
131
|
+
{showJEI && jeiPosition === 'left' && (
|
|
132
|
+
<div
|
|
133
|
+
className="mc-inv-overlay-jei mc-inv-overlay-jei-left"
|
|
134
|
+
onClick={(e) => e.stopPropagation()}
|
|
135
|
+
style={{
|
|
136
|
+
position: 'absolute',
|
|
137
|
+
right: `calc(100% + ${4 * scale}px)`,
|
|
138
|
+
top: 0,
|
|
139
|
+
zIndex: 5,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{jeiPanel}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Inventory / Recipe view — stopPropagation so clicks inside don't close the overlay */}
|
|
147
|
+
<div className="mc-inv-overlay-window" onClick={(e) => e.stopPropagation()}>
|
|
148
|
+
{recipeNavStack.length > 0 ? (
|
|
149
|
+
<RecipeInventoryView
|
|
150
|
+
navStack={recipeNavStack}
|
|
151
|
+
onBack={popRecipeFrame}
|
|
152
|
+
onGuideIndexChange={handleGuideIndexChange}
|
|
153
|
+
onPushFrame={handleRecipePushFrame}
|
|
154
|
+
/>
|
|
155
|
+
) : (
|
|
156
|
+
<InventoryWindow type={type} title={title} />
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Hotbar — stopPropagation */}
|
|
161
|
+
{showHotbar && (
|
|
162
|
+
<div
|
|
163
|
+
className="mc-inv-overlay-hotbar"
|
|
164
|
+
onClick={(e) => e.stopPropagation()}
|
|
165
|
+
style={{ marginTop: 8 * scale }}
|
|
166
|
+
>
|
|
167
|
+
<Hotbar />
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* JEI on right — stopPropagation */}
|
|
172
|
+
{showJEI && jeiPosition === 'right' && (
|
|
173
|
+
<div
|
|
174
|
+
className="mc-inv-overlay-jei mc-inv-overlay-jei-right"
|
|
175
|
+
onClick={(e) => e.stopPropagation()}
|
|
176
|
+
style={{
|
|
177
|
+
position: 'absolute',
|
|
178
|
+
left: `calc(100% + ${4 * scale}px)`,
|
|
179
|
+
top: 0,
|
|
180
|
+
zIndex: 5,
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{jeiPanel}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Extra children (notes, etc.) */}
|
|
188
|
+
{children}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<CursorItem />
|
|
194
|
+
</>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useInventoryContext } from '../../context/InventoryContext'
|
|
3
|
+
import { useScale } from '../../context/ScaleContext'
|
|
4
|
+
|
|
5
|
+
const ENCHANTMENT_NAMES: Record<number, string> = {
|
|
6
|
+
0: 'Protection',
|
|
7
|
+
1: 'Fire Protection',
|
|
8
|
+
2: 'Feather Falling',
|
|
9
|
+
3: 'Blast Protection',
|
|
10
|
+
4: 'Projectile Protection',
|
|
11
|
+
5: 'Respiration',
|
|
12
|
+
6: 'Aqua Affinity',
|
|
13
|
+
7: 'Thorns',
|
|
14
|
+
8: 'Depth Strider',
|
|
15
|
+
9: 'Frost Walker',
|
|
16
|
+
10: 'Binding Curse',
|
|
17
|
+
16: 'Sharpness',
|
|
18
|
+
17: 'Smite',
|
|
19
|
+
18: 'Bane of Arthropods',
|
|
20
|
+
19: 'Knockback',
|
|
21
|
+
20: 'Fire Aspect',
|
|
22
|
+
21: 'Looting',
|
|
23
|
+
22: 'Sweeping Edge',
|
|
24
|
+
32: 'Efficiency',
|
|
25
|
+
33: 'Silk Touch',
|
|
26
|
+
34: 'Unbreaking',
|
|
27
|
+
35: 'Fortune',
|
|
28
|
+
48: 'Power',
|
|
29
|
+
49: 'Punch',
|
|
30
|
+
50: 'Flame',
|
|
31
|
+
51: 'Infinity',
|
|
32
|
+
61: 'Luck of the Sea',
|
|
33
|
+
62: 'Lure',
|
|
34
|
+
65: 'Loyalty',
|
|
35
|
+
66: 'Impaling',
|
|
36
|
+
67: 'Riptide',
|
|
37
|
+
68: 'Channeling',
|
|
38
|
+
70: 'Multishot',
|
|
39
|
+
71: 'Quick Charge',
|
|
40
|
+
72: 'Piercing',
|
|
41
|
+
73: 'Mending',
|
|
42
|
+
74: 'Vanishing Curse',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface EnchantmentOptionsProps {
|
|
46
|
+
properties: Record<string, number>
|
|
47
|
+
x: number
|
|
48
|
+
y: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const OPTION_KEYS = [
|
|
52
|
+
{ levelKey: 'topEnchantLevel', idKey: 'topEnchantId', slot: 0 },
|
|
53
|
+
{ levelKey: 'middleEnchantLevel', idKey: 'middleEnchantId', slot: 1 },
|
|
54
|
+
{ levelKey: 'bottomEnchantLevel', idKey: 'bottomEnchantId', slot: 2 },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
export function EnchantmentOptions({ properties, x, y }: EnchantmentOptionsProps) {
|
|
58
|
+
const { sendAction } = useInventoryContext()
|
|
59
|
+
const { scale } = useScale()
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
style={{
|
|
64
|
+
position: 'absolute',
|
|
65
|
+
left: x * scale,
|
|
66
|
+
top: y * scale,
|
|
67
|
+
display: 'flex',
|
|
68
|
+
flexDirection: 'column',
|
|
69
|
+
gap: 0,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{OPTION_KEYS.map(({ levelKey, idKey, slot }, i) => {
|
|
73
|
+
const level = properties[levelKey] ?? 0
|
|
74
|
+
const enchId = properties[idKey] ?? -1
|
|
75
|
+
const name = ENCHANTMENT_NAMES[enchId] ?? (enchId >= 0 ? `Enchant #${enchId}` : '???')
|
|
76
|
+
const disabled = level <= 0
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
key={i}
|
|
81
|
+
onClick={() => !disabled && sendAction({ type: 'enchant', enchantIndex: slot })}
|
|
82
|
+
style={{
|
|
83
|
+
width: 108 * scale,
|
|
84
|
+
height: 19 * scale,
|
|
85
|
+
display: 'flex',
|
|
86
|
+
alignItems: 'center',
|
|
87
|
+
padding: `0 ${4 * scale}px`,
|
|
88
|
+
gap: 4 * scale,
|
|
89
|
+
background: disabled ? 'rgba(0,0,0,0.3)' : 'rgba(0,60,0,0.5)',
|
|
90
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
91
|
+
border: `${scale}px solid ${disabled ? '#333333' : '#446644'}`,
|
|
92
|
+
boxSizing: 'border-box',
|
|
93
|
+
fontSize: 6 * scale,
|
|
94
|
+
fontFamily: "'Minecraft', monospace",
|
|
95
|
+
color: disabled ? '#555555' : '#80c060',
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<span style={{ color: disabled ? '#555' : '#558855', fontWeight: 'bold' }}>
|
|
99
|
+
{level}
|
|
100
|
+
</span>
|
|
101
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
102
|
+
{disabled ? '' : name}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|