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,303 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import type { ItemStack, RecipeGuide, RecipeNavFrame } from '../../types'
|
|
3
|
+
import { useScale } from '../../context/ScaleContext'
|
|
4
|
+
import { useInventoryContext } from '../../context/InventoryContext'
|
|
5
|
+
import { Slot } from '../Slot'
|
|
6
|
+
import styles from './JEI.module.css'
|
|
7
|
+
|
|
8
|
+
export interface JEIItem {
|
|
9
|
+
type: number
|
|
10
|
+
name: string
|
|
11
|
+
displayName: string
|
|
12
|
+
count?: number
|
|
13
|
+
metadata?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface JEIProps {
|
|
17
|
+
items?: JEIItem[]
|
|
18
|
+
position?: 'left' | 'right'
|
|
19
|
+
width?: number
|
|
20
|
+
onItemClick?: (item: JEIItem) => void
|
|
21
|
+
onItemMiddleClick?: (item: JEIItem) => void
|
|
22
|
+
/** Called when R is pressed while hovering an item — return list of recipes to display */
|
|
23
|
+
onGetRecipes?: (item: JEIItem) => RecipeGuide[]
|
|
24
|
+
/** Called when U is pressed while hovering an item — return list of usages to display */
|
|
25
|
+
onGetUsages?: (item: JEIItem) => RecipeGuide[]
|
|
26
|
+
/** Called when R/U shortcut should push a new recipe frame (managed externally by InventoryOverlay) */
|
|
27
|
+
onPushRecipeFrame?: (frame: RecipeNavFrame) => void
|
|
28
|
+
className?: string
|
|
29
|
+
style?: React.CSSProperties
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ITEMS_PER_ROW = 6
|
|
33
|
+
const PADDING = 4
|
|
34
|
+
const FAV_KEY = 'mc-inv-jei-favorites'
|
|
35
|
+
|
|
36
|
+
function getItemKey(item: JEIItem): string {
|
|
37
|
+
return `${item.type}:${item.metadata ?? 0}:${item.name}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadFavorites(): Set<string> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = localStorage.getItem(FAV_KEY)
|
|
43
|
+
return new Set(raw ? (JSON.parse(raw) as string[]) : [])
|
|
44
|
+
} catch {
|
|
45
|
+
return new Set()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function saveFavorites(favs: Set<string>) {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(FAV_KEY, JSON.stringify([...favs]))
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function JEI({
|
|
56
|
+
items = [],
|
|
57
|
+
position = 'right',
|
|
58
|
+
width,
|
|
59
|
+
onItemClick,
|
|
60
|
+
onItemMiddleClick,
|
|
61
|
+
onGetRecipes,
|
|
62
|
+
onGetUsages,
|
|
63
|
+
onPushRecipeFrame,
|
|
64
|
+
className,
|
|
65
|
+
style,
|
|
66
|
+
}: JEIProps) {
|
|
67
|
+
const { scale, contentSize } = useScale()
|
|
68
|
+
const { hoveredSlot } = useInventoryContext()
|
|
69
|
+
const [search, setSearch] = useState('')
|
|
70
|
+
const [page, setPage] = useState(0)
|
|
71
|
+
const [showFavorites, setShowFavorites] = useState(false)
|
|
72
|
+
const [favorites, setFavorites] = useState<Set<string>>(loadFavorites)
|
|
73
|
+
// Map from negative slot index → JEI item (to enable F-key and R/U on hover)
|
|
74
|
+
const slotToItemRef = useRef<Map<number, JEIItem>>(new Map())
|
|
75
|
+
|
|
76
|
+
const pushRecipeFrame = useCallback((targetItem: JEIItem, mode: 'recipes' | 'usages') => {
|
|
77
|
+
const getter = mode === 'recipes' ? onGetRecipes : onGetUsages
|
|
78
|
+
if (!getter || !onPushRecipeFrame) return
|
|
79
|
+
const guides = getter(targetItem)
|
|
80
|
+
if (guides.length === 0) return
|
|
81
|
+
onPushRecipeFrame({ item: targetItem, mode, guides, guideIndex: 0 })
|
|
82
|
+
}, [onGetRecipes, onGetUsages, onPushRecipeFrame])
|
|
83
|
+
|
|
84
|
+
const padding = PADDING * scale
|
|
85
|
+
const itemSize = contentSize
|
|
86
|
+
const cols = ITEMS_PER_ROW
|
|
87
|
+
const panelWidth = width ?? cols * itemSize + padding * 2
|
|
88
|
+
const rows = Math.floor((300 * scale) / itemSize)
|
|
89
|
+
const itemsPerPage = cols * rows
|
|
90
|
+
|
|
91
|
+
const toggleFavorite = useCallback((key: string) => {
|
|
92
|
+
setFavorites((prev) => {
|
|
93
|
+
const next = new Set(prev)
|
|
94
|
+
if (next.has(key)) next.delete(key)
|
|
95
|
+
else next.add(key)
|
|
96
|
+
saveFavorites(next)
|
|
97
|
+
return next
|
|
98
|
+
})
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
// F / R / U keyboard handlers
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const handler = (e: KeyboardEvent) => {
|
|
104
|
+
// F: toggle favorite for hovered JEI item
|
|
105
|
+
if (e.code === 'KeyF' && hoveredSlot !== null && hoveredSlot < 0) {
|
|
106
|
+
const item = slotToItemRef.current.get(hoveredSlot)
|
|
107
|
+
if (item) toggleFavorite(getItemKey(item))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// R / U: show recipes / usages for hovered JEI item slot
|
|
112
|
+
if (e.code !== 'KeyR' && e.code !== 'KeyU') return
|
|
113
|
+
const mode = e.code === 'KeyR' ? 'recipes' : 'usages'
|
|
114
|
+
|
|
115
|
+
if (hoveredSlot !== null && hoveredSlot < 0) {
|
|
116
|
+
const item = slotToItemRef.current.get(hoveredSlot)
|
|
117
|
+
if (item) pushRecipeFrame(item, mode)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
window.addEventListener('keydown', handler)
|
|
121
|
+
return () => window.removeEventListener('keydown', handler)
|
|
122
|
+
}, [hoveredSlot, toggleFavorite, pushRecipeFrame])
|
|
123
|
+
|
|
124
|
+
// Expose hoveredSlot info for parent-level nested navigation
|
|
125
|
+
// (RecipeInventoryView handles its own R/U, JEI only needs to handle its own items)
|
|
126
|
+
|
|
127
|
+
const baseList = useMemo(() => {
|
|
128
|
+
if (showFavorites) return items.filter((i) => favorites.has(getItemKey(i)))
|
|
129
|
+
return items
|
|
130
|
+
}, [items, showFavorites, favorites])
|
|
131
|
+
|
|
132
|
+
const filteredItems = useMemo(() => {
|
|
133
|
+
if (!search.trim()) return baseList
|
|
134
|
+
const q = search.toLowerCase()
|
|
135
|
+
return baseList.filter(
|
|
136
|
+
(item) =>
|
|
137
|
+
item.displayName.toLowerCase().includes(q) ||
|
|
138
|
+
item.name.toLowerCase().includes(q) ||
|
|
139
|
+
String(item.type).includes(q),
|
|
140
|
+
)
|
|
141
|
+
}, [baseList, search])
|
|
142
|
+
|
|
143
|
+
const totalPages = Math.ceil(filteredItems.length / itemsPerPage)
|
|
144
|
+
const visibleItems = filteredItems.slice(page * itemsPerPage, (page + 1) * itemsPerPage)
|
|
145
|
+
|
|
146
|
+
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
147
|
+
setSearch(e.target.value)
|
|
148
|
+
setPage(0)
|
|
149
|
+
}, [])
|
|
150
|
+
|
|
151
|
+
const handleWheel = useCallback(
|
|
152
|
+
(e: React.WheelEvent<HTMLDivElement>) => {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
setPage((prev) => {
|
|
155
|
+
if (e.deltaY > 0) return Math.min(prev + 1, totalPages - 1)
|
|
156
|
+
return Math.max(prev - 1, 0)
|
|
157
|
+
})
|
|
158
|
+
},
|
|
159
|
+
[totalPages],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Build slot→item map on each render
|
|
163
|
+
slotToItemRef.current.clear()
|
|
164
|
+
visibleItems.forEach((jeiItem, i) => {
|
|
165
|
+
const slotIndex = -(page * itemsPerPage + i + 1)
|
|
166
|
+
slotToItemRef.current.set(slotIndex, jeiItem)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
className={['mc-inv-root', styles.jei, className].filter(Boolean).join(' ')}
|
|
172
|
+
style={{
|
|
173
|
+
width: panelWidth,
|
|
174
|
+
background: '#c6c6c6',
|
|
175
|
+
border: `${scale}px solid #555555`,
|
|
176
|
+
display: 'flex',
|
|
177
|
+
flexDirection: 'column',
|
|
178
|
+
...style,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{/* Search + pagination controls */}
|
|
182
|
+
<div style={{ padding: `${padding}px`, borderBottom: `${scale}px solid #555555` }}>
|
|
183
|
+
<input
|
|
184
|
+
type="text"
|
|
185
|
+
value={search}
|
|
186
|
+
onChange={handleSearchChange}
|
|
187
|
+
placeholder="Search items…"
|
|
188
|
+
className={styles.searchInput}
|
|
189
|
+
style={{
|
|
190
|
+
width: '100%',
|
|
191
|
+
padding: `${2 * scale}px`,
|
|
192
|
+
fontSize: 6 * scale,
|
|
193
|
+
background: '#8b8b8b',
|
|
194
|
+
border: `${scale}px solid #373737`,
|
|
195
|
+
color: '#ffffff',
|
|
196
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
197
|
+
outline: 'none',
|
|
198
|
+
boxSizing: 'border-box',
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
{/* Prev / Next / Favorites below search bar */}
|
|
202
|
+
<div style={{
|
|
203
|
+
display: 'flex',
|
|
204
|
+
alignItems: 'center',
|
|
205
|
+
gap: scale,
|
|
206
|
+
marginTop: 2 * scale,
|
|
207
|
+
fontSize: 6 * scale,
|
|
208
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
209
|
+
}}>
|
|
210
|
+
<button
|
|
211
|
+
className={styles.pageBtn}
|
|
212
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
213
|
+
disabled={page === 0}
|
|
214
|
+
style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
|
|
215
|
+
title="Previous page"
|
|
216
|
+
>
|
|
217
|
+
◀
|
|
218
|
+
</button>
|
|
219
|
+
<span style={{ flex: 1, textAlign: 'center', color: '#404040' }}>
|
|
220
|
+
{page + 1} / {Math.max(1, totalPages)}
|
|
221
|
+
</span>
|
|
222
|
+
<button
|
|
223
|
+
className={styles.pageBtn}
|
|
224
|
+
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
225
|
+
disabled={page >= totalPages - 1}
|
|
226
|
+
style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
|
|
227
|
+
title="Next page"
|
|
228
|
+
>
|
|
229
|
+
▶
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
className={[styles.pageBtn, showFavorites ? styles.pageBtnActive : ''].join(' ')}
|
|
233
|
+
onClick={() => { setShowFavorites((v) => !v); setPage(0) }}
|
|
234
|
+
style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
|
|
235
|
+
title={showFavorites ? 'Show all items' : 'Show favorites (F to toggle)'}
|
|
236
|
+
>
|
|
237
|
+
★
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Item grid — no slot backgrounds, just raw items */}
|
|
243
|
+
<div
|
|
244
|
+
style={{
|
|
245
|
+
position: 'relative',
|
|
246
|
+
display: 'flex',
|
|
247
|
+
flexWrap: 'wrap',
|
|
248
|
+
padding: `${padding}px`,
|
|
249
|
+
gap: 0,
|
|
250
|
+
flex: 1,
|
|
251
|
+
alignContent: 'flex-start',
|
|
252
|
+
overflowY: 'hidden',
|
|
253
|
+
}}
|
|
254
|
+
onWheel={handleWheel}
|
|
255
|
+
>
|
|
256
|
+
{visibleItems.map((jeiItem, i) => {
|
|
257
|
+
const itemStack: ItemStack = {
|
|
258
|
+
type: jeiItem.type,
|
|
259
|
+
name: jeiItem.name,
|
|
260
|
+
count: jeiItem.count ?? 1,
|
|
261
|
+
metadata: jeiItem.metadata,
|
|
262
|
+
displayName: jeiItem.displayName,
|
|
263
|
+
}
|
|
264
|
+
const slotIndex = -(page * itemsPerPage + i + 1)
|
|
265
|
+
const isFav = favorites.has(getItemKey(jeiItem))
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
key={`${jeiItem.type}-${jeiItem.metadata ?? 0}-${i}`}
|
|
269
|
+
style={{ position: 'relative' }}
|
|
270
|
+
>
|
|
271
|
+
<Slot
|
|
272
|
+
index={slotIndex}
|
|
273
|
+
item={itemStack}
|
|
274
|
+
size={itemSize}
|
|
275
|
+
noBackground
|
|
276
|
+
onClickOverride={(button, mode) => {
|
|
277
|
+
if (button === 'left' && mode === 'normal' && onItemClick) {
|
|
278
|
+
onItemClick(jeiItem)
|
|
279
|
+
} else if (button === 'middle' && onItemMiddleClick) {
|
|
280
|
+
onItemMiddleClick(jeiItem)
|
|
281
|
+
}
|
|
282
|
+
}}
|
|
283
|
+
/>
|
|
284
|
+
{isFav && (
|
|
285
|
+
<span style={{
|
|
286
|
+
position: 'absolute',
|
|
287
|
+
top: 0,
|
|
288
|
+
right: 0,
|
|
289
|
+
fontSize: Math.round(5 * scale),
|
|
290
|
+
color: '#ffcc00',
|
|
291
|
+
lineHeight: 1,
|
|
292
|
+
pointerEvents: 'none',
|
|
293
|
+
textShadow: `0 0 ${scale}px rgba(0,0,0,0.8)`,
|
|
294
|
+
}}>★</span>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
})}
|
|
299
|
+
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { RecipeNavFrame, ItemStack } from '../../types'
|
|
4
|
+
import { ItemCanvas } from '../ItemCanvas'
|
|
5
|
+
import { Tooltip } from '../Tooltip'
|
|
6
|
+
import { useScale } from '../../context/ScaleContext'
|
|
7
|
+
import { InventoryBackground } from '../InventoryWindow/InventoryBackground'
|
|
8
|
+
import { getInventoryType } from '../../registry'
|
|
9
|
+
|
|
10
|
+
interface RecipeInventoryViewProps {
|
|
11
|
+
navStack: RecipeNavFrame[]
|
|
12
|
+
onBack: () => void
|
|
13
|
+
onGuideIndexChange: (idx: number) => void
|
|
14
|
+
/** Called when R or U is pressed while hovering a recipe item (nested navigation) */
|
|
15
|
+
onPushFrame: (item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Slot item with a Tooltip, used inside the recipe view */
|
|
19
|
+
function RecipeItemCell({
|
|
20
|
+
item,
|
|
21
|
+
x,
|
|
22
|
+
y,
|
|
23
|
+
size,
|
|
24
|
+
onHover,
|
|
25
|
+
}: {
|
|
26
|
+
item: ItemStack | null
|
|
27
|
+
x: number
|
|
28
|
+
y: number
|
|
29
|
+
size: number
|
|
30
|
+
onHover: (item: ItemStack | null, mx: number, my: number) => void
|
|
31
|
+
}) {
|
|
32
|
+
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
|
|
33
|
+
const [hovered, setHovered] = useState(false)
|
|
34
|
+
|
|
35
|
+
if (!item) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
position: 'absolute',
|
|
40
|
+
left: x,
|
|
41
|
+
top: y,
|
|
42
|
+
width: size,
|
|
43
|
+
height: size,
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{ position: 'absolute', left: x, top: y, width: size, height: size, cursor: 'default' }}
|
|
52
|
+
onMouseEnter={(e) => {
|
|
53
|
+
setTooltipPos({ x: e.clientX, y: e.clientY })
|
|
54
|
+
setHovered(true)
|
|
55
|
+
onHover(item, e.clientX, e.clientY)
|
|
56
|
+
}}
|
|
57
|
+
onMouseLeave={() => {
|
|
58
|
+
setHovered(false)
|
|
59
|
+
onHover(null, 0, 0)
|
|
60
|
+
}}
|
|
61
|
+
onMouseMove={(e) => {
|
|
62
|
+
setTooltipPos({ x: e.clientX, y: e.clientY })
|
|
63
|
+
onHover(item, e.clientX, e.clientY)
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<ItemCanvas item={item} size={size} style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }} />
|
|
67
|
+
{hovered && createPortal(
|
|
68
|
+
<Tooltip item={item} mouseX={tooltipPos.x} mouseY={tooltipPos.y} visible />,
|
|
69
|
+
document.body
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders the current recipe guide using the actual inventory background layout
|
|
77
|
+
* (crafting_table for crafting, furnace for smelting). Replaces the main
|
|
78
|
+
* InventoryWindow while a recipe is being browsed.
|
|
79
|
+
*/
|
|
80
|
+
export function RecipeInventoryView({
|
|
81
|
+
navStack,
|
|
82
|
+
onBack,
|
|
83
|
+
onGuideIndexChange,
|
|
84
|
+
onPushFrame,
|
|
85
|
+
}: RecipeInventoryViewProps) {
|
|
86
|
+
const { scale, contentSize } = useScale()
|
|
87
|
+
// Registry coords already point to item area — no border offset needed
|
|
88
|
+
const borderPx = 0
|
|
89
|
+
const frame = navStack[navStack.length - 1]
|
|
90
|
+
const guide = frame.guides[frame.guideIndex]
|
|
91
|
+
const hoveredItemRef = useRef<RecipeNavFrame['item'] | null>(null)
|
|
92
|
+
|
|
93
|
+
const handleHoverItem = useCallback((item: ItemStack | null) => {
|
|
94
|
+
if (!item) { hoveredItemRef.current = null; return }
|
|
95
|
+
hoveredItemRef.current = {
|
|
96
|
+
type: item.type,
|
|
97
|
+
name: item.name ?? '',
|
|
98
|
+
displayName: item.displayName ?? item.name ?? `Item #${item.type}`,
|
|
99
|
+
count: item.count,
|
|
100
|
+
metadata: item.metadata,
|
|
101
|
+
}
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
// R / U for nested navigation on hovered recipe items
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const handler = (e: KeyboardEvent) => {
|
|
107
|
+
if (e.code !== 'KeyR' && e.code !== 'KeyU') return
|
|
108
|
+
const item = hoveredItemRef.current
|
|
109
|
+
if (!item) return
|
|
110
|
+
onPushFrame(item, e.code === 'KeyR' ? 'recipes' : 'usages')
|
|
111
|
+
}
|
|
112
|
+
window.addEventListener('keydown', handler)
|
|
113
|
+
return () => window.removeEventListener('keydown', handler)
|
|
114
|
+
}, [onPushFrame])
|
|
115
|
+
|
|
116
|
+
// Escape = go back one level
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const handler = (e: KeyboardEvent) => {
|
|
119
|
+
if (e.code === 'Escape') onBack()
|
|
120
|
+
}
|
|
121
|
+
window.addEventListener('keydown', handler)
|
|
122
|
+
return () => window.removeEventListener('keydown', handler)
|
|
123
|
+
}, [onBack])
|
|
124
|
+
|
|
125
|
+
const isMultiFrame = navStack.length > 1
|
|
126
|
+
|
|
127
|
+
// Determine layout type and get definition
|
|
128
|
+
const layoutType = guide.type === 'smelting' ? 'furnace' : 'crafting_table'
|
|
129
|
+
const def = getInventoryType(layoutType)!
|
|
130
|
+
|
|
131
|
+
// Container-only slots (no player inv / hotbar, group !== 'player' && group !== 'hotbar')
|
|
132
|
+
const containerSlots = def.slots.filter(
|
|
133
|
+
(s) => s.group !== 'player' && s.group !== 'hotbar',
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Map recipe items to slot indices
|
|
137
|
+
const slotItems = new Map<number, ItemStack | null>()
|
|
138
|
+
|
|
139
|
+
if (guide.type === 'crafting') {
|
|
140
|
+
// Slot 0 = result, slots 1-9 = 3x3 grid
|
|
141
|
+
slotItems.set(0, guide.result ?? null)
|
|
142
|
+
const ingr = guide.ingredients ?? []
|
|
143
|
+
for (let i = 0; i < 9; i++) {
|
|
144
|
+
slotItems.set(i + 1, ingr[i] ?? null)
|
|
145
|
+
}
|
|
146
|
+
} else if (guide.type === 'smelting') {
|
|
147
|
+
slotItems.set(0, guide.input ?? null)
|
|
148
|
+
slotItems.set(1, guide.fuel ?? null)
|
|
149
|
+
slotItems.set(2, guide.result ?? null)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const totalGuides = frame.guides.length
|
|
153
|
+
const navFontSize = Math.max(6, Math.round(6 * scale))
|
|
154
|
+
const navPx = Math.max(1, Math.round(scale))
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
className="mc-inv-recipe-view"
|
|
159
|
+
style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'stretch' }}
|
|
160
|
+
>
|
|
161
|
+
{/* ── Navigation bar ABOVE the inventory background ── */}
|
|
162
|
+
<div
|
|
163
|
+
className="mc-inv-recipe-nav"
|
|
164
|
+
style={{
|
|
165
|
+
display: 'flex',
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
gap: 4 * scale,
|
|
168
|
+
marginBottom: 3 * scale,
|
|
169
|
+
fontSize: navFontSize,
|
|
170
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<NavBtn scale={scale} onClick={onBack}>
|
|
174
|
+
{isMultiFrame ? '◀ Back' : '✕ Close'}
|
|
175
|
+
</NavBtn>
|
|
176
|
+
|
|
177
|
+
{/* Mode label */}
|
|
178
|
+
<span style={{ color: '#505050', flex: 1 }}>
|
|
179
|
+
{frame.mode === 'recipes' ? 'Recipes for' : 'Usages of'}{' '}
|
|
180
|
+
<span style={{ color: '#205090' }}>{frame.item.displayName}</span>
|
|
181
|
+
{navStack.length > 1 && (
|
|
182
|
+
<span style={{ color: '#aaa', marginLeft: 4 * scale }}>
|
|
183
|
+
{'▸'.repeat(navStack.length - 1)}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</span>
|
|
187
|
+
|
|
188
|
+
{/* Prev / next for multiple guides */}
|
|
189
|
+
{totalGuides > 1 && (
|
|
190
|
+
<>
|
|
191
|
+
<NavBtn
|
|
192
|
+
scale={scale}
|
|
193
|
+
onClick={() => onGuideIndexChange(Math.max(0, frame.guideIndex - 1))}
|
|
194
|
+
disabled={frame.guideIndex === 0}
|
|
195
|
+
>◀</NavBtn>
|
|
196
|
+
<span style={{ color: '#404040', minWidth: 40 * scale, textAlign: 'center' }}>
|
|
197
|
+
{frame.guideIndex + 1} / {totalGuides}
|
|
198
|
+
</span>
|
|
199
|
+
<NavBtn
|
|
200
|
+
scale={scale}
|
|
201
|
+
onClick={() => onGuideIndexChange(Math.min(totalGuides - 1, frame.guideIndex + 1))}
|
|
202
|
+
disabled={frame.guideIndex >= totalGuides - 1}
|
|
203
|
+
>▶</NavBtn>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* ── Inventory background with container slots only ── */}
|
|
209
|
+
<InventoryBackground definition={def} title={guide.title}>
|
|
210
|
+
{containerSlots.map((slotDef) => {
|
|
211
|
+
const item = slotItems.get(slotDef.index) ?? null
|
|
212
|
+
return (
|
|
213
|
+
<RecipeItemCell
|
|
214
|
+
key={slotDef.index}
|
|
215
|
+
item={item}
|
|
216
|
+
x={slotDef.x * scale}
|
|
217
|
+
y={slotDef.y * scale}
|
|
218
|
+
size={slotDef.size ? slotDef.size * scale - 2 * navPx : contentSize}
|
|
219
|
+
onHover={handleHoverItem}
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
|
|
224
|
+
{/* Description for custom-type guides */}
|
|
225
|
+
{guide.description && (
|
|
226
|
+
<div style={{
|
|
227
|
+
position: 'absolute',
|
|
228
|
+
top: 24 * scale,
|
|
229
|
+
left: 8 * scale,
|
|
230
|
+
right: 8 * scale,
|
|
231
|
+
fontSize: navFontSize,
|
|
232
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
233
|
+
color: '#404040',
|
|
234
|
+
lineHeight: 1.5,
|
|
235
|
+
pointerEvents: 'none',
|
|
236
|
+
whiteSpace: 'pre-wrap',
|
|
237
|
+
wordBreak: 'break-word',
|
|
238
|
+
}}>
|
|
239
|
+
{guide.description}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</InventoryBackground>
|
|
243
|
+
|
|
244
|
+
{/* ── Hint below ── */}
|
|
245
|
+
<div style={{
|
|
246
|
+
marginTop: 2 * scale,
|
|
247
|
+
textAlign: 'center',
|
|
248
|
+
fontSize: Math.max(5, Math.round(5.5 * scale)),
|
|
249
|
+
fontFamily: "'Minecraftia', 'Minecraft', monospace",
|
|
250
|
+
color: 'rgba(80,80,80,0.7)',
|
|
251
|
+
pointerEvents: 'none',
|
|
252
|
+
}}>
|
|
253
|
+
Hover ingredient + R / U for nested lookup
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function NavBtn({
|
|
260
|
+
scale,
|
|
261
|
+
onClick,
|
|
262
|
+
disabled,
|
|
263
|
+
title,
|
|
264
|
+
children,
|
|
265
|
+
}: {
|
|
266
|
+
scale: number
|
|
267
|
+
onClick: () => void
|
|
268
|
+
disabled?: boolean
|
|
269
|
+
title?: string
|
|
270
|
+
children: React.ReactNode
|
|
271
|
+
}) {
|
|
272
|
+
return (
|
|
273
|
+
<button
|
|
274
|
+
onClick={onClick}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
title={title}
|
|
277
|
+
style={{
|
|
278
|
+
background: disabled ? '#8b8b8b' : '#c6c6c6',
|
|
279
|
+
border: `${Math.max(1, Math.round(scale))}px solid #555`,
|
|
280
|
+
color: disabled ? '#666' : '#404040',
|
|
281
|
+
fontFamily: 'inherit',
|
|
282
|
+
fontSize: 'inherit',
|
|
283
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
284
|
+
padding: `${Math.round(scale)}px ${Math.round(3 * scale)}px`,
|
|
285
|
+
lineHeight: 1,
|
|
286
|
+
outline: 'none',
|
|
287
|
+
flexShrink: 0,
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
{children}
|
|
291
|
+
</button>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RecipeInventoryView } from './RecipeInventoryView'
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/* Bare variant: no background/border (used in JEI grid) */
|
|
2
|
+
.slotBare {
|
|
3
|
+
cursor: default;
|
|
4
|
+
position: relative;
|
|
5
|
+
overflow: visible;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.slot {
|
|
9
|
+
cursor: default;
|
|
10
|
+
position: relative;
|
|
11
|
+
overflow: visible;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.hovered::after {
|
|
15
|
+
content: '';
|
|
16
|
+
position: absolute;
|
|
17
|
+
inset: 0;
|
|
18
|
+
background: rgba(255, 255, 255, 0.4);
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.highlighted::after {
|
|
23
|
+
content: '';
|
|
24
|
+
position: absolute;
|
|
25
|
+
inset: 0;
|
|
26
|
+
background: rgba(255, 255, 128, 0.4);
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dragTarget::after {
|
|
31
|
+
content: '';
|
|
32
|
+
position: absolute;
|
|
33
|
+
inset: 0;
|
|
34
|
+
background: rgba(255, 255, 255, 0.3);
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.resultSlot {
|
|
39
|
+
background: #8b8b8b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.disabled {
|
|
43
|
+
cursor: not-allowed;
|
|
44
|
+
opacity: 0.5;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.emptyLabel {
|
|
48
|
+
position: absolute;
|
|
49
|
+
inset: 0;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
color: rgba(255, 255, 255, 0.3);
|
|
54
|
+
font-family: 'Minecraft', monospace;
|
|
55
|
+
text-align: center;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
font-size: 0.6em;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Mobile overlay backdrop */
|
|
61
|
+
.mobileOverlay {
|
|
62
|
+
position: fixed;
|
|
63
|
+
inset: 0;
|
|
64
|
+
z-index: 10000;
|
|
65
|
+
background: rgba(0, 0, 0, 0.3);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.mobileMenu {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
background: #282828;
|
|
72
|
+
border: 2px solid #555555;
|
|
73
|
+
font-family: 'Minecraft', monospace;
|
|
74
|
+
image-rendering: pixelated;
|
|
75
|
+
z-index: 10001;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.mobileMenuTitle {
|
|
79
|
+
color: #ffffff;
|
|
80
|
+
font-weight: bold;
|
|
81
|
+
padding-bottom: 4px;
|
|
82
|
+
border-bottom: 1px solid #555555;
|
|
83
|
+
margin-bottom: 4px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.mobileBtn {
|
|
87
|
+
background: #404040;
|
|
88
|
+
color: #ffffff;
|
|
89
|
+
border: 1px solid #666666;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
padding: 4px 8px;
|
|
92
|
+
font-family: inherit;
|
|
93
|
+
font-size: inherit;
|
|
94
|
+
text-align: left;
|
|
95
|
+
transition: background 0.1s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.mobileBtn:hover,
|
|
99
|
+
.mobileBtn:active {
|
|
100
|
+
background: #555555;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.mobileBtnDanger {
|
|
104
|
+
color: #ff5555;
|
|
105
|
+
border-color: #aa0000;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.mobileBtnDanger:hover,
|
|
109
|
+
.mobileBtnDanger:active {
|
|
110
|
+
background: #330000;
|
|
111
|
+
}
|