minecraft-inventory 0.1.4 → 0.1.6
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 +103 -0
- package/src/cache/textureCache.ts +10 -0
- package/src/components/InventoryOverlay/InventoryOverlay.tsx +1 -1
- package/src/components/InventoryWindow/InventoryBackground.tsx +69 -82
- 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/Notes/Notes.tsx +76 -45
- package/src/components/Slot/Slot.tsx +6 -0
- package/src/components/Text/MessageFormattedString.tsx +1 -0
- package/src/components/Tooltip/Tooltip.tsx +1 -2
- package/src/connector/demo.ts +65 -0
- package/src/connector/mineflayer.ts +18 -3
- package/src/context/InventoryContext.tsx +34 -17
- package/src/generated/localTextures.ts +66 -51
- package/src/index.tsx +19 -1
- package/src/registry/index.ts +30 -1
- package/src/registry/inventories.ts +19 -16
- package/src/types.ts +38 -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,103 @@
|
|
|
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 / 16
|
|
63
|
+
const [tx, ty, tw, th] = top
|
|
64
|
+
const [lx, ly, lw, lh] = left
|
|
65
|
+
const [rx, ry, rw, rh] = right
|
|
66
|
+
|
|
67
|
+
// Isometric layout: top face tilted, left and right as sides
|
|
68
|
+
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)
|
|
72
|
+
ctx.restore()
|
|
73
|
+
|
|
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)
|
|
76
|
+
|
|
77
|
+
const dataUrl = canvas.toDataURL('image/png')
|
|
78
|
+
resolve(dataUrl)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
reject(e)
|
|
81
|
+
} finally {
|
|
82
|
+
if (canvas) releaseCanvas(canvas)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isUrl) {
|
|
87
|
+
img.onload = () => draw(img)
|
|
88
|
+
img.onerror = () =>
|
|
89
|
+
reject(new Error('Failed to load block texture'))
|
|
90
|
+
img.crossOrigin = 'anonymous'
|
|
91
|
+
img.src = source
|
|
92
|
+
} else if (
|
|
93
|
+
(img).complete &&
|
|
94
|
+
(img).naturalWidth > 0
|
|
95
|
+
) {
|
|
96
|
+
draw(img)
|
|
97
|
+
} else {
|
|
98
|
+
img.onload = () => draw(img)
|
|
99
|
+
img.onerror = () =>
|
|
100
|
+
reject(new Error('Block texture image failed'))
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
@@ -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:
|
|
@@ -1,54 +1,24 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from 'react'
|
|
2
2
|
import { useTextures } from '../../context/TextureContext'
|
|
3
3
|
import { useScale } from '../../context/ScaleContext'
|
|
4
4
|
import { MessageFormattedString } from '../Text/MessageFormattedString'
|
|
5
5
|
import type { InventoryTypeDefinition } from '../../registry'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* For generic_9xN (N < 6):
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* For generic_9xN (N < 6): CSS-stitch the 6-row generic_54 texture using two
|
|
9
|
+
* overlapping img elements with objectPosition to clip top and bottom portions.
|
|
10
|
+
* No canvas or async loading needed — works with any URL including remote ones.
|
|
11
11
|
*
|
|
12
|
-
* The source texture (generic_54.png, 176×222) layout:
|
|
13
|
-
* y=0..16
|
|
14
|
-
* y=17..124 — 6 container rows (6×18 = 108px)
|
|
15
|
-
* y=
|
|
12
|
+
* The source texture (generic_54.png, 176×222) fixed layout:
|
|
13
|
+
* y=0 ..16 — title bar (17px)
|
|
14
|
+
* y=17 ..124 — 6 container rows (6×18 = 108px)
|
|
15
|
+
* y=125 ..221 — player inventory section (97px)
|
|
16
|
+
*
|
|
17
|
+
* Output height = topH + playerH = N*18+17 + 97 = N*18+114.
|
|
18
|
+
* Registry backgroundHeight must equal N*18+114 for the div to match.
|
|
16
19
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
if (containerRows >= 6) {
|
|
22
|
-
setDataUrl(srcUrl)
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
let cancelled = false
|
|
26
|
-
const img = new window.Image()
|
|
27
|
-
img.crossOrigin = 'anonymous'
|
|
28
|
-
img.onload = () => {
|
|
29
|
-
if (cancelled) return
|
|
30
|
-
const srcW = 176
|
|
31
|
-
const srcH = img.naturalHeight // 222 for generic_54
|
|
32
|
-
const topH = containerRows * 18 + 17
|
|
33
|
-
const playerH = 96
|
|
34
|
-
const canvas = document.createElement('canvas')
|
|
35
|
-
canvas.width = srcW
|
|
36
|
-
canvas.height = topH + playerH
|
|
37
|
-
const ctx = canvas.getContext('2d')
|
|
38
|
-
if (!ctx) { setDataUrl(srcUrl); return }
|
|
39
|
-
// Top: title + N container rows
|
|
40
|
-
ctx.drawImage(img, 0, 0, srcW, topH, 0, 0, srcW, topH)
|
|
41
|
-
// Bottom: player inventory section (last 96px of source)
|
|
42
|
-
ctx.drawImage(img, 0, srcH - playerH, srcW, playerH, 0, topH, srcW, playerH)
|
|
43
|
-
setDataUrl(canvas.toDataURL())
|
|
44
|
-
}
|
|
45
|
-
img.onerror = () => { if (!cancelled) setDataUrl(srcUrl) }
|
|
46
|
-
img.src = srcUrl
|
|
47
|
-
return () => { cancelled = true }
|
|
48
|
-
}, [srcUrl, containerRows])
|
|
49
|
-
|
|
50
|
-
return dataUrl
|
|
51
|
-
}
|
|
20
|
+
const SRC_PLAYER_Y = 17 + 6 * 18 // 125 — fixed position where player section starts
|
|
21
|
+
const PLAYER_H = 222 - SRC_PLAYER_Y // 97px — height of player section in generic_54.png
|
|
52
22
|
|
|
53
23
|
interface InventoryBackgroundProps {
|
|
54
24
|
definition: InventoryTypeDefinition
|
|
@@ -66,20 +36,24 @@ export function InventoryBackground({
|
|
|
66
36
|
const textures = useTextures()
|
|
67
37
|
const { scale } = useScale()
|
|
68
38
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const stitchedUrl = useStitchedTexture(rawBgUrl, definition.containerRows ?? 6)
|
|
72
|
-
const bgUrl = definition.containerRows != null && definition.containerRows < 6
|
|
73
|
-
? stitchedUrl // may be null while stitching
|
|
74
|
-
: rawBgUrl
|
|
39
|
+
const bgUrl = textures.getGuiTextureUrl(definition.backgroundTexture)
|
|
40
|
+
const isStitched = definition.containerRows != null && definition.containerRows <= 6
|
|
75
41
|
|
|
76
42
|
const w = definition.backgroundWidth * scale
|
|
77
43
|
const h = definition.backgroundHeight * scale
|
|
78
44
|
|
|
79
|
-
// Source dimensions from definition (e.g., 176x166) — clip to this region from texture
|
|
80
45
|
const srcW = definition.backgroundWidth
|
|
81
46
|
const srcH = definition.backgroundHeight
|
|
82
47
|
|
|
48
|
+
const sharedImgStyle: React.CSSProperties = {
|
|
49
|
+
display: 'block',
|
|
50
|
+
width: srcW,
|
|
51
|
+
imageRendering: 'pixelated',
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
userSelect: 'none',
|
|
54
|
+
objectFit: 'none',
|
|
55
|
+
}
|
|
56
|
+
|
|
83
57
|
return (
|
|
84
58
|
<div
|
|
85
59
|
className="mc-inv-background"
|
|
@@ -93,42 +67,55 @@ export function InventoryBackground({
|
|
|
93
67
|
outlineOffset: 0,
|
|
94
68
|
}}
|
|
95
69
|
>
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
top: 0,
|
|
102
|
-
left: 0,
|
|
103
|
-
width: srcW,
|
|
104
|
-
height: srcH,
|
|
105
|
-
overflow: 'hidden',
|
|
106
|
-
transform: `scale(${scale})`,
|
|
107
|
-
transformOrigin: 'top left',
|
|
108
|
-
}}
|
|
109
|
-
>
|
|
110
|
-
{/* Background texture — render at natural size, clipped by wrapper overflow */}
|
|
111
|
-
{bgUrl && (
|
|
112
|
-
<img
|
|
113
|
-
className="mc-inv-background-image"
|
|
114
|
-
src={bgUrl}
|
|
115
|
-
alt=""
|
|
116
|
-
aria-hidden
|
|
70
|
+
{isStitched ? (
|
|
71
|
+
/* CSS two-part stitch: clips top (title+N rows) and bottom (player section)
|
|
72
|
+
from the same source image using objectPosition — no canvas/async needed. */
|
|
73
|
+
<div
|
|
74
|
+
className="mc-inv-background-wrapper"
|
|
117
75
|
style={{
|
|
118
|
-
|
|
76
|
+
position: 'absolute',
|
|
77
|
+
top: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
transform: `scale(${scale})`,
|
|
80
|
+
transformOrigin: 'top left',
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{/* Top: title bar + N container rows */}
|
|
84
|
+
<div style={{ width: srcW, height: definition.containerRows! * 18 + 17, overflow: 'hidden' }}>
|
|
85
|
+
<img className="mc-inv-background-image" src={bgUrl} alt="" aria-hidden draggable={false}
|
|
86
|
+
style={{ ...sharedImgStyle, objectPosition: '0 0' }} />
|
|
87
|
+
</div>
|
|
88
|
+
{/* Bottom: player inventory section starting at SRC_PLAYER_Y in source */}
|
|
89
|
+
<div style={{ width: srcW, height: PLAYER_H, overflow: 'hidden' }}>
|
|
90
|
+
<img src={bgUrl} alt="" aria-hidden draggable={false}
|
|
91
|
+
style={{ ...sharedImgStyle, objectPosition: `0 -${SRC_PLAYER_Y}px` }} />
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
/* Standard: clip source to srcW×srcH via overflow:hidden, then scale */
|
|
96
|
+
<div
|
|
97
|
+
className="mc-inv-background-wrapper"
|
|
98
|
+
style={{
|
|
99
|
+
position: 'absolute',
|
|
100
|
+
top: 0,
|
|
101
|
+
left: 0,
|
|
119
102
|
width: srcW,
|
|
120
103
|
height: srcH,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Clip to top-left srcW×srcH region (if texture is larger)
|
|
125
|
-
objectFit: 'none',
|
|
126
|
-
objectPosition: '0 0',
|
|
104
|
+
overflow: 'hidden',
|
|
105
|
+
transform: `scale(${scale})`,
|
|
106
|
+
transformOrigin: 'top left',
|
|
127
107
|
}}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
108
|
+
>
|
|
109
|
+
<img
|
|
110
|
+
className="mc-inv-background-image"
|
|
111
|
+
src={bgUrl}
|
|
112
|
+
alt=""
|
|
113
|
+
aria-hidden
|
|
114
|
+
draggable={false}
|
|
115
|
+
style={{ ...sharedImgStyle, objectPosition: '0 0' }}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
132
119
|
|
|
133
120
|
{/* Title */}
|
|
134
121
|
{title !== undefined && (
|
|
@@ -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 &&
|