minecraft-inventory 0.1.6 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-inventory",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "release": {
@@ -59,20 +59,44 @@ export function renderBlockIcon(
59
59
  ctx.imageSmoothingEnabled = false
60
60
  ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
61
61
 
62
- const s = OUTPUT_SIZE / 16
62
+ const s = OUTPUT_SIZE / 2 - 2 // face size with padding (14px at 32px canvas)
63
+ const ox = (OUTPUT_SIZE - 2 * s) / 2 // horizontal offset to center
64
+ const oy = (OUTPUT_SIZE - 2 * s) / 2 // vertical offset to center
63
65
  const [tx, ty, tw, th] = top
64
66
  const [lx, ly, lw, lh] = left
65
67
  const [rx, ry, rw, rh] = right
66
68
 
67
- // Isometric layout: top face tilted, left and right as sides
69
+ // Enable smoothing for isometric transforms (better diagonal edges)
70
+ ctx.imageSmoothingEnabled = true
71
+ ctx.imageSmoothingQuality = 'high'
72
+
73
+ // Isometric cube using affine transforms.
74
+ // Face vertices for a cube centered in OUTPUT_SIZE × OUTPUT_SIZE:
75
+ // Top: (s+ox, oy) → (2s+ox, s/2+oy) → (s+ox, s+oy) → (ox, s/2+oy)
76
+ // Left: (ox, s/2+oy) → (s+ox, s+oy) → (s+ox, 2s+oy) → (ox, 3s/2+oy)
77
+ // Right: (s+ox, s+oy) → (2s+ox, s/2+oy) → (2s+ox, 3s/2+oy) → (s+ox, 2s+oy)
78
+
79
+ // Top face
68
80
  ctx.save()
69
- ctx.translate(8 * s, 2 * s)
70
- ctx.rotate(-Math.PI / 4)
71
- ctx.drawImage(image, tx, ty, tw, th, -4 * s, -4 * s, 8 * s, 8 * s)
81
+ ctx.setTransform(1, 0.5, -1, 0.5, s + ox, oy)
82
+ ctx.drawImage(image, tx, ty, tw, th, 0, 0, s, s)
72
83
  ctx.restore()
73
84
 
74
- ctx.drawImage(image, lx, ly, lw, lh, 0, 12 * s, 10 * s, 10 * s)
75
- ctx.drawImage(image, rx, ry, rw, rh, 12 * s, 12 * s, 10 * s, 10 * s)
85
+ // Left face (darkened)
86
+ ctx.save()
87
+ ctx.setTransform(1, 0.5, 0, 1, ox, s / 2 + oy)
88
+ ctx.drawImage(image, lx, ly, lw, lh, 0, 0, s, s)
89
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'
90
+ ctx.fillRect(0, 0, s, s)
91
+ ctx.restore()
92
+
93
+ // Right face (slightly darkened)
94
+ ctx.save()
95
+ ctx.setTransform(1, -0.5, 0, 1, s + ox, s + oy)
96
+ ctx.drawImage(image, rx, ry, rw, rh, 0, 0, s, s)
97
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'
98
+ ctx.fillRect(0, 0, s, s)
99
+ ctx.restore()
76
100
 
77
101
  const dataUrl = canvas.toDataURL('image/png')
78
102
  resolve(dataUrl)
@@ -52,6 +52,8 @@ export interface InventoryOverlayProps {
52
52
  debugBounds?: boolean
53
53
  /** Show red debug outline around the inventory background */
54
54
  showDebug?: boolean
55
+ /** When true, entity display area shows layout debug bounds instead of the default image. */
56
+ entityDisplayDebug?: boolean
55
57
  /** Override entity display rendering. Pass a function returning JSX, or null to hide. */
56
58
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
57
59
  /** Hide the "INV" version watermark (opt-out) */
@@ -80,6 +82,7 @@ export function InventoryOverlay({
80
82
  children,
81
83
  debugBounds = false,
82
84
  showDebug = false,
85
+ entityDisplayDebug = false,
83
86
  renderEntity,
84
87
  noWatermark = false,
85
88
  }: InventoryOverlayProps) {
@@ -242,7 +245,14 @@ export function InventoryOverlay({
242
245
  onPushFrame={handleRecipePushFrame}
243
246
  />
244
247
  ) : (
245
- <InventoryWindow type={type} title={title} properties={properties} showDebug={showDebug} renderEntity={renderEntity} />
248
+ <InventoryWindow
249
+ type={type}
250
+ title={title}
251
+ properties={properties}
252
+ showDebug={showDebug}
253
+ entityDisplayDebug={entityDisplayDebug}
254
+ renderEntity={renderEntity}
255
+ />
246
256
  )}
247
257
  </div>
248
258
  </div>
@@ -288,7 +298,7 @@ export function InventoryOverlay({
288
298
  lineHeight: 1,
289
299
  }}
290
300
  >
291
- INV 0.1.6
301
+ INV 0.1.7
292
302
  </a>
293
303
  )}
294
304
 
@@ -1,13 +1,22 @@
1
1
  import React from 'react'
2
2
  import { useScale } from '../../context/ScaleContext'
3
- import type { EntityDisplayArea } from '../../types'
3
+ import type { EntityDisplayArea, InventoryTypeDefinition } from '../../types'
4
+ import { ENTITY_PLACEHOLDER_IMAGES } from './defaultEntityImages'
4
5
 
5
6
  interface EntityDisplayProps {
6
7
  area: EntityDisplayArea
8
+ placeholder: NonNullable<InventoryTypeDefinition['entityPlaceholder']>
9
+ /** When true, draw layout debug bounds instead of the default image (ignored if renderEntity is set). */
10
+ debug?: boolean
7
11
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
8
12
  }
9
13
 
10
- export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
14
+ export function EntityDisplay({
15
+ area,
16
+ placeholder,
17
+ debug = false,
18
+ renderEntity,
19
+ }: EntityDisplayProps) {
11
20
  const { scale } = useScale()
12
21
 
13
22
  if (renderEntity === null) return null
@@ -15,6 +24,44 @@ export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
15
24
  const w = area.width * scale
16
25
  const h = area.height * scale
17
26
 
27
+ let content: React.ReactNode
28
+ if (renderEntity) {
29
+ content = renderEntity(w, h)
30
+ } else if (debug) {
31
+ content = (
32
+ <div
33
+ className="mc-inv-entity-display-placeholder"
34
+ style={{
35
+ width: '100%',
36
+ height: '100%',
37
+ background: 'rgba(255, 0, 0, 0.15)',
38
+ border: '1px solid rgba(255, 0, 0, 0.4)',
39
+ boxSizing: 'border-box',
40
+ }}
41
+ />
42
+ )
43
+ } else {
44
+ const image = ENTITY_PLACEHOLDER_IMAGES[placeholder]
45
+ if (!image) return null
46
+ content = (
47
+ <img
48
+ src={ENTITY_PLACEHOLDER_IMAGES[placeholder]}
49
+ alt=""
50
+ draggable={false}
51
+ className="mc-inv-entity-display-image"
52
+ style={{
53
+ display: 'block',
54
+ width: '100%',
55
+ height: '100%',
56
+ objectFit: 'contain',
57
+ pointerEvents: 'none',
58
+ userSelect: 'none',
59
+ imageRendering: 'pixelated',
60
+ }}
61
+ />
62
+ )
63
+ }
64
+
18
65
  return (
19
66
  <div
20
67
  className="mc-inv-entity-display"
@@ -27,20 +74,7 @@ export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
27
74
  overflow: 'hidden',
28
75
  }}
29
76
  >
30
- {renderEntity ? (
31
- renderEntity(w, h)
32
- ) : (
33
- <div
34
- className="mc-inv-entity-display-placeholder"
35
- style={{
36
- width: '100%',
37
- height: '100%',
38
- background: 'rgba(255, 0, 0, 0.15)',
39
- border: '1px solid rgba(255, 0, 0, 0.4)',
40
- boxSizing: 'border-box',
41
- }}
42
- />
43
- )}
77
+ {content}
44
78
  </div>
45
79
  )
46
80
  }
@@ -22,6 +22,8 @@ interface InventoryWindowProps {
22
22
  style?: React.CSSProperties
23
23
  enableKeyboardShortcuts?: boolean
24
24
  showDebug?: boolean
25
+ /** When true, entity slot shows layout debug bounds instead of the default placeholder image. */
26
+ entityDisplayDebug?: boolean
25
27
  /** Override entity display rendering. Pass a function returning JSX, or null to hide. */
26
28
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
27
29
  }
@@ -35,6 +37,7 @@ export function InventoryWindow({
35
37
  style,
36
38
  enableKeyboardShortcuts = true,
37
39
  showDebug = false,
40
+ entityDisplayDebug = false,
38
41
  renderEntity,
39
42
  }: InventoryWindowProps) {
40
43
  const def = getInventoryType(type)
@@ -104,8 +107,13 @@ export function InventoryWindow({
104
107
  ))}
105
108
 
106
109
  {/* Entity display area */}
107
- {def.entityDisplay && (
108
- <EntityDisplay area={def.entityDisplay} renderEntity={renderEntity} />
110
+ {def.entityDisplay && def.entityPlaceholder && (
111
+ <EntityDisplay
112
+ area={def.entityDisplay}
113
+ placeholder={def.entityPlaceholder}
114
+ debug={entityDisplayDebug}
115
+ renderEntity={renderEntity}
116
+ />
109
117
  )}
110
118
 
111
119
  {/* Progress bars */}
@@ -0,0 +1,13 @@
1
+ import playerImage from '../../assets/entities/player.png'
2
+ import horseImage from '../../assets/entities/horse.png'
3
+ import llamaImage from '../../assets/entities/llama.png'
4
+ import { InventoryTypeDefinition } from '../../types'
5
+
6
+ export const ENTITY_PLACEHOLDER_IMAGES: Record<
7
+ NonNullable<InventoryTypeDefinition['entityPlaceholder']>,
8
+ string | undefined
9
+ > = {
10
+ player: playerImage,
11
+ horse: horseImage,
12
+ llama: llamaImage,
13
+ }
@@ -46,6 +46,7 @@ export function Slot({
46
46
  startDrag,
47
47
  addDragSlot,
48
48
  endDrag,
49
+ cancelDrag,
49
50
  hoveredSlot,
50
51
  setHoveredSlot,
51
52
  activeNumberKey,
@@ -53,12 +54,14 @@ export function Slot({
53
54
  setPKeyActive,
54
55
  focusedSlot,
55
56
  setFocusedSlot,
57
+ dragEndedRef,
56
58
  } = useInventoryContext()
57
59
 
58
60
  const { contentSize } = useScale()
59
61
  const isMobile = useMobile()
60
62
  const slotRef = useRef<HTMLDivElement>(null)
61
63
  const labelRef = useRef<HTMLDivElement>(null)
64
+ const lastClickTimeRef = useRef(0)
62
65
  const [mobileTouchPos, setMobileTouchPos] = useState({ x: 0, y: 0 })
63
66
  const [showTooltip, setShowTooltip] = useState(false)
64
67
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -122,16 +125,19 @@ export function Slot({
122
125
  (e: React.MouseEvent) => {
123
126
  if (isMobile || disabled) return
124
127
  e.preventDefault()
128
+ dragEndedRef.current = false
125
129
  const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
126
130
  if (button === 'middle') {
127
131
  sendAction({ type: 'click', slotIndex: index, button: 'middle', mode: 'middle' })
128
132
  return
129
133
  }
130
134
  if (heldItem && (button === 'left' || button === 'right')) {
135
+ // Don't start drag during double-click sequence
136
+ if (Date.now() - lastClickTimeRef.current < 400) return
131
137
  startDrag(index, button)
132
138
  }
133
139
  },
134
- [isMobile, disabled, heldItem, index, sendAction, startDrag],
140
+ [isMobile, disabled, heldItem, index, sendAction, startDrag, dragEndedRef],
135
141
  )
136
142
 
137
143
  const handleMouseUp = useCallback(
@@ -145,6 +151,11 @@ export function Slot({
145
151
  return
146
152
  }
147
153
 
154
+ // Suppress spurious mouseUp events that fire after a drag ends.
155
+ // The browser can dispatch extra mouseUp events after endDrag resets isDragging;
156
+ // without this guard they fall through to the click path below.
157
+ if (dragEndedRef.current) return
158
+
148
159
  // Focus/swap logic — active in P mode OR when a slot is already focused
149
160
  if (button === 'left' && (pKeyActive || focusedSlot !== null)) {
150
161
  if (pKeyActive) setPKeyActive(false)
@@ -162,6 +173,15 @@ export function Slot({
162
173
  return
163
174
  }
164
175
 
176
+ // Suppress the second mouseup of a double-click to prevent it from
177
+ // putting the item back before the dblclick event fires mode=6.
178
+ const now = Date.now()
179
+ if (button === 'left' && now - lastClickTimeRef.current < 400) {
180
+ lastClickTimeRef.current = 0
181
+ return
182
+ }
183
+ lastClickTimeRef.current = now
184
+
165
185
  const mode = e.shiftKey ? 'shift' : 'normal'
166
186
  if (onClickOverride) {
167
187
  onClickOverride(button, mode)
@@ -175,20 +195,21 @@ export function Slot({
175
195
  }
176
196
  if (isDragging) endDrag()
177
197
  },
178
- [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
198
+ [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, dragEndedRef],
179
199
  )
180
200
 
181
201
  const handleDoubleClick = useCallback(
182
202
  (e: React.MouseEvent) => {
183
203
  if (isMobile || disabled) return
184
204
  e.preventDefault()
205
+ cancelDrag()
185
206
  if (onClickOverride) {
186
207
  onClickOverride('left', 'double')
187
208
  } else {
188
209
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'double' })
189
210
  }
190
211
  },
191
- [isMobile, disabled, sendAction, index, onClickOverride],
212
+ [isMobile, disabled, sendAction, index, onClickOverride, cancelDrag],
192
213
  )
193
214
 
194
215
  const handleContextMenu = useCallback((e: React.MouseEvent) => {
@@ -1,5 +1,6 @@
1
1
  import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types'
2
2
  import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types'
3
+ import { getInventoryType } from '../registry'
3
4
 
4
5
  type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown }
5
6
 
@@ -37,6 +38,20 @@ export interface MineflayerConnectorOptions {
37
38
  * ```
38
39
  */
39
40
  itemMapper?: (raw: RawSlot, mapped: ItemStack) => ItemStack
41
+
42
+ /**
43
+ * When true, the connector only tracks the player inventory (window 0) and never
44
+ * emits windowOpen/windowUpdate/windowClose events. Use for HUD hotbar that must
45
+ * always show player hotbar slots regardless of open container windows.
46
+ */
47
+ hotbarOnly?: boolean
48
+
49
+ /**
50
+ * Optional title formatter. Called with the raw window title from the server
51
+ * (may be a JSON text component string, NBT object, or plain text).
52
+ * Return a human-readable string for display.
53
+ */
54
+ formatTitle?: (rawTitle: any) => string
40
55
  }
41
56
 
42
57
  function makeSlotConverter(itemMapper?: MineflayerConnectorOptions['itemMapper']) {
@@ -82,6 +97,9 @@ interface MineflayerBotExtended extends MineflayerBot {
82
97
  supportFeature?(name: string): boolean
83
98
  _client?: {
84
99
  write(packet: string, data: unknown): void
100
+ on?(event: string, listener: (...args: any[]) => void): void
101
+ off?(event: string, listener: (...args: any[]) => void): void
102
+ removeListener?(event: string, listener: (...args: any[]) => void): void
85
103
  writeChannel?(channel: string, data: unknown): void
86
104
  registerChannel?(channel: string, schema: unknown): void
87
105
  }
@@ -118,35 +136,148 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
118
136
  const listeners = new Set<ConnectorListener>()
119
137
  const ext = bot as MineflayerBotExtended
120
138
  const convert = makeSlotConverter(options?.itemMapper)
139
+ const hotbarOnly = options?.hotbarOnly ?? false
140
+ const formatTitle = options?.formatTitle
141
+
142
+ // Track window properties (furnace progress, enchant levels, etc.)
143
+ const windowProperties: Record<string, number> = {}
144
+ let currentWindowType: string | null = null
145
+
146
+ // Track stateId for raw packet sending (drag operations bypass bot.clickWindow)
147
+ let dragStateId = typeof (bot as any)._stateId === 'number' ? (bot as any)._stateId : -1
148
+ // True only while drag packets are being written; guards trackState against
149
+ // stale server responses overwriting our predicted stateId mid-sequence.
150
+ let isDraggingRaw = false
151
+
152
+ // Resolve the Item class (prismarine-item) for converting notch-format items.
153
+ // We extract it lazily from the first non-null slot item's constructor.
154
+ let ItemClass: { fromNotch(notch: unknown): unknown } | null = null
155
+ function getItemClass(): typeof ItemClass {
156
+ if (ItemClass) return ItemClass
157
+ const win = bot.currentWindow ?? bot.inventory
158
+ for (const slot of win.slots) {
159
+ if (slot) {
160
+ ItemClass = slot.constructor as any
161
+ return ItemClass
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ const rawPacketListeners: Array<[string, (...args: any[]) => void]> = []
168
+ if (ext._client?.on) {
169
+ const trackState = (packet: any) => {
170
+ if (packet.stateId != null) {
171
+ if (isDraggingRaw) {
172
+ // During active drag, only accept higher stateIds to prevent
173
+ // stale server responses from reverting our predicted value.
174
+ if (packet.stateId > dragStateId) {
175
+ dragStateId = packet.stateId
176
+ }
177
+ } else {
178
+ dragStateId = packet.stateId
179
+ }
180
+ }
181
+ }
182
+ ext._client.on('window_items' as any, trackState)
183
+ ext._client.on('set_slot' as any, trackState)
184
+ rawPacketListeners.push(['window_items', trackState], ['set_slot', trackState])
185
+
186
+ // Mineflayer drops set_slot with windowId=-1 (cursor updates) because the window
187
+ // lookup fails. Intercept these to keep selectedItem in sync after raw packet ops.
188
+ const onRawSetSlot = (packet: any) => {
189
+ if (packet.windowId !== -1 || packet.slot !== -1) return
190
+ const win = bot.currentWindow ?? bot.inventory
191
+ const IC = getItemClass()
192
+ if (IC && packet.item) {
193
+ ;(win as any).selectedItem = IC.fromNotch(packet.item) ?? null
194
+ } else {
195
+ ;(win as any).selectedItem = null
196
+ }
197
+ emit({ type: 'heldItemChange', item: convert((win as any).selectedItem) })
198
+ }
199
+ ext._client.on('set_slot' as any, onRawSetSlot)
200
+ rawPacketListeners.push(['set_slot', onRawSetSlot])
201
+
202
+ // Mineflayer processes window_items slots but ignores the carriedItem field.
203
+ // Intercept to update selectedItem from the server's cursor state.
204
+ // Always call scheduleSlotUpdate() so UI gets corrected state even when mineflayer's
205
+ // handler fires first with a stale selectedItem (drag-flash fix).
206
+ const onRawWindowItems = (packet: any) => {
207
+ if (packet.carriedItem != null) {
208
+ const win = packet.windowId === 0 ? bot.inventory : (bot.currentWindow ?? bot.inventory)
209
+ const IC = getItemClass()
210
+ if (IC) {
211
+ ;(win as any).selectedItem = IC.fromNotch(packet.carriedItem) ?? null
212
+ } else {
213
+ ;(win as any).selectedItem = null
214
+ }
215
+ }
216
+ scheduleSlotUpdate()
217
+ }
218
+ ext._client.on('window_items' as any, onRawWindowItems)
219
+ rawPacketListeners.push(['window_items', onRawWindowItems])
220
+ }
121
221
 
122
222
  function emit(event: ConnectorEvent) {
123
223
  listeners.forEach((l) => l(event))
124
224
  }
125
225
 
226
+ /** Build a reverse map from dataSlot index → property name for the current window type. */
227
+ function getDataSlotMap(type: string): Record<number, string> | null {
228
+ const typeDef = getInventoryType(type)
229
+ if (!typeDef?.properties) return null
230
+ const map: Record<number, string> = {}
231
+ for (const [name, def] of Object.entries(typeDef.properties)) {
232
+ map[def.dataSlot] = name
233
+ }
234
+ return map
235
+ }
236
+
126
237
  /**
127
238
  * Builds a window state from the currently open window, OR from `bot.inventory`
128
239
  * when no container is open (exposing the player's own inventory as a synthetic
129
240
  * 'player' window with windowId = 0).
130
241
  */
131
242
  function buildWindowState(): InventoryWindowState | null {
132
- const win = bot.currentWindow
243
+ // In hotbarOnly mode, always build a player-like state (never the container itself)
244
+ const win = hotbarOnly ? null : bot.currentWindow
133
245
  if (win) {
134
- return {
246
+ const title = formatTitle ? formatTitle(win.title) : win.title
247
+ const state: InventoryWindowState = {
135
248
  windowId: win.id,
136
249
  type: win.type ?? 'unknown',
137
- title: win.title,
250
+ title,
138
251
  slots: botSlotsToSlotStates(win.slots, convert),
139
- heldItem: convert(bot.heldItem),
252
+ heldItem: convert(win.selectedItem),
140
253
  }
254
+ if (Object.keys(windowProperties).length > 0) {
255
+ state.properties = { ...windowProperties }
256
+ }
257
+ return state
258
+ }
259
+ // No open container (or hotbarOnly) — expose the player inventory.
260
+ // When a container IS open in hotbarOnly mode, mineflayer doesn't update
261
+ // bot.inventory.slots in real time — read player slots from the container
262
+ // window instead (using inventoryStart offset).
263
+ const containerWin = hotbarOnly ? bot.currentWindow : null
264
+ const invStart: number | null = containerWin ? (containerWin as any).inventoryStart ?? null : null
265
+ const readSlot = (playerSlotIndex: number) => {
266
+ if (containerWin && invStart != null) {
267
+ // Map player inventory index to container window index
268
+ const containerIndex = playerSlotIndex - 9 + invStart
269
+ return convert(containerWin.slots[containerIndex])
270
+ }
271
+ return convert(bot.inventory.slots[playerSlotIndex])
141
272
  }
142
- // No open container — expose the player inventory as a synthetic 'player' window.
273
+
143
274
  const invSlots: SlotState[] = []
144
275
  // Slots 0–8: crafting/armour — leave empty (not accessible from bot.inventory directly)
145
276
  for (let i = 0; i < 9; i++) invSlots.push({ index: i, item: null })
146
277
  // Slots 9–35: main inventory
147
- for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
278
+ for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: readSlot(i) })
148
279
  // Slots 36–44: hotbar
149
- for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
280
+ for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: readSlot(i) })
150
281
  // Slot 45: offhand
151
282
  invSlots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
152
283
  return {
@@ -154,7 +285,7 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
154
285
  type: 'player',
155
286
  title: undefined,
156
287
  slots: invSlots,
157
- heldItem: convert(bot.heldItem),
288
+ heldItem: convert((bot.inventory as any).selectedItem ?? null),
158
289
  }
159
290
  }
160
291
 
@@ -178,14 +309,91 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
178
309
  const onSetSlot = () => {
179
310
  const state = buildWindowState()
180
311
  if (state) emit({ type: 'windowUpdate', state })
181
- else {
182
- emit({ type: 'playerUpdate', state: buildPlayerState() })
312
+ emit({ type: 'playerUpdate', state: buildPlayerState() })
313
+ }
314
+
315
+ const onHeldItemChanged = () => {
316
+ emit({ type: 'playerUpdate', state: buildPlayerState() })
317
+ }
318
+
319
+ // Mineflayer emits 'setSlot:${windowId}' (e.g. 'setSlot:0'), not plain 'setSlot'.
320
+ // Always listen on window 0 (player inventory) and dynamically track container windows.
321
+ // Also listen for 'heldItemChanged' to track active hotbar slot changes.
322
+ let currentWindowSlotEvent: string | null = null
323
+ let currentWindowItemsEvent: string | null = null
324
+
325
+ const onWindowOpenInternal = () => {
326
+ const win = bot.currentWindow
327
+ if (win) {
328
+ currentWindowSlotEvent = `setSlot:${win.id}`
329
+ currentWindowItemsEvent = `setWindowItems:${win.id}`
330
+ bot.on(currentWindowSlotEvent as any, onSetSlot)
331
+ bot.on(currentWindowItemsEvent as any, scheduleSlotUpdate)
332
+ // Reset properties and resolve window type for property mapping
333
+ for (const key of Object.keys(windowProperties)) delete windowProperties[key]
334
+ currentWindowType = win.type ?? null
335
+ ;(win as any).on('updateSlot', scheduleSlotUpdate)
336
+ }
337
+ if (!hotbarOnly) onWindowOpen()
338
+ }
339
+
340
+ const onWindowCloseInternal = () => {
341
+ const closingWin = bot.currentWindow
342
+ if (closingWin) {
343
+ ;(closingWin as any).off('updateSlot', scheduleSlotUpdate)
344
+ }
345
+ if (currentWindowSlotEvent) {
346
+ bot.off(currentWindowSlotEvent as any, onSetSlot)
347
+ currentWindowSlotEvent = null
348
+ }
349
+ if (currentWindowItemsEvent) {
350
+ bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate)
351
+ currentWindowItemsEvent = null
352
+ }
353
+ currentWindowType = null
354
+ for (const key of Object.keys(windowProperties)) delete windowProperties[key]
355
+ if (!hotbarOnly) onWindowClose()
356
+ // In hotbar mode, emit a windowUpdate after close so the hotbar
357
+ // re-syncs from bot.inventory (now freshly copied back by mineflayer)
358
+ if (hotbarOnly) scheduleSlotUpdate()
359
+ }
360
+
361
+ // Handle craft_progress_bar packets (furnace progress, enchant levels, etc.)
362
+ const onCraftProgressBar = (packet: { windowId: number; property: number; value: number }) => {
363
+ const win = bot.currentWindow
364
+ if (!win || packet.windowId !== win.id || !currentWindowType) return
365
+ const slotMap = getDataSlotMap(currentWindowType)
366
+ if (!slotMap) return
367
+ const propName = slotMap[packet.property]
368
+ if (propName) {
369
+ windowProperties[propName] = packet.value
370
+ const state = buildWindowState()
371
+ if (state) emit({ type: 'windowUpdate', state })
183
372
  }
184
373
  }
185
374
 
186
- bot.on('windowOpen', onWindowOpen)
187
- bot.on('windowClose', onWindowClose)
188
- bot.on('setSlot', onSetSlot)
375
+ // Listen to Window-level slot changes to catch optimistic acceptClick updates
376
+ // that don't fire bot-level events (needed for multi-connector architecture)
377
+ let slotUpdateScheduled = false
378
+ const scheduleSlotUpdate = () => {
379
+ if (slotUpdateScheduled) return
380
+ slotUpdateScheduled = true
381
+ queueMicrotask(() => {
382
+ slotUpdateScheduled = false
383
+ onSetSlot()
384
+ })
385
+ }
386
+
387
+ ;(bot.inventory as any).on('updateSlot', scheduleSlotUpdate)
388
+
389
+ bot.on('windowOpen', onWindowOpenInternal)
390
+ bot.on('windowClose', onWindowCloseInternal)
391
+ bot.on('setSlot:0' as any, onSetSlot)
392
+ bot.on('setWindowItems:0' as any, scheduleSlotUpdate)
393
+ bot.on('heldItemChanged' as any, onHeldItemChanged)
394
+ if (!hotbarOnly && ext._client) {
395
+ ext._client.on?.('craft_progress_bar' as any, onCraftProgressBar as any)
396
+ }
189
397
 
190
398
  async function openPlayerInventory() {
191
399
  const vehicle = bot.vehicle
@@ -255,57 +463,123 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
255
463
  openPlayerInventory,
256
464
 
257
465
  sendAction: async (action: InventoryAction) => {
258
- // Hotbar "open inventory" button — delegates to openPlayerInventory()
259
- if (action.type === 'open-inventory') {
260
- await openPlayerInventory()
261
- return
262
- }
263
-
264
- const win = bot.currentWindow
265
-
266
- if (action.type === 'trade' && win) {
267
- if (ext.trade && isVillagerWindow(win)) {
268
- await ext.trade(win, action.tradeIndex, 1)
269
- } else if (isVillagerWindow(win)) {
270
- await win.trade(action.tradeIndex, 1)
466
+ try {
467
+ // Hotbar "open inventory" button — delegates to openPlayerInventory()
468
+ if (action.type === 'open-inventory') {
469
+ await openPlayerInventory()
470
+ return
271
471
  }
272
- return
273
- }
274
-
275
- if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) {
276
- await win.enchant(action.enchantIndex)
277
- return
278
- }
279
-
280
- if (action.type === 'rename' && win && isAnvilWindow(win)) {
281
- const w = win as { slots?: unknown[]; findInventoryItem?: (id: number) => unknown; rename: (item: unknown, name: string) => Promise<void> }
282
- const inputSlot = w.slots?.[0] as { type?: number; metadata?: number; count?: number; nbt?: unknown } | null
283
- const item = inputSlot?.type ? (w.findInventoryItem?.(inputSlot.type) ?? inputSlot) : null
284
- if (item) await w.rename(item, action.text)
285
- return
286
- }
472
+
473
+ const win = bot.currentWindow
474
+
475
+ if (action.type === 'trade' && win) {
476
+ if (ext.trade && isVillagerWindow(win)) {
477
+ await ext.trade(win, action.tradeIndex, 1)
478
+ } else if (isVillagerWindow(win)) {
479
+ await win.trade(action.tradeIndex, 1)
480
+ }
481
+ return
482
+ }
483
+
484
+ if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) {
485
+ await win.enchant(action.enchantIndex)
486
+ return
487
+ }
488
+
489
+ if (action.type === 'rename' && win && isAnvilWindow(win)) {
490
+ const w = win as { slots?: unknown[]; findInventoryItem?: (id: number) => unknown; rename: (item: unknown, name: string) => Promise<void> }
491
+ const inputSlot = w.slots?.[0] as { type?: number; metadata?: number; count?: number; nbt?: unknown } | null
492
+ const item = inputSlot?.type ? (w.findInventoryItem?.(inputSlot.type) ?? inputSlot) : null
493
+ if (item) await w.rename(item, action.text)
494
+ return
495
+ }
496
+
497
+ if (action.type === 'beacon' && win && isBeaconWindow(win)) {
498
+ if (typeof win.setBeaconEffects === 'function') {
499
+ await win.setBeaconEffects(action.primaryEffect, action.secondaryEffect)
500
+ } else if (ext._client) {
501
+ ext._client.write('beacon_effect', {
502
+ primaryEffect: action.primaryEffect,
503
+ secondaryEffect: action.secondaryEffect,
504
+ })
505
+ }
506
+ return
507
+ }
508
+
509
+ if (action.type === 'click' && action.mode === 'double') {
510
+ // bot.clickWindow() throws for mode=6 (prismarine-windows doubleClick is unimplemented).
511
+ // Send raw window_click packet directly, same approach as drag (mode=5).
512
+ if (!ext._client) return
513
+ const windowId = bot.currentWindow ? bot.currentWindow.id : 0
287
514
 
288
- if (action.type === 'beacon' && win && isBeaconWindow(win)) {
289
- if (typeof win.setBeaconEffects === 'function') {
290
- await win.setBeaconEffects(action.primaryEffect, action.secondaryEffect)
291
- } else if (ext._client) {
292
- ext._client.write('beacon_effect', {
293
- primaryEffect: action.primaryEffect,
294
- secondaryEffect: action.secondaryEffect,
515
+ ext._client.write('window_click', {
516
+ windowId,
517
+ stateId: dragStateId,
518
+ slot: action.slotIndex,
519
+ mouseButton: 0,
520
+ mode: 6,
521
+ changedSlots: [],
522
+ cursorItem: { present: false } as any,
295
523
  })
524
+ dragStateId++
525
+ return
296
526
  }
297
- return
298
- }
299
-
300
- if (action.type === 'click') {
301
- const [mouseButton, mode] = modeFromAction(action)
302
- await bot.clickWindow(action.slotIndex, mouseButton, mode)
303
- } else if (action.type === 'drop') {
304
- await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4)
305
- } else if (action.type === 'close') {
306
- if (win) bot.closeWindow(win)
307
- } else if (action.type === 'hotbar-swap') {
308
- await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2)
527
+
528
+ if (action.type === 'click') {
529
+ const [mouseButton, mode] = modeFromAction(action)
530
+ await bot.clickWindow(action.slotIndex, mouseButton, mode)
531
+ onSetSlot()
532
+ } else if (action.type === 'drag') {
533
+ // bot.clickWindow() throws for mode=5 (prismarine-windows dragClick is unimplemented).
534
+ // Send raw window_click packets directly via _client.write.
535
+ if (!ext._client) return
536
+ const isRight = action.button === 'right'
537
+ const startButton = isRight ? 4 : 0
538
+ const slotButton = isRight ? 5 : 1
539
+ const endButton = isRight ? 6 : 2
540
+ const windowId = bot.currentWindow ? bot.currentWindow.id : 0
541
+ const cursorItem = { present: false } as any
542
+
543
+ isDraggingRaw = true
544
+ const writeClick = (slot: number, mouseButton: number) => {
545
+ ext._client!.write('window_click', {
546
+ windowId,
547
+ stateId: dragStateId,
548
+ slot,
549
+ mouseButton,
550
+ mode: 5,
551
+ changedSlots: [],
552
+ cursorItem,
553
+ })
554
+ dragStateId++
555
+ }
556
+
557
+ writeClick(-999, startButton)
558
+ for (const slot of action.slots) {
559
+ writeClick(slot, slotButton)
560
+ }
561
+ writeClick(-999, endButton)
562
+ isDraggingRaw = false
563
+ } else if (action.type === 'drop') {
564
+ await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4)
565
+ onSetSlot()
566
+ } else if (action.type === 'close') {
567
+ if (win) {
568
+ bot.closeWindow(win)
569
+ } else {
570
+ // Player inventory (synthetic) — send close_window so server drops cursor items
571
+ if (ext._client) {
572
+ ext._client.write('close_window', { windowId: 0 })
573
+ }
574
+ ;(bot.inventory as any).selectedItem = null
575
+ }
576
+ } else if (action.type === 'hotbar-swap') {
577
+ await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2)
578
+ onSetSlot()
579
+ }
580
+ } catch (err) {
581
+ const detail = 'slotIndex' in action ? ` slot=${(action as any).slotIndex}` : ''
582
+ console.error(`[minecraft-inventory] sendAction "${action.type}"${detail} failed:`, err)
309
583
  }
310
584
  },
311
585
 
@@ -318,9 +592,29 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
318
592
  listeners.add(listener)
319
593
  return () => {
320
594
  listeners.delete(listener)
321
- bot.off('windowOpen', onWindowOpen)
322
- bot.off('windowClose', onWindowClose)
323
- bot.off('setSlot', onSetSlot)
595
+ bot.off('windowOpen', onWindowOpenInternal)
596
+ bot.off('windowClose', onWindowCloseInternal)
597
+ bot.off('setSlot:0' as any, onSetSlot)
598
+ bot.off('setWindowItems:0' as any, scheduleSlotUpdate)
599
+ bot.off('heldItemChanged' as any, onHeldItemChanged)
600
+ if (ext._client?.off) {
601
+ if (!hotbarOnly) {
602
+ ext._client.off('craft_progress_bar' as any, onCraftProgressBar as any)
603
+ }
604
+ for (const [event, listener] of rawPacketListeners) {
605
+ ext._client.off(event as any, listener as any)
606
+ }
607
+ }
608
+ if (currentWindowSlotEvent) {
609
+ bot.off(currentWindowSlotEvent as any, onSetSlot)
610
+ }
611
+ if (currentWindowItemsEvent) {
612
+ bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate)
613
+ }
614
+ ;(bot.inventory as any).off('updateSlot', scheduleSlotUpdate)
615
+ if (bot.currentWindow) {
616
+ ;(bot.currentWindow as any).off('updateSlot', scheduleSlotUpdate)
617
+ }
324
618
  }
325
619
  },
326
620
  }
@@ -28,6 +28,7 @@ export interface MineflayerBotWindow {
28
28
  type: string
29
29
  title?: string
30
30
  slots: Array<{ type: number; count: number; metadata?: number; nbt?: unknown } | null>
31
+ selectedItem?: { type: number; count: number; metadata?: number; nbt?: unknown } | null
31
32
  }
32
33
 
33
34
  /** Villager window from bot.openVillager() — has trade() */
@@ -42,6 +42,9 @@ export interface InventoryContextValue {
42
42
  /** Pending first digit for P-key slot number entry */
43
43
  pKeyDigit: string
44
44
  setPKeyDigit: (d: string) => void
45
+ /** Ref set to true when a drag just ended; cleared on next mouseDown.
46
+ * Used by Slot to suppress spurious click events that fire after endDrag. */
47
+ dragEndedRef: React.MutableRefObject<boolean>
45
48
  }
46
49
 
47
50
  const InventoryContext = createContext<InventoryContextValue | null>(null)
@@ -90,6 +93,10 @@ export function InventoryProvider({ connector, children, noDragSpread = false }:
90
93
  const dragButtonRef = useRef(dragButton)
91
94
  dragButtonRef.current = dragButton
92
95
 
96
+ // Set to true when endDrag fires; cleared on the next mouseDown in Slot.
97
+ // Prevents spurious mouseUp events from sending unwanted clicks after a drag.
98
+ const dragEndedRef = useRef(false)
99
+
93
100
  useEffect(() => {
94
101
  if (!connector) return
95
102
  setWindowState(connector.getWindowState())
@@ -159,6 +166,7 @@ export function InventoryProvider({ connector, children, noDragSpread = false }:
159
166
 
160
167
  const startDrag = useCallback((slotIndex: number, button: 'left' | 'right') => {
161
168
  if (noDragSpread) return
169
+ dragEndedRef.current = false
162
170
  setIsDragging(true)
163
171
  setDragButton(button)
164
172
  setDragSlots([slotIndex])
@@ -175,6 +183,7 @@ export function InventoryProvider({ connector, children, noDragSpread = false }:
175
183
  }, [computeDragPreview, dragButton, heldItem, windowState])
176
184
 
177
185
  const endDrag = useCallback(() => {
186
+ dragEndedRef.current = true
178
187
  setDragSlots((slots) => {
179
188
  const button = dragButtonRef.current
180
189
  const held = heldItemRef.current
@@ -196,7 +205,6 @@ export function InventoryProvider({ connector, children, noDragSpread = false }:
196
205
  })
197
206
  if (compatibleSlots.length > 0) {
198
207
  const perSlot = Math.floor(held.count / compatibleSlots.length)
199
- // Vanilla behavior: if perSlot=0 (more slots than items), nothing is distributed
200
208
  if (perSlot > 0) {
201
209
  let totalPlaced = 0
202
210
  for (const idx of compatibleSlots) {
@@ -302,6 +310,7 @@ export function InventoryProvider({ connector, children, noDragSpread = false }:
302
310
  setFocusedSlot,
303
311
  pKeyDigit,
304
312
  setPKeyDigit,
313
+ dragEndedRef,
305
314
  }
306
315
 
307
316
  return <InventoryContext.Provider value={value}>{children}</InventoryContext.Provider>
@@ -95,6 +95,7 @@ export const inventoryDefinitions = makeInventoryDefinitions({
95
95
  backgroundWidth: 176,
96
96
  backgroundHeight: 166,
97
97
  entityDisplay: { x: 26, y: 8, width: 49, height: 70 },
98
+ entityPlaceholder: 'player',
98
99
  slots: [
99
100
  // Result (0)
100
101
  { x: 154, y: 28, group: 'result', resultSlot: true },
@@ -552,6 +553,7 @@ export const inventoryDefinitions = makeInventoryDefinitions({
552
553
  backgroundWidth: 176,
553
554
  backgroundHeight: 166,
554
555
  entityDisplay: { x: 26, y: 18, width: 52, height: 52 },
556
+ entityPlaceholder: 'horse',
555
557
  slots: [
556
558
  // Saddle (0); slot 1 (horse armor) is absent for donkeys — gap is intentional
557
559
  { x: 8, y: 18, group: 'saddle', label: 'Saddle' },
@@ -568,6 +570,7 @@ export const inventoryDefinitions = makeInventoryDefinitions({
568
570
  backgroundWidth: 176,
569
571
  backgroundHeight: 166,
570
572
  entityDisplay: { x: 26, y: 18, width: 52, height: 52 },
573
+ entityPlaceholder: 'llama',
571
574
  slots: [
572
575
  { x: 8, y: 18, group: 'saddle', label: 'Carpet' },
573
576
  ...gridSlots(5, 3, 80, 18, 'chest').map((s, i) => i === 0 ? { ...s, index: 2 } : s),
package/src/types.ts CHANGED
@@ -177,6 +177,11 @@ export interface InventoryTypeDefinition {
177
177
  extraData?: Record<string, unknown>
178
178
  /** Bounds for the entity preview area (e.g. player model, horse model). */
179
179
  entityDisplay?: EntityDisplayArea
180
+ /**
181
+ * Bundled placeholder image for the entity panel when `renderEntity` is not set.
182
+ * Maps to files under `src/assets/entities/`.
183
+ */
184
+ entityPlaceholder?: 'player' | 'horse' | 'llama'
180
185
  /**
181
186
  * When set, InventoryBackground will canvas-stitch the background texture:
182
187
  * top (title + N container rows) + bottom (player inventory section) from