minecraft-inventory 0.1.5 → 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/README.md +16 -3
- package/package.json +1 -1
- package/src/bundledTexturesConfig.ts +126 -0
- package/src/cache/blockRenderer.ts +127 -0
- package/src/cache/textureCache.ts +10 -0
- package/src/components/InventoryOverlay/InventoryOverlay.tsx +12 -2
- package/src/components/InventoryWindow/EntityDisplay.tsx +50 -16
- package/src/components/InventoryWindow/InventoryBackground.tsx +1 -1
- package/src/components/InventoryWindow/InventoryWindow.tsx +10 -2
- package/src/components/InventoryWindow/defaultEntityImages.ts +13 -0
- package/src/components/ItemCanvas/ItemCanvas.tsx +103 -17
- package/src/components/JEI/JEI.module.css +2 -0
- package/src/components/JEI/JEI.tsx +2 -3
- package/src/components/Slot/Slot.tsx +24 -3
- package/src/components/Text/MessageFormattedString.tsx +1 -0
- package/src/components/Tooltip/Tooltip.tsx +1 -2
- package/src/connector/mineflayer.ts +372 -66
- package/src/connector/types.ts +1 -0
- package/src/context/InventoryContext.tsx +10 -1
- package/src/generated/localTextures.ts +66 -51
- package/src/index.tsx +18 -0
- package/src/registry/inventories.ts +12 -7
- package/src/types.ts +37 -0
package/README.md
CHANGED
|
@@ -262,16 +262,16 @@ pnpm add mc-assets # optional peer dependency
|
|
|
262
262
|
|
|
263
263
|
```tsx
|
|
264
264
|
import { TextureProvider, InventoryOverlay } from 'minecraft-inventory'
|
|
265
|
-
import {
|
|
265
|
+
import { localBundledTexturesConfig } from './bundledTexturesConfig'
|
|
266
266
|
|
|
267
267
|
// GUI container backgrounds (chest, furnace, etc.) load from bundled mc-assets;
|
|
268
268
|
// item/block textures still use the default remote URLs unless you override them.
|
|
269
|
-
<TextureProvider config={
|
|
269
|
+
<TextureProvider config={localBundledTexturesConfig}>
|
|
270
270
|
<InventoryOverlay type="chest" ... />
|
|
271
271
|
</TextureProvider>
|
|
272
272
|
```
|
|
273
273
|
|
|
274
|
-
The generated file
|
|
274
|
+
The generated file (`src/generated/localTextures.ts`) exports `bundledTextureMap`, `allTexturePaths`, and `allContainerPaths`. Use `createBundledTexturesConfig({ remoteFallback: true, bundledTextureMap? })` to get a config with `getGuiTextureUrl`, `setOverride(path, image)`, `clearOverrides()`, `setRemoteFallback(enabled)`, and `resetRenderedSlots()`. Pass short paths to `setOverride` (e.g. `gui/sprites/container/anvil/text_field_disabled.png`) — the version prefix exists only for the texture import generator. Call `resetRenderedSlots()` after multiple `setOverride` calls to invalidate cached textures so slots re-request them. `localBundledTexturesConfig` is the default instance. Re-run `pnpm gen:textures` after changing [inventory types](#adding-new-inventory-types).
|
|
275
275
|
|
|
276
276
|
---
|
|
277
277
|
|
|
@@ -546,9 +546,18 @@ interface ItemStack {
|
|
|
546
546
|
lore?: string[]
|
|
547
547
|
durability?: number // Current durability value
|
|
548
548
|
maxDurability?: number // Max durability (renders bar when < max)
|
|
549
|
+
textureKey?: string // Override texture path for getItemTextureUrl
|
|
550
|
+
texture?: string | HTMLImageElement // Direct texture (bypasses URL lookup)
|
|
551
|
+
blockTexture?: BlockTextureRender // Isometric block icon from face slices
|
|
552
|
+
debugKey?: string
|
|
549
553
|
}
|
|
550
554
|
```
|
|
551
555
|
|
|
556
|
+
**Texture overrides (mineflayer `itemMapper`):**
|
|
557
|
+
- `texture: string` — URL or data URL, fetched and cached.
|
|
558
|
+
- `texture: HTMLImageElement` — Preloaded image, used directly.
|
|
559
|
+
- `blockTexture: { source, top, left, right }` — Composite an isometric block icon from three face slices. Each face has `slice: [x, y, w, h]` in source texture pixels. Uses a pool of aux canvases (not recreated per slot).
|
|
560
|
+
|
|
552
561
|
---
|
|
553
562
|
|
|
554
563
|
## Project Structure
|
|
@@ -613,6 +622,10 @@ Each Minecraft window type has a fixed slot layout defined by the server protoco
|
|
|
613
622
|
- [`prismarine-windows`](https://github.com/PrismarineJS/prismarine-windows) for JS slot maps
|
|
614
623
|
- Minecraft source: `net/minecraft/world/inventory/` for the canonical layout
|
|
615
624
|
|
|
625
|
+
### Memory leak note (Tooltip / MessageFormattedString)
|
|
626
|
+
|
|
627
|
+
If opening tooltips increases memory without returning to baseline, see [docs/MEMORY_LEAK_ANALYSIS.md](docs/MEMORY_LEAK_ANALYSIS.md). The likely cause is `filter:blur(2px)` on obfuscated (§k) text creating retained compositor layers.
|
|
628
|
+
|
|
616
629
|
### Connector protocol
|
|
617
630
|
|
|
618
631
|
When implementing a custom connector, actions have these shapes:
|
package/package.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized config for bundled GUI textures. Uses maps from generated localTextures.
|
|
3
|
+
* Supports overrides via short paths (e.g. "gui/sprites/container/anvil/text_field_disabled.png"),
|
|
4
|
+
* remote fallback toggle, and cache invalidation after overrides.
|
|
5
|
+
*
|
|
6
|
+
* Version prefix (e.g. "1.21.11/textures/") in bundled keys exists only for the texture
|
|
7
|
+
* import generator — setOverride accepts short paths without it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
bundledTextureMap,
|
|
12
|
+
allTexturePaths,
|
|
13
|
+
allContainerPaths,
|
|
14
|
+
} from './generated/localTextures'
|
|
15
|
+
import type { TextureConfig } from './context/TextureContext'
|
|
16
|
+
import { clearTextureCache } from './cache/textureCache'
|
|
17
|
+
|
|
18
|
+
const MC_ASSETS_REMOTE =
|
|
19
|
+
'https://raw.githubusercontent.com/zardoy/mc-assets/refs/heads/gh-pages'
|
|
20
|
+
|
|
21
|
+
/** Normalize path to short form: "gui/..." (strip version prefix if present). */
|
|
22
|
+
function toShortPath(path: string): string {
|
|
23
|
+
const m = path.match(/^\d[\d.]+\/textures\/(.+)$/)
|
|
24
|
+
return m ? m[1] : path
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Default version for building mc-assets URLs when path is short. */
|
|
28
|
+
const DEFAULT_VERSION = '1.21.11'
|
|
29
|
+
|
|
30
|
+
export interface BundledTexturesConfigOptions {
|
|
31
|
+
/** When true (default), unknown paths fall back to remote mc-assets URL. */
|
|
32
|
+
remoteFallback?: boolean
|
|
33
|
+
/** Override the bundled texture map (e.g. for custom builds). */
|
|
34
|
+
bundledTextureMap?: Record<string, string | undefined>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BundledTexturesConfig extends Pick<TextureConfig, 'getGuiTextureUrl'> {
|
|
38
|
+
/**
|
|
39
|
+
* Override a texture. Pass short path (e.g. "gui/sprites/container/anvil/text_field_disabled.png")
|
|
40
|
+
* and image URL. Version prefix is only for the import generator — not needed here.
|
|
41
|
+
*/
|
|
42
|
+
setOverride(path: string, image: string): void
|
|
43
|
+
/** Remove all texture overrides. */
|
|
44
|
+
clearOverrides(): void
|
|
45
|
+
/** Enable or disable remote fallback for unknown paths. */
|
|
46
|
+
setRemoteFallback(enabled: boolean): void
|
|
47
|
+
/**
|
|
48
|
+
* Invalidate cached textures so slots re-request them. Call after multiple setOverride
|
|
49
|
+
* calls when you want UI to immediately reflect new overrides.
|
|
50
|
+
*/
|
|
51
|
+
resetRenderedSlots(): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a reverse map: shortPath -> bundled URL.
|
|
56
|
+
*/
|
|
57
|
+
function buildShortPathToBundled(
|
|
58
|
+
bundled: Record<string, string | undefined>,
|
|
59
|
+
): Map<string, string> {
|
|
60
|
+
const map = new Map<string, string>()
|
|
61
|
+
for (const [key, url] of Object.entries(bundled)) {
|
|
62
|
+
if (!url) continue
|
|
63
|
+
const short = toShortPath(key)
|
|
64
|
+
if (!map.has(short)) map.set(short, url)
|
|
65
|
+
}
|
|
66
|
+
return map
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a bundled textures config with overrides and optional remote fallback.
|
|
71
|
+
* Pass the returned config to <TextureProvider config={...}>.
|
|
72
|
+
*/
|
|
73
|
+
export function createBundledTexturesConfig(
|
|
74
|
+
options?: BundledTexturesConfigOptions,
|
|
75
|
+
): BundledTexturesConfig {
|
|
76
|
+
let remoteFallbackEnabled = options?.remoteFallback ?? true
|
|
77
|
+
const overrides = new Map<string, string>()
|
|
78
|
+
const bundled = options?.bundledTextureMap ?? bundledTextureMap
|
|
79
|
+
const shortToBundled = buildShortPathToBundled(bundled)
|
|
80
|
+
|
|
81
|
+
function getGuiTextureUrl(path: string): string {
|
|
82
|
+
const shortPath = toShortPath(path)
|
|
83
|
+
|
|
84
|
+
const override = overrides.get(shortPath)
|
|
85
|
+
if (override) return override
|
|
86
|
+
|
|
87
|
+
const bundledUrl = bundled[path] ?? shortToBundled.get(shortPath)
|
|
88
|
+
if (bundledUrl) return bundledUrl
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
remoteFallbackEnabled &&
|
|
92
|
+
path.endsWith('.png') &&
|
|
93
|
+
path.includes('/textures/')
|
|
94
|
+
) {
|
|
95
|
+
return `${MC_ASSETS_REMOTE}/${path}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (remoteFallbackEnabled && shortPath.endsWith('.png')) {
|
|
99
|
+
const versioned = `${DEFAULT_VERSION}/textures/${shortPath}`
|
|
100
|
+
return `${MC_ASSETS_REMOTE}/${versioned}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return path
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
getGuiTextureUrl,
|
|
108
|
+
setOverride(path: string, image: string) {
|
|
109
|
+
overrides.set(toShortPath(path), image)
|
|
110
|
+
},
|
|
111
|
+
clearOverrides() {
|
|
112
|
+
overrides.clear()
|
|
113
|
+
},
|
|
114
|
+
setRemoteFallback(enabled: boolean) {
|
|
115
|
+
remoteFallbackEnabled = enabled
|
|
116
|
+
},
|
|
117
|
+
resetRenderedSlots() {
|
|
118
|
+
clearTextureCache()
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Default config instance for <TextureProvider config={localBundledTexturesConfig}>. */
|
|
124
|
+
export const localBundledTexturesConfig = createBundledTexturesConfig()
|
|
125
|
+
|
|
126
|
+
export { allTexturePaths, allContainerPaths }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool of offscreen canvases for block-face compositing.
|
|
3
|
+
* Reuses a fixed set of canvases instead of creating new ones per render.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const POOL_SIZE = 8
|
|
7
|
+
const OUTPUT_SIZE = 32
|
|
8
|
+
|
|
9
|
+
const pool: HTMLCanvasElement[] = []
|
|
10
|
+
const inUse = new Set<HTMLCanvasElement>()
|
|
11
|
+
|
|
12
|
+
function getCanvas(): HTMLCanvasElement {
|
|
13
|
+
const free = pool.find((c) => !inUse.has(c))
|
|
14
|
+
if (free) {
|
|
15
|
+
inUse.add(free)
|
|
16
|
+
return free
|
|
17
|
+
}
|
|
18
|
+
if (pool.length < POOL_SIZE) {
|
|
19
|
+
const c = document.createElement('canvas')
|
|
20
|
+
c.width = OUTPUT_SIZE
|
|
21
|
+
c.height = OUTPUT_SIZE
|
|
22
|
+
pool.push(c)
|
|
23
|
+
inUse.add(c)
|
|
24
|
+
return c
|
|
25
|
+
}
|
|
26
|
+
const c = pool[pool.length - 1]
|
|
27
|
+
inUse.add(c)
|
|
28
|
+
return c
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function releaseCanvas(c: HTMLCanvasElement): void {
|
|
32
|
+
inUse.delete(c)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Slice rect [x, y, width, height] */
|
|
36
|
+
type Slice = [number, number, number, number]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Composite top/left/right block faces into an isometric-style icon.
|
|
40
|
+
* Uses a pool of canvases; not recreated each time.
|
|
41
|
+
*/
|
|
42
|
+
export function renderBlockIcon(
|
|
43
|
+
source: HTMLImageElement | string,
|
|
44
|
+
top: Slice,
|
|
45
|
+
left: Slice,
|
|
46
|
+
right: Slice,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const isUrl = typeof source === 'string'
|
|
50
|
+
const img = isUrl ? new Image() : (source as HTMLImageElement)
|
|
51
|
+
|
|
52
|
+
const draw = (image: HTMLImageElement) => {
|
|
53
|
+
let canvas: HTMLCanvasElement | null = null
|
|
54
|
+
try {
|
|
55
|
+
canvas = getCanvas()
|
|
56
|
+
const ctx = canvas.getContext('2d')
|
|
57
|
+
if (!ctx) throw new Error('No 2d context')
|
|
58
|
+
|
|
59
|
+
ctx.imageSmoothingEnabled = false
|
|
60
|
+
ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
|
|
61
|
+
|
|
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
|
|
65
|
+
const [tx, ty, tw, th] = top
|
|
66
|
+
const [lx, ly, lw, lh] = left
|
|
67
|
+
const [rx, ry, rw, rh] = right
|
|
68
|
+
|
|
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
|
|
80
|
+
ctx.save()
|
|
81
|
+
ctx.setTransform(1, 0.5, -1, 0.5, s + ox, oy)
|
|
82
|
+
ctx.drawImage(image, tx, ty, tw, th, 0, 0, s, s)
|
|
83
|
+
ctx.restore()
|
|
84
|
+
|
|
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()
|
|
100
|
+
|
|
101
|
+
const dataUrl = canvas.toDataURL('image/png')
|
|
102
|
+
resolve(dataUrl)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
reject(e)
|
|
105
|
+
} finally {
|
|
106
|
+
if (canvas) releaseCanvas(canvas)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isUrl) {
|
|
111
|
+
img.onload = () => draw(img)
|
|
112
|
+
img.onerror = () =>
|
|
113
|
+
reject(new Error('Failed to load block texture'))
|
|
114
|
+
img.crossOrigin = 'anonymous'
|
|
115
|
+
img.src = source
|
|
116
|
+
} else if (
|
|
117
|
+
(img).complete &&
|
|
118
|
+
(img).naturalWidth > 0
|
|
119
|
+
) {
|
|
120
|
+
draw(img)
|
|
121
|
+
} else {
|
|
122
|
+
img.onload = () => draw(img)
|
|
123
|
+
img.onerror = () =>
|
|
124
|
+
reject(new Error('Block texture image failed'))
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
@@ -45,6 +45,16 @@ export function isTextureFailed(url: string): boolean {
|
|
|
45
45
|
return failedUrls.has(url)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Clear all cached texture data URLs and failed state.
|
|
50
|
+
* Call after setOverride in bundledTexturesConfig so slots re-request textures.
|
|
51
|
+
*/
|
|
52
|
+
export function clearTextureCache(): void {
|
|
53
|
+
dataUrlCache.clear()
|
|
54
|
+
failedUrls.clear()
|
|
55
|
+
inflight.clear()
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
/**
|
|
49
59
|
* Hook that resolves a URL to a cached base64 data URL.
|
|
50
60
|
* Returns:
|
|
@@ -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
|
|
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.
|
|
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({
|
|
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
|
-
{
|
|
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
|
}
|
|
@@ -37,7 +37,7 @@ export function InventoryBackground({
|
|
|
37
37
|
const { scale } = useScale()
|
|
38
38
|
|
|
39
39
|
const bgUrl = textures.getGuiTextureUrl(definition.backgroundTexture)
|
|
40
|
-
const isStitched = definition.containerRows != null && definition.containerRows
|
|
40
|
+
const isStitched = definition.containerRows != null && definition.containerRows <= 6
|
|
41
41
|
|
|
42
42
|
const w = definition.backgroundWidth * scale
|
|
43
43
|
const h = definition.backgroundHeight * scale
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ItemStack } from '../../types'
|
|
|
3
3
|
import { useTextures } from '../../context/TextureContext'
|
|
4
4
|
import { useScale } from '../../context/ScaleContext'
|
|
5
5
|
import { useDataUrl, isTextureFailed } from '../../cache/textureCache'
|
|
6
|
+
import { renderBlockIcon } from '../../cache/blockRenderer'
|
|
6
7
|
|
|
7
8
|
interface ItemCanvasProps {
|
|
8
9
|
item: ItemStack
|
|
@@ -22,28 +23,68 @@ function getDurabilityColor(current: number, max: number): string {
|
|
|
22
23
|
return '#ff5555'
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
className,
|
|
32
|
-
style,
|
|
33
|
-
}: ItemCanvasProps) {
|
|
26
|
+
/** Resolve item texture source: direct override, block render, or URL lookup. */
|
|
27
|
+
function useItemTextureSrc(item: ItemStack): {
|
|
28
|
+
src: string | null
|
|
29
|
+
failed: boolean
|
|
30
|
+
loading: boolean
|
|
31
|
+
} {
|
|
34
32
|
const textures = useTextures()
|
|
35
|
-
const { contentSize, pixelSize } = useScale()
|
|
36
|
-
const renderSize = size ?? contentSize
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
34
|
+
// Block texture: always run effect for blockTexture case
|
|
35
|
+
const blockConfig = item.blockTexture ?? null
|
|
36
|
+
const [blockDataUrl, setBlockDataUrl] = useState<string | null>(null)
|
|
37
|
+
const [blockFailed, setBlockFailed] = useState(false)
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!blockConfig) return
|
|
41
|
+
let cancelled = false
|
|
42
|
+
renderBlockIcon(
|
|
43
|
+
blockConfig.source,
|
|
44
|
+
blockConfig.top.slice,
|
|
45
|
+
blockConfig.left.slice,
|
|
46
|
+
blockConfig.right.slice,
|
|
47
|
+
)
|
|
48
|
+
.then((url) => {
|
|
49
|
+
if (!cancelled) setBlockDataUrl(url)
|
|
50
|
+
})
|
|
51
|
+
.catch(() => {
|
|
52
|
+
if (!cancelled) setBlockFailed(true)
|
|
53
|
+
})
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true
|
|
56
|
+
}
|
|
57
|
+
}, [
|
|
58
|
+
blockConfig?.source,
|
|
59
|
+
blockConfig ? String(blockConfig.top.slice) : '',
|
|
60
|
+
blockConfig ? String(blockConfig.left.slice) : '',
|
|
61
|
+
blockConfig ? String(blockConfig.right.slice) : '',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
// Reset block state when switching away from blockTexture
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!blockConfig) {
|
|
67
|
+
setBlockDataUrl(null)
|
|
68
|
+
setBlockFailed(false)
|
|
69
|
+
}
|
|
70
|
+
}, [blockConfig])
|
|
71
|
+
|
|
72
|
+
// Direct texture (string) - use cache
|
|
73
|
+
const directUrl =
|
|
74
|
+
typeof item.texture === 'string' ? item.texture : null
|
|
75
|
+
const directDataUrl = useDataUrl(directUrl)
|
|
41
76
|
|
|
42
|
-
//
|
|
43
|
-
const
|
|
77
|
+
// Default URL lookup
|
|
78
|
+
const primaryUrl = blockConfig ? '' : (typeof item.texture === 'string' ? '' : textures.getItemTextureUrl(item))
|
|
79
|
+
const fallbackUrl =
|
|
80
|
+
!blockConfig &&
|
|
81
|
+
!item.textureKey &&
|
|
82
|
+
item.name
|
|
83
|
+
? textures.getBlockTextureUrl(item)
|
|
84
|
+
: null
|
|
85
|
+
const primaryDataUrl = useDataUrl(primaryUrl || null)
|
|
44
86
|
const primaryFailed = primaryDataUrl === null || isTextureFailed(primaryUrl)
|
|
45
87
|
|
|
46
|
-
// Load fallback only once primary is known to have failed
|
|
47
88
|
const [loadFallback, setLoadFallback] = useState(false)
|
|
48
89
|
useEffect(() => {
|
|
49
90
|
if (primaryFailed && fallbackUrl) setLoadFallback(true)
|
|
@@ -51,11 +92,56 @@ export const ItemCanvas = memo(function ItemCanvas({
|
|
|
51
92
|
}, [primaryFailed, fallbackUrl])
|
|
52
93
|
const fallbackDataUrl = useDataUrl(loadFallback ? fallbackUrl : null)
|
|
53
94
|
|
|
95
|
+
// Resolve final src by priority
|
|
96
|
+
if (typeof item.texture === 'string') {
|
|
97
|
+
return {
|
|
98
|
+
src: directDataUrl ?? null,
|
|
99
|
+
failed: directDataUrl === null || isTextureFailed(item.texture),
|
|
100
|
+
loading: directDataUrl === undefined,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (item.texture instanceof HTMLImageElement) {
|
|
104
|
+
const img = item.texture
|
|
105
|
+
return {
|
|
106
|
+
src: img.complete && img.naturalWidth > 0 ? img.src : null,
|
|
107
|
+
failed: false,
|
|
108
|
+
loading: !img.complete,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (blockConfig) {
|
|
112
|
+
return {
|
|
113
|
+
src: blockDataUrl,
|
|
114
|
+
failed: blockFailed,
|
|
115
|
+
loading: !blockDataUrl && !blockFailed,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
54
119
|
const src = loadFallback ? fallbackDataUrl : primaryDataUrl
|
|
55
120
|
const failed = loadFallback
|
|
56
121
|
? (fallbackDataUrl === null || isTextureFailed(fallbackUrl ?? ''))
|
|
57
122
|
: primaryFailed && !fallbackUrl
|
|
58
123
|
|
|
124
|
+
return {
|
|
125
|
+
src: src ?? null,
|
|
126
|
+
failed,
|
|
127
|
+
loading: src === undefined,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Renders a single item: texture as <img>, count as <span>, durability as CSS bars. */
|
|
132
|
+
export const ItemCanvas = memo(function ItemCanvas({
|
|
133
|
+
item,
|
|
134
|
+
size,
|
|
135
|
+
noCount = false,
|
|
136
|
+
noDurability = false,
|
|
137
|
+
className,
|
|
138
|
+
style,
|
|
139
|
+
}: ItemCanvasProps) {
|
|
140
|
+
const { contentSize, pixelSize } = useScale()
|
|
141
|
+
const renderSize = size ?? contentSize
|
|
142
|
+
|
|
143
|
+
const { src, failed, loading } = useItemTextureSrc(item)
|
|
144
|
+
|
|
59
145
|
const hasDurability =
|
|
60
146
|
!noDurability &&
|
|
61
147
|
item.durability !== undefined &&
|