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.
Files changed (51) hide show
  1. package/README.md +566 -0
  2. package/package.json +37 -0
  3. package/src/InventoryGUI.tsx +108 -0
  4. package/src/cache/textureCache.ts +95 -0
  5. package/src/components/CursorItem/CursorItem.tsx +94 -0
  6. package/src/components/CursorItem/index.ts +1 -0
  7. package/src/components/HUD/HUD.tsx +11 -0
  8. package/src/components/HUD/index.ts +1 -0
  9. package/src/components/Hotbar/Hotbar.tsx +180 -0
  10. package/src/components/Hotbar/index.ts +1 -0
  11. package/src/components/InventoryOverlay/InventoryOverlay.tsx +196 -0
  12. package/src/components/InventoryOverlay/index.ts +2 -0
  13. package/src/components/InventoryWindow/EnchantmentOptions.tsx +109 -0
  14. package/src/components/InventoryWindow/InventoryBackground.tsx +110 -0
  15. package/src/components/InventoryWindow/InventoryWindow.tsx +120 -0
  16. package/src/components/InventoryWindow/ProgressBar.tsx +78 -0
  17. package/src/components/InventoryWindow/VillagerTradeList.tsx +136 -0
  18. package/src/components/InventoryWindow/index.ts +5 -0
  19. package/src/components/ItemCanvas/ItemCanvas.tsx +154 -0
  20. package/src/components/ItemCanvas/index.ts +1 -0
  21. package/src/components/JEI/JEI.module.css +37 -0
  22. package/src/components/JEI/JEI.tsx +303 -0
  23. package/src/components/JEI/index.ts +2 -0
  24. package/src/components/RecipeGuide/RecipeInventoryView.tsx +293 -0
  25. package/src/components/RecipeGuide/index.ts +1 -0
  26. package/src/components/Slot/Slot.module.css +111 -0
  27. package/src/components/Slot/Slot.tsx +363 -0
  28. package/src/components/Slot/index.ts +1 -0
  29. package/src/components/Text/MessageFormatted.css +5 -0
  30. package/src/components/Text/MessageFormatted.tsx +79 -0
  31. package/src/components/Text/MessageFormattedString.tsx +74 -0
  32. package/src/components/Text/chatUtils.ts +172 -0
  33. package/src/components/Tooltip/Tooltip.module.css +56 -0
  34. package/src/components/Tooltip/Tooltip.tsx +130 -0
  35. package/src/components/Tooltip/index.ts +1 -0
  36. package/src/connector/demo.ts +213 -0
  37. package/src/connector/index.ts +4 -0
  38. package/src/connector/mineflayer.ts +113 -0
  39. package/src/connector/types.ts +41 -0
  40. package/src/context/InventoryContext.tsx +157 -0
  41. package/src/context/ScaleContext.tsx +73 -0
  42. package/src/context/TextureContext.tsx +70 -0
  43. package/src/globals.d.ts +4 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +41 -0
  45. package/src/hooks/useMobile.ts +28 -0
  46. package/src/index.tsx +65 -0
  47. package/src/mount.tsx +52 -0
  48. package/src/registry/index.ts +21 -0
  49. package/src/registry/inventories.ts +612 -0
  50. package/src/styles/tokens.css +47 -0
  51. 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,2 @@
1
+ export { InventoryOverlay } from './InventoryOverlay'
2
+ export type { InventoryOverlayProps } from './InventoryOverlay'
@@ -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
+ }