fuck it we're rendering blocks using webgl

This commit is contained in:
2026-05-19 00:15:54 -04:00
parent d440199d4e
commit b802c39334
15 changed files with 80463 additions and 179 deletions
@@ -0,0 +1,458 @@
import * as THREE from '/assets/three.module.js';
// Renders Minecraft items the way Minecraft does: by walking the official
// item-definition → model → parent-chain graph and baking the resulting
// elements + textures into a small WebGL scene. Returns an HTMLImageElement
// per material, cached forever for that page.
const RENDER_SIZE = 96;
const FACE_BRIGHTNESS = { up: 1.0, down: 0.5, north: 0.8, south: 0.8, east: 0.6, west: 0.6 };
const DEFAULT_GUI_TRANSFORM = { rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1] };
const DEFAULT_BLOCK_GUI = { rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625] };
// Default "plains biome" tints — what Minecraft uses for the inventory icon
// when no biome context exists. Faces with a `tintindex` get multiplied by
// the entry for their material, leaving the texture's grayscale source
// (e.g. block/grass_block_top.png) as the only colour signal.
const GRASS = 0x91BD59;
const FOLIAGE = 0x48B518;
const WATER = 0x3F76E4;
const TINT_RGB = {
grass_block: GRASS, short_grass: GRASS, tall_grass: GRASS,
fern: GRASS, large_fern: GRASS, sugar_cane: GRASS,
pink_petals: GRASS,
oak_leaves: FOLIAGE, jungle_leaves: FOLIAGE, acacia_leaves: FOLIAGE,
dark_oak_leaves: FOLIAGE, mangrove_leaves: FOLIAGE, vine: FOLIAGE,
birch_leaves: 0x80A755,
spruce_leaves: 0x619961,
lily_pad: 0x208030,
water: WATER, water_bucket: WATER,
melon_stem: 0xE0C71C, pumpkin_stem: 0xE0C71C,
attached_melon_stem: 0xE0C71C, attached_pumpkin_stem: 0xE0C71C,
redstone_wire: 0xFF0000,
};
const itemCache = new Map(); // material → Promise<HTMLImageElement | null>
const itemDefCache = new Map(); // material → Promise<itemdef json>
const modelCache = new Map(); // path → Promise<resolved model>
const textureCache = new Map(); // path → Promise<THREE.Texture>
let renderer = null;
let scene = null;
let camera = null;
function initThree() {
if (renderer) return;
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false, preserveDrawingBuffer: false });
renderer.setSize(RENDER_SIZE, RENDER_SIZE);
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
scene = new THREE.Scene();
// Frustum sized to fit a 16-unit cube at the standard 0.625 GUI scale,
// with a touch of padding so corners don't clip after rotation.
const half = 8.2;
camera = new THREE.OrthographicCamera(-half, half, half, -half, -200, 200);
camera.position.set(0, 0, 100);
camera.lookAt(0, 0, 0);
}
function stripNs(ref) {
if (!ref) return ref;
const i = ref.indexOf(':');
return i >= 0 ? ref.substring(i + 1) : ref;
}
async function loadItemDef(name) {
if (itemDefCache.has(name)) return itemDefCache.get(name);
const p = fetch(`/assets/items/${name}.json`).then(r => {
if (!r.ok) throw new Error(`item def ${name}: ${r.status}`);
return r.json();
});
itemDefCache.set(name, p);
return p;
}
const BUILTIN = {
'builtin/generated': { builtin: 'generated', textures: {}, display: {} },
'builtin/entity': { builtin: 'entity', textures: {}, display: {} },
};
async function loadModel(path) {
if (BUILTIN[path]) return BUILTIN[path];
if (modelCache.has(path)) return modelCache.get(path);
const p = (async () => {
const r = await fetch(`/assets/models/${path}.json`);
if (!r.ok) throw new Error(`model ${path}: ${r.status}`);
const data = await r.json();
if (!data.parent) return data;
const parent = await loadModel(stripNs(data.parent));
return mergeModel(parent, data);
})();
modelCache.set(path, p);
return p;
}
function mergeModel(parent, child) {
return {
builtin: child.builtin ?? parent.builtin,
elements: child.elements ?? parent.elements,
gui_light: child.gui_light ?? parent.gui_light,
textures: { ...(parent.textures || {}), ...(child.textures || {}) },
display: { ...(parent.display || {}), ...(child.display || {}) },
};
}
function resolveTextureRef(textures, ref) {
let cur = ref;
for (let i = 0; i < 16 && cur; i++) {
if (!cur.startsWith('#')) return stripNs(cur);
cur = textures[cur.substring(1)];
}
return null;
}
async function loadTexture(path) {
if (textureCache.has(path)) return textureCache.get(path);
const p = new Promise((resolve, reject) => {
new THREE.TextureLoader().load(`/assets/textures/${path}.png`, tex => {
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = THREE.ClampToEdgeWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
// Animated textures are vertical strips; crop to the first frame
// by repeating only the top square portion of the image.
const img = tex.image;
if (img && img.height > img.width) {
const frame = img.width / img.height;
tex.repeat.y = frame;
tex.offset.y = 1 - frame;
tex.needsUpdate = true;
}
resolve(tex);
}, undefined, reject);
});
textureCache.set(path, p);
return p;
}
// Extract the first concrete model path from an item definition's model node.
// Handles minecraft:model directly and recurses into condition / select /
// range_dispatch / etc., taking whatever fallback the structure exposes.
function extractModelPath(node) {
if (!node) return null;
if (typeof node === 'string') return node;
if (node.type === 'minecraft:model' && typeof node.model === 'string') return node.model;
if (node.type === 'minecraft:special' && typeof node.base === 'string') return node.base;
const fields = ['fallback', 'on_false', 'on_true', 'model'];
for (const f of fields) {
if (node[f]) {
const r = extractModelPath(node[f]);
if (r) return r;
}
}
for (const arrField of ['cases', 'entries']) {
const arr = node[arrField];
if (Array.isArray(arr)) {
for (const e of arr) {
const r = extractModelPath(e.model || e);
if (r) return r;
}
}
}
return null;
}
function tintToRgb(tint) {
if (!tint || typeof tint !== 'object') return null;
const type = stripNs(tint.type);
if (type === 'constant' && Number.isFinite(tint.value)) return tint.value & 0xFFFFFF;
if (type === 'grass') return GRASS;
if (type === 'foliage') return FOLIAGE;
if (type === 'water') return WATER;
return null;
}
function extractTintRgb(node, itemName) {
if (!node || typeof node === 'string') return TINT_RGB[itemName] ?? null;
if (Array.isArray(node.tints) && node.tints.length) {
const rgb = tintToRgb(node.tints[0]);
if (rgb != null) return rgb;
}
const fields = ['fallback', 'on_false', 'on_true', 'model'];
for (const f of fields) {
if (node[f] && typeof node[f] !== 'string') {
const r = extractTintRgb(node[f], itemName);
if (r != null) return r;
}
}
for (const arrField of ['cases', 'entries']) {
const arr = node[arrField];
if (Array.isArray(arr)) {
for (const e of arr) {
const r = extractTintRgb(e.model || e, itemName);
if (r != null) return r;
}
}
}
return TINT_RGB[itemName] ?? null;
}
// Convert MC face data (face name, element from/to, uv rect) to vertex
// positions and UVs ready for a THREE.BufferGeometry. Vertices are emitted
// in TL, BL, BR, TR order when looking at the face from outside the cuboid.
function faceQuad(face, from, to, uv) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const [u1, v1, u2, v2] = uv;
let positions;
switch (face) {
case 'up': positions = [x1,y2,z1, x1,y2,z2, x2,y2,z2, x2,y2,z1]; break;
case 'down': positions = [x1,y1,z2, x1,y1,z1, x2,y1,z1, x2,y1,z2]; break;
case 'north': positions = [x2,y2,z1, x2,y1,z1, x1,y1,z1, x1,y2,z1]; break;
case 'south': positions = [x1,y2,z2, x1,y1,z2, x2,y1,z2, x2,y2,z2]; break;
case 'east': positions = [x2,y2,z2, x2,y1,z2, x2,y1,z1, x2,y2,z1]; break;
case 'west': positions = [x1,y2,z1, x1,y1,z1, x1,y1,z2, x1,y2,z2]; break;
default: return null;
}
const uvs = [
u1 / 16, 1 - v1 / 16,
u1 / 16, 1 - v2 / 16,
u2 / 16, 1 - v2 / 16,
u2 / 16, 1 - v1 / 16,
];
return { positions, uvs };
}
// When a face omits "uv", MC derives it from the element's coords.
function defaultUV(face, from, to) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
switch (face) {
case 'up':
case 'down': return [x1, z1, x2, z2];
case 'north':
case 'south': return [x1, 16 - y2, x2, 16 - y1];
case 'east':
case 'west': return [z1, 16 - y2, z2, 16 - y1];
default: return [0, 0, 16, 16];
}
}
async function buildElementGroup(elem, textures, guiLight, tintRgb) {
const group = new THREE.Group();
const faces = elem.faces || {};
const sideLit = guiLight !== 'front';
for (const [face, data] of Object.entries(faces)) {
const texPath = resolveTextureRef(textures, data.texture);
if (!texPath) continue;
let tex;
try { tex = await loadTexture(texPath); }
catch { continue; }
const uv = data.uv || defaultUV(face, elem.from, elem.to);
const quad = faceQuad(face, elem.from, elem.to, uv);
if (!quad) continue;
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(quad.positions, 3));
geom.setAttribute('uv', new THREE.Float32BufferAttribute(quad.uvs, 2));
geom.setIndex([0, 1, 2, 0, 2, 3]);
const b = sideLit ? (FACE_BRIGHTNESS[face] ?? 1) : 1;
const tinted = (data.tintindex !== undefined) && tintRgb != null;
const tr = tinted ? ((tintRgb >> 16) & 0xFF) / 255 : 1;
const tg = tinted ? ((tintRgb >> 8) & 0xFF) / 255 : 1;
const tb = tinted ? ( tintRgb & 0xFF) / 255 : 1;
const mat = new THREE.MeshBasicMaterial({
map: tex,
color: new THREE.Color(b * tr, b * tg, b * tb),
transparent: true,
alphaTest: 0.01,
side: THREE.DoubleSide,
depthWrite: true,
// Pull tinted overlay faces a hair toward the camera so they
// don't z-fight the non-tinted faces underneath (e.g. grass
// block's side overlay over its dirt-and-grass side).
polygonOffset: tinted,
polygonOffsetFactor: tinted ? -1 : 0,
polygonOffsetUnits: tinted ? -1 : 0,
});
group.add(new THREE.Mesh(geom, mat));
}
if (elem.rotation) {
const origin = elem.rotation.origin || [8, 8, 8];
const angle = ((elem.rotation.angle || 0) * Math.PI) / 180;
const axis = elem.rotation.axis || 'y';
const wrapTo = new THREE.Group();
wrapTo.position.set(origin[0], origin[1], origin[2]);
const rot = new THREE.Group();
if (axis === 'x') rot.rotation.x = angle;
else if (axis === 'y') rot.rotation.y = angle;
else if (axis === 'z') rot.rotation.z = angle;
wrapTo.add(rot);
const wrapBack = new THREE.Group();
wrapBack.position.set(-origin[0], -origin[1], -origin[2]);
rot.add(wrapBack);
wrapBack.add(group);
return wrapTo;
}
return group;
}
async function buildElementsModel(model, tintRgb) {
const group = new THREE.Group();
const textures = model.textures || {};
const guiLight = model.gui_light;
for (const elem of model.elements || []) {
group.add(await buildElementGroup(elem, textures, guiLight, tintRgb));
}
return group;
}
async function buildLayeredSprite(model, tintRgb) {
const group = new THREE.Group();
const textures = model.textures || {};
for (let i = 0; ; i++) {
const ref = textures[`layer${i}`];
if (!ref) break;
let tex;
try { tex = await loadTexture(stripNs(ref)); }
catch { continue; }
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute([
0, 16, 0,
0, 0, 0,
16, 0, 0,
16, 16, 0,
], 3));
geom.setAttribute('uv', new THREE.Float32BufferAttribute([0, 1, 0, 0, 1, 0, 1, 1], 2));
geom.setIndex([0, 1, 2, 0, 2, 3]);
const tinted = i === 0 && tintRgb != null;
const tr = tinted ? ((tintRgb >> 16) & 0xFF) / 255 : 1;
const tg = tinted ? ((tintRgb >> 8) & 0xFF) / 255 : 1;
const tb = tinted ? ( tintRgb & 0xFF) / 255 : 1;
const mat = new THREE.MeshBasicMaterial({
map: tex,
color: new THREE.Color(tr, tg, tb),
transparent: true,
alphaTest: 0.01,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geom, mat);
mesh.position.z = i * 0.05;
group.add(mesh);
}
return group;
}
function atlasUv(x1, y1, x2, y2, tw = 64, th = 64) {
return [x1 / tw, 1 - y1 / th, x1 / tw, 1 - y2 / th, x2 / tw, 1 - y2 / th, x2 / tw, 1 - y1 / th];
}
function addBoxFace(group, positions, uv, material) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geom.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geom.setIndex([0, 1, 2, 0, 2, 3]);
group.add(new THREE.Mesh(geom, material));
}
function addShieldBox(group, from, to, frontUv, backUv, frontMat, backMat, sideMat) {
const [x1, y1, z1] = from;
const [x2, y2, z2] = to;
const full = [0, 0, 0, 1, 1, 1, 1, 0];
addBoxFace(group, [x1,y2,z2, x1,y1,z2, x2,y1,z2, x2,y2,z2], frontUv, frontMat); // south/front
addBoxFace(group, [x2,y2,z1, x2,y1,z1, x1,y1,z1, x1,y2,z1], backUv, backMat); // north/back
addBoxFace(group, [x1,y2,z1, x1,y2,z2, x2,y2,z2, x2,y2,z1], full, sideMat); // top
addBoxFace(group, [x1,y1,z2, x1,y1,z1, x2,y1,z1, x2,y1,z2], full, sideMat); // bottom
addBoxFace(group, [x2,y2,z2, x2,y1,z2, x2,y1,z1, x2,y2,z1], full, sideMat); // right
addBoxFace(group, [x1,y2,z1, x1,y1,z1, x1,y1,z2, x1,y2,z2], full, sideMat); // left
}
async function buildShieldSprite() {
const tex = await loadTexture('entity/shield/shield_base_nopattern');
const textured = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
alphaTest: 0.01,
side: THREE.FrontSide,
});
const side = new THREE.MeshBasicMaterial({ color: new THREE.Color(0.48, 0.48, 0.52) });
const handle = new THREE.MeshBasicMaterial({ color: new THREE.Color(0.30, 0.20, 0.10) });
const group = new THREE.Group();
// Vanilla shields are special entity models, so they don't expose normal
// item-model elements. Build a small cuboid approximation from the entity
// texture atlas instead of drawing a flat sprite.
addShieldBox(group, [2, -3, 7], [14, 19, 9], atlasUv(1, 2, 13, 24), atlasUv(15, 2, 27, 24), textured, textured, side);
addShieldBox(group, [5, 4, 5], [11, 12, 7], atlasUv(29, 1, 35, 9), atlasUv(36, 1, 42, 9), textured, textured, handle);
return group;
}
const SHIELD_GUI_TRANSFORM = { rotation: [15, -35, -5], translation: [0, 0, 0], scale: [0.72, 0.72, 0.72] };
function applyGuiTransform(group, display, isBlockShape) {
const gui = (display && display.gui) || (isBlockShape ? DEFAULT_BLOCK_GUI : DEFAULT_GUI_TRANSFORM);
const r = gui.rotation || [0, 0, 0];
const t = gui.translation || [0, 0, 0];
const s = gui.scale || [1, 1, 1];
const inner = new THREE.Group();
inner.position.set(-8, -8, -8);
inner.add(group);
const outer = new THREE.Group();
outer.rotation.set(
THREE.MathUtils.degToRad(r[0]),
THREE.MathUtils.degToRad(r[1]),
THREE.MathUtils.degToRad(r[2]),
);
outer.scale.set(s[0], s[1], s[2]);
outer.position.set(t[0], t[1], t[2]);
outer.add(inner);
return outer;
}
function disposeGroup(group) {
group.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
// Don't dispose the texture — it's shared via cache.
obj.material.dispose();
}
});
}
async function renderItem(name) {
if (itemCache.has(name)) return itemCache.get(name);
const p = (async () => {
try {
initThree();
const itemDef = await loadItemDef(name);
const modelRef = extractModelPath(itemDef.model);
if (!modelRef) return null;
const model = await loadModel(stripNs(modelRef));
const isBlockShape = !!(model.elements && model.elements.length);
const tintRgb = extractTintRgb(itemDef.model, name);
const isShield = name === 'shield';
const inner = isShield
? await buildShieldSprite()
: isBlockShape
? await buildElementsModel(model, tintRgb)
: await buildLayeredSprite(model, tintRgb);
const outer = applyGuiTransform(inner, isShield ? { gui: SHIELD_GUI_TRANSFORM } : model.display, isBlockShape);
// The next four lines must stay synchronous so concurrent
// renderItem() callers can't interleave on the shared scene.
scene.add(outer);
renderer.render(scene, camera);
const dataUrl = renderer.domElement.toDataURL('image/png');
scene.remove(outer);
disposeGroup(outer);
return dataUrl;
} catch (e) {
console.warn('blockrenderer: failed', name, e);
return null;
}
})();
itemCache.set(name, p);
return p;
}
export { renderItem };
+73 -24
View File
@@ -1,4 +1,4 @@
(function () {
(async function () {
const pingEl = document.querySelector('[data-player-ping]');
const statusEl = document.querySelector('[data-player-status]');
const worldEl = document.querySelector('[data-player-world]');
@@ -7,6 +7,8 @@
const uuid = pingEl.getAttribute('data-uuid');
if (!uuid) return;
const { renderItem } = await import('/assets/blockrenderer.js');
// ---- Helpers ----
function escapeHtml(s) {
@@ -83,20 +85,38 @@
const actionInput = form.querySelector('[data-action-input]');
const actionLabel = form.querySelector('[data-action-label]');
const durationField = form.querySelector('[data-duration-field]');
const reasonField = form.querySelector('[data-reason-field]');
const reasonInput = form.querySelector('input[name="reason"]');
const slotInput = form.querySelector('[data-slot-input]');
const actionDescription = form.querySelector('[data-action-description]');
document.querySelectorAll('[data-admin-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-admin-action');
const isTemp = btn.getAttribute('data-admin-temp') === 'true';
const noReason = btn.getAttribute('data-admin-no-reason') === 'true';
const selectedRequired = btn.getAttribute('data-selected-required') === 'true';
if (selectedRequired && !selectedKey) return;
actionInput.value = action;
actionLabel.textContent = action;
actionLabel.textContent = action.replace(/-/g, ' ');
if (slotInput) slotInput.value = selectedRequired ? selectedKey : '';
durationField.hidden = !isTemp;
durationField.querySelector('select').disabled = !isTemp;
if (reasonInput) reasonInput.value = '';
if (reasonField) reasonField.hidden = noReason;
if (reasonInput) {
reasonInput.disabled = noReason;
reasonInput.required = !noReason;
reasonInput.value = '';
}
if (actionDescription) {
const target = 'Target: <span class="font-medium text-foreground">' + escapeHtml(document.querySelector('h1')?.textContent || 'player') + '</span>';
actionDescription.innerHTML = selectedRequired
? target + '<br>Slot: <span class="font-mono text-foreground">' + escapeHtml(selectedKey) + '</span>'
: target;
}
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
if (reasonInput) setTimeout(() => reasonInput.focus(), 0);
if (!noReason && reasonInput) setTimeout(() => reasonInput.focus(), 0);
});
});
@@ -115,6 +135,17 @@
// Slot currently rendered in the detail panel (key like "storage-5"); kept across re-renders so the highlight survives data refreshes.
let selectedKey = null;
function updateInventoryActionButtons() {
const selectedItem = getItemBySlotKey(lastInv, selectedKey);
const enabled = !!(lastInv && lastInv.online && selectedKey && selectedItem);
document.querySelectorAll('[data-selected-required]').forEach(btn => {
btn.disabled = !enabled;
if (enabled) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
btn.title = enabled ? 'Clear ' + selectedKey : 'Select an occupied inventory slot first';
});
}
function renderDurabilityBar(item) {
if (!item.maxDamage) return '';
const damage = item.damage || 0;
@@ -143,23 +174,28 @@
return escapeHtml(parts.join(' • '));
}
function renderItemIcon(item, large = false) {
const tex = item.texture || {};
if (tex.top) {
const side = tex.side || tex.top;
const sizeClass = large ? 'iso-cube--lg' : 'iso-cube--sm';
return `
<div class="iso-cube ${sizeClass} pointer-events-none">
<div class="iso-face iso-top" style="background-image:url(${tex.top})"></div>
<div class="iso-face iso-front" style="background-image:url(${side})"></div>
<div class="iso-face iso-right" style="background-image:url(${side})"></div>
</div>
`;
}
if (tex.flat) {
return `<img src="${tex.flat}" alt="${escapeHtml(item.type)}" loading="lazy" class="size-full object-contain pointer-events-none">`;
}
return `<span class="absolute inset-0 grid place-items-center text-[8px] font-mono text-muted-foreground leading-tight px-0.5 text-center break-all pointer-events-none">${escapeHtml(item.type.toLowerCase().replace(/_/g, ' '))}</span>`;
function renderItemIcon(item) {
const name = item.type.toLowerCase();
const label = escapeHtml(name.replace(/_/g, ' '));
return `<span class="absolute inset-0 pointer-events-none" data-item-icon="${escapeHtml(name)}">
<span class="absolute inset-0 grid place-items-center text-[8px] font-mono text-muted-foreground leading-tight px-0.5 text-center break-all">${label}</span>
</span>`;
}
async function hydrateIcons(root) {
const targets = Array.from(root.querySelectorAll('[data-item-icon]:not([data-item-hydrated])'));
for (const el of targets) el.setAttribute('data-item-hydrated', '');
await Promise.all(targets.map(async el => {
const name = el.getAttribute('data-item-icon');
const url = await renderItem(name);
if (!url) return;
const img = document.createElement('img');
img.className = 'size-full object-contain pointer-events-none [image-rendering:pixelated]';
img.alt = name;
img.src = url;
el.innerHTML = '';
el.appendChild(img);
}));
}
function renderSlot(item, key) {
@@ -235,7 +271,7 @@
const lines = [];
lines.push(`<div class="flex items-start gap-3">
<div class="ring-card relative size-16 shrink-0 rounded-md bg-muted/40">
${renderItemIcon(item, true)}
${renderItemIcon(item)}
</div>
<div class="min-w-0">
${safeName ? `<p class="truncate text-base font-medium italic">${safeName}</p>` : ''}
@@ -325,20 +361,29 @@
}
function render(inv) {
const previousGrid = invRoot.querySelector('[data-inv-grid]');
const previousScrollLeft = previousGrid ? previousGrid.scrollLeft : 0;
lastInv = inv;
if (!inv.online) {
selectedKey = null;
invRoot.innerHTML = `<p class="py-6 text-center text-sm text-muted-foreground">Player is offline.</p>`;
updateInventoryActionButtons();
return;
}
invRoot.innerHTML = `
<div class="grid gap-6 lg:grid-cols-[auto_1fr]">
<div data-inv-grid>${renderInventoryGrid(inv)}</div>
<div data-inv-grid class="-mx-2 overflow-x-auto px-2 pb-2 sm:mx-0 sm:px-0">
<div class="min-w-max">${renderInventoryGrid(inv)}</div>
</div>
<div data-inv-detail class="rounded-xl border border-border/40 bg-background/40 p-4">
${renderDetailPanel(getItemBySlotKey(inv, selectedKey))}
</div>
</div>
`;
const grid = invRoot.querySelector('[data-inv-grid]');
if (grid) grid.scrollLeft = previousScrollLeft;
hydrateIcons(invRoot);
updateInventoryActionButtons();
}
invRoot.addEventListener('click', (evt) => {
@@ -360,13 +405,17 @@
selectedKey = btn.getAttribute('data-slot-key');
const item = getItemBySlotKey(lastInv, selectedKey);
const detail = invRoot.querySelector('[data-inv-detail]');
if (detail) detail.innerHTML = renderDetailPanel(item);
if (detail) {
detail.innerHTML = renderDetailPanel(item);
hydrateIcons(detail);
}
invRoot.querySelectorAll('[data-slot-key]').forEach(el => {
const isSelected = el.getAttribute('data-slot-key') === selectedKey;
el.classList.toggle('ring-2', isSelected);
el.classList.toggle('ring-primary', isSelected);
el.classList.toggle('ring-card', !isSelected);
});
updateInventoryActionButtons();
});
const invSrc = new EventSource('/api/player/inventory/stream?uuid=' + encodeURIComponent(uuid));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12 -27
View File
@@ -85,35 +85,19 @@ PLAYERS
class="col-span-2 h-9 rounded-full bg-primary/10 px-4 text-sm font-medium text-primary transition-colors hover:bg-primary/20">
Freeze
</button>
<button type="button" data-admin-action="clear-inventory" data-admin-temp="false" data-admin-no-reason="true"
class="h-9 rounded-full bg-destructive/10 px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20">
Clear inventory
</button>
<button type="button" data-admin-action="clear-selected" data-admin-temp="false" data-admin-no-reason="true" data-selected-required="true" disabled
class="h-9 rounded-full bg-destructive/10 px-4 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-destructive/10">
Clear selected
</button>
</div>
</article>
</section>
<style>
.iso-cube {
position: absolute;
inset: 0;
margin: auto;
transform-style: preserve-3d;
transform: rotateX(-30deg) rotateY(45deg);
}
.iso-cube--sm { width: 2.1rem; height: 2.1rem; --iso-half: 1.05rem; }
.iso-cube--lg { width: 2.8rem; height: 2.8rem; --iso-half: 1.4rem; }
.iso-face {
position: absolute;
inset: 0;
background-size: 100% 100%;
background-repeat: no-repeat;
image-rendering: pixelated;
image-rendering: crisp-edges;
backface-visibility: hidden;
}
.iso-face.iso-top { transform: rotateX(90deg) translateZ(var(--iso-half)); filter: brightness(1.0); }
.iso-face.iso-front { transform: translateZ(var(--iso-half)); filter: brightness(0.82); }
.iso-face.iso-right { transform: rotateY(90deg) translateZ(var(--iso-half)); filter: brightness(0.66); }
</style>
<section class="rise rise-2 mt-4">
<article class="ring-card rounded-2xl bg-card p-5">
<h2 class="text-sm font-medium tracking-tight">Live inventory</h2>
@@ -122,21 +106,22 @@ PLAYERS
</section>
<dialog id="action-dialog"
class="ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
class="fixed inset-0 m-auto ring-card w-[min(28rem,calc(100%-2rem))] rounded-2xl bg-card p-5 text-foreground shadow-2xl backdrop:bg-background/60 backdrop:backdrop-blur-sm">
<form method="POST" action="/api/admin/action" id="action-form" class="flex flex-col gap-4">
<input type="hidden" name="uuid" value="${player_uuid}">
<input type="hidden" name="action" value="" data-action-input>
<input type="hidden" name="slot" value="" data-slot-input>
<header>
<h3 class="text-lg font-medium">
Confirm <span data-action-label class="capitalize">action</span>
</h3>
<p class="mt-1 text-sm text-muted-foreground">
<p class="mt-1 text-sm text-muted-foreground" data-action-description>
Target: <span class="font-medium text-foreground">${player_name}</span>
</p>
</header>
<label class="flex flex-col gap-1.5 text-sm">
<label class="flex flex-col gap-1.5 text-sm" data-reason-field>
<span class="text-muted-foreground">Reason</span>
<input name="reason" type="text" required minlength="1" maxlength="500"
placeholder="Required"