Game — Inventory Grid (drag · tooltips)
An RPG inventory screen with a 6x4 slot grid of CSS-drawn item icons, rarity-colored borders from common to legendary, stack counts, an equipment paper-doll (head, chest, weapon, ring), a carry-weight bar and a gold counter. Native HTML5 drag-and-drop moves, swaps and stack-merges items, drops gear into matching equipment slots with valid/invalid highlights, rich hover tooltips show name, rarity and stats, and right-click instantly uses or equips. Includes sort-by-rarity, loot and repair actions with neon-glow toasts.
MCP
Code
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.1);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
/* rarity */
--rar-common: #8b94b3;
--rar-uncommon: #36e27a;
--rar-rare: #00b3ff;
--rar-epic: #b46bff;
--rar-legendary: #ffb02e;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
min-height: 100vh;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
color: var(--text);
background:
radial-gradient(1100px 600px at 12% -10%, rgba(124, 77, 255, 0.16), transparent 60%),
radial-gradient(900px 600px at 105% 5%, rgba(0, 229, 255, 0.12), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
padding: clamp(14px, 3vw, 36px);
position: relative;
}
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.018) 0,
rgba(255, 255, 255, 0.018) 1px,
transparent 2px,
transparent 4px
);
mix-blend-mode: overlay;
}
/* ---------- shell ---------- */
.inv {
position: relative;
z-index: 1;
max-width: 1040px;
margin: 0 auto;
background: linear-gradient(180deg, var(--bg-2), var(--bg));
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.04);
overflow: hidden;
}
/* ---------- top bar ---------- */
.inv__top {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 18px;
align-items: center;
padding: 18px 22px;
background: linear-gradient(180deg, rgba(124, 77, 255, 0.1), transparent);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand__mark {
width: 34px; height: 34px;
background: conic-gradient(from 210deg, var(--accent), var(--accent-2), var(--accent-3), var(--accent));
clip-path: polygon(50% 0, 100% 28%, 100% 72%, 50% 100%, 0 72%, 0 28%);
box-shadow: var(--glow);
}
.brand__studio {
margin: 0;
font: 700 10px/1 "Orbitron", sans-serif;
letter-spacing: 0.32em;
color: var(--accent);
}
.brand__game {
margin: 2px 0 0;
font: 900 19px/1 "Orbitron", sans-serif;
letter-spacing: 0.04em;
}
.hero { display: flex; align-items: center; gap: 12px; }
.hero__avatar {
width: 46px; height: 46px;
display: grid; place-items: center;
border: 1px solid var(--accent-2);
background: radial-gradient(circle at 50% 30%, rgba(124, 77, 255, 0.5), var(--panel));
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
.hero__rune {
width: 16px; height: 16px;
background: var(--accent);
clip-path: polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%);
box-shadow: var(--glow);
}
.hero__name { margin: 0; font: 700 14px/1.1 "Inter", sans-serif; }
.hero__lvl {
font: 700 10px/1 "Orbitron", sans-serif;
color: var(--bg);
background: var(--accent);
padding: 2px 6px;
border-radius: 999px;
margin-left: 4px;
}
.hero__class { margin: 3px 0 0; font-size: 12px; color: var(--muted); }
.purse {
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: rgba(255, 200, 87, 0.06);
}
.purse__coin {
width: 18px; height: 18px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffe9a8, var(--warn) 60%, #b67d12);
box-shadow: 0 0 10px rgba(255, 200, 87, 0.6);
}
.purse__amt { font: 700 16px/1 "Orbitron", sans-serif; color: var(--warn); }
.purse__lbl { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; }
/* ---------- body ---------- */
.inv__body {
display: grid;
grid-template-columns: 260px 1fr;
gap: 18px;
padding: 22px;
}
.panel__title {
margin: 0 0 14px;
font: 700 12px/1 "Orbitron", sans-serif;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- equipment ---------- */
.equip {
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 16px;
position: relative;
}
.equip::before {
content: "";
position: absolute; inset: 0;
border-radius: var(--r-md);
box-shadow: inset 0 0 40px rgba(124, 77, 255, 0.08);
pointer-events: none;
}
.equip__doll {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.equip__slot {
aspect-ratio: 1;
border: 1.5px dashed var(--line-2);
border-radius: var(--r-sm);
background: rgba(0, 0, 0, 0.25);
display: grid;
place-items: center;
position: relative;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.equip__hint {
font: 600 10px/1 "Inter", sans-serif;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
opacity: 0.55;
}
.equip__slot.has-item .equip__hint { display: none; }
.stats { display: grid; gap: 8px; }
.stat {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
}
.stat span { color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.stat b { font: 700 14px/1 "Orbitron", sans-serif; color: var(--accent); }
/* ---------- backpack ---------- */
.bag {
background: linear-gradient(180deg, var(--panel), var(--bg-2));
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 16px;
}
.bag__head { display: flex; justify-content: space-between; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
margin: 4px 0 18px;
}
.slot {
aspect-ratio: 1;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.3));
position: relative;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.slot.drop-ok {
border-color: var(--accent);
box-shadow: var(--glow), inset 0 0 14px rgba(0, 229, 255, 0.2);
background: rgba(0, 229, 255, 0.07);
}
.slot.drop-bad {
border-color: var(--danger);
box-shadow: inset 0 0 14px rgba(255, 77, 77, 0.18);
}
/* ---------- item ---------- */
.item {
position: absolute; inset: 3px;
border-radius: 4px;
display: grid; place-items: center;
cursor: grab;
border: 1.5px solid var(--rar);
background:
radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--rar) 26%, transparent), transparent 70%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.35));
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 0 14px -2px var(--rar);
transition: transform 0.12s, box-shadow 0.15s, filter 0.15s;
user-select: none;
}
.item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px -4px var(--rar), 0 0 0 1px var(--rar);
filter: brightness(1.1);
z-index: 3;
}
.item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.item.dragging { opacity: 0.35; cursor: grabbing; }
.item[data-rar="common"] { --rar: var(--rar-common); }
.item[data-rar="uncommon"] { --rar: var(--rar-uncommon); }
.item[data-rar="rare"] { --rar: var(--rar-rare); }
.item[data-rar="epic"] { --rar: var(--rar-epic); }
.item[data-rar="legendary"] { --rar: var(--rar-legendary); }
.item[data-rar="legendary"] .glyph { animation: pulse 2.4s ease-in-out infinite; }
.glyph {
width: 64%; height: 64%;
background: var(--rar);
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--rar) 70%, transparent));
}
.glyph--sword { clip-path: polygon(45% 0, 55% 0, 58% 62%, 70% 70%, 70% 80%, 56% 78%, 56% 100%, 44% 100%, 44% 78%, 30% 80%, 30% 70%, 42% 62%); }
.glyph--shield { clip-path: polygon(50% 0, 100% 18%, 90% 70%, 50% 100%, 10% 70%, 0 18%); }
.glyph--helm { clip-path: polygon(20% 30%, 50% 8%, 80% 30%, 80% 70%, 60% 70%, 60% 55%, 40% 55%, 40% 70%, 20% 70%); }
.glyph--chest { clip-path: polygon(20% 12%, 40% 24%, 60% 24%, 80% 12%, 92% 35%, 78% 45%, 78% 92%, 22% 92%, 22% 45%, 8% 35%); }
.glyph--ring { clip-path: polygon(50% 0, 62% 16%, 50% 24%, 38% 16%); border-radius: 0; background: radial-gradient(circle, transparent 38%, var(--rar) 40%, var(--rar) 62%, transparent 64%); clip-path: none; border-radius: 50%; }
.glyph--potion { clip-path: polygon(40% 0, 60% 0, 60% 28%, 82% 70%, 74% 100%, 26% 100%, 18% 70%, 40% 28%); }
.glyph--gem { clip-path: polygon(50% 0, 82% 30%, 65% 100%, 35% 100%, 18% 30%); }
.glyph--scroll { clip-path: polygon(12% 14%, 88% 14%, 88% 86%, 12% 86%); border-radius: 50% / 18%; }
.glyph--orb { border-radius: 50%; }
.glyph--bow { clip-path: polygon(70% 0, 80% 8%, 38% 50%, 80% 92%, 70% 100%, 22% 52%, 22% 48%); }
.stack {
position: absolute;
right: 2px; bottom: 1px;
font: 700 10px/1 "Orbitron", sans-serif;
color: var(--text);
text-shadow: 0 1px 2px #000, 0 0 4px #000;
padding: 1px 3px;
pointer-events: none;
}
.equip__slot .item { inset: 4px; }
/* ---------- capacity ---------- */
.cap__row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px; }
.cap__lbl { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; }
.cap__val b { font-family: "Orbitron", sans-serif; color: var(--text); }
.cap__bar {
height: 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--line);
overflow: hidden;
}
.cap__fill {
display: block;
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--success), var(--accent));
box-shadow: 0 0 10px rgba(0, 229, 255, 0.5);
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s;
}
.cap__fill.warn { background: linear-gradient(90deg, var(--warn), var(--accent-3)); }
.cap__fill.over { background: linear-gradient(90deg, var(--danger), var(--accent-3)); }
/* ---------- footer ---------- */
.inv__foot {
display: flex; justify-content: space-between; align-items: center; gap: 16px;
padding: 16px 22px;
border-top: 1px solid var(--line);
background: rgba(0, 0, 0, 0.2);
}
.hint { margin: 0; font-size: 12px; color: var(--muted); }
.foot__btns { display: flex; gap: 10px; }
/* ---------- buttons ---------- */
.btn {
font: 700 12px/1 "Orbitron", sans-serif;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text);
border: 1px solid var(--line-2);
background: var(--panel-2);
padding: 10px 16px;
border-radius: var(--r-sm);
cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: transform 0.12s, box-shadow 0.15s, border-color 0.15s, background 0.15s;
}
.btn:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--glow); }
.btn:active { transform: translateY(0); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn__ico { font-size: 13px; }
.btn--ghost { background: transparent; }
.btn--cta {
color: var(--bg);
border-color: transparent;
background: linear-gradient(120deg, var(--accent), var(--accent-2));
clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
}
.btn--cta:hover { box-shadow: 0 0 22px rgba(124, 77, 255, 0.6); }
/* ---------- tooltip ---------- */
.tip {
position: fixed;
z-index: 50;
min-width: 220px;
max-width: 280px;
padding: 12px 14px;
background: linear-gradient(180deg, var(--panel-2), var(--bg-2));
border: 1px solid var(--rar, var(--line-2));
border-radius: var(--r-md);
box-shadow: 0 16px 40px -10px #000, 0 0 22px -6px var(--rar, transparent);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 0.12s, transform 0.12s;
}
.tip.show { opacity: 1; transform: translateY(0); }
.tip__head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.tip__name { font: 700 14px/1.2 "Orbitron", sans-serif; color: var(--rar, var(--text)); }
.tip__rar { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--rar); }
.tip__type { margin: 4px 0 8px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.tip__stats { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 3px; }
.tip__stats li { display: flex; justify-content: space-between; font-size: 12px; }
.tip__stats .pos { color: var(--success); font-weight: 700; }
.tip__flavor { margin: 0; font-size: 11px; font-style: italic; color: var(--muted); }
.tip__cta { margin: 8px 0 0; font: 600 10px/1 "Inter", sans-serif; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); }
.tip__cta:empty { display: none; }
/* ---------- toasts ---------- */
.toasts {
position: fixed;
right: 18px; bottom: 18px;
z-index: 60;
display: grid; gap: 8px;
width: min(300px, 80vw);
}
.toast {
padding: 11px 14px;
border-radius: var(--r-sm);
background: linear-gradient(180deg, var(--panel-2), var(--panel));
border: 1px solid var(--line-2);
border-left: 3px solid var(--accent);
box-shadow: 0 12px 30px -10px #000;
font-size: 13px;
display: flex; gap: 9px; align-items: center;
animation: slidein 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast.out { animation: slideout 0.25s forwards; }
.toast__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: var(--glow); flex: none; }
.toast--good { border-left-color: var(--success); }
.toast--good .toast__dot { background: var(--success); box-shadow: 0 0 10px var(--success); }
.toast--warn { border-left-color: var(--warn); }
.toast--warn .toast__dot { background: var(--warn); box-shadow: 0 0 10px var(--warn); }
@keyframes pulse { 0%, 100% { filter: drop-shadow(0 0 6px var(--rar)); } 50% { filter: drop-shadow(0 0 16px var(--rar)) brightness(1.3); } }
@keyframes slidein { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideout { to { opacity: 0; transform: translateX(20px); } }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ---------- responsive ---------- */
@media (max-width: 760px) {
.inv__body { grid-template-columns: 1fr; }
.equip__doll { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 520px) {
body { padding: 10px; }
.inv__top { grid-template-columns: 1fr; gap: 12px; }
.hero, .purse { justify-self: start; }
.brand__game { font-size: 17px; }
.grid { grid-template-columns: repeat(4, 1fr); gap: 6px; }
.equip__doll { grid-template-columns: repeat(2, 1fr); }
.inv__foot { flex-direction: column; align-items: stretch; }
.foot__btns { justify-content: space-between; }
.tip { max-width: 240px; }
}(() => {
"use strict";
const GRID_SLOTS = 24; // 6 x 4
const WEIGHT_MAX = 120;
// ---- item catalog (fictional) ----
const ITEMS = {
stormpike: {
name: "Stormpike Edge", glyph: "sword", rar: "legendary", type: "Two-Handed Blade",
slot: "weapon", weight: 14, stack: 1, flavor: "Forged in the Ashen Vents of Nullforge.",
stats: { Power: 142, Crit: "9%", Speed: "1.4" }
},
nightveil: {
name: "Nightveil Plate", glyph: "chest", rar: "epic", type: "Heavy Chest",
slot: "chest", weight: 18, stack: 1, flavor: "Woven shadow hardens to steel.",
stats: { Armor: 96, Vitality: 40 }
},
aegis: {
name: "Aegis of Hollow Reign", glyph: "shield", rar: "epic", type: "Tower Shield",
slot: "weapon", weight: 16, stack: 1, flavor: "Last bastion of the fallen kings.",
stats: { Armor: 72, Block: "22%" }
},
sentinel: {
name: "Sentinel Helm", glyph: "helm", rar: "rare", type: "Plate Helm",
slot: "head", weight: 8, stack: 1, flavor: "Eyes that never close.",
stats: { Armor: 44, Focus: 18 }
},
emberband: {
name: "Emberband", glyph: "ring", rar: "rare", type: "Signet Ring",
slot: "ring", weight: 1, stack: 1, flavor: "Warm to the touch, always.",
stats: { Crit: "6%", Power: 20 }
},
driftbow: {
name: "Neon Drift", glyph: "bow", rar: "uncommon", type: "Recurve Bow",
slot: "weapon", weight: 9, stack: 1, flavor: "Hums when the wind shifts.",
stats: { Power: 58, Range: "+30" }
},
embervial: {
name: "Ember Vial", glyph: "potion", rar: "uncommon", type: "Consumable",
slot: null, weight: 1, stack: 12, flavor: "Restores 240 HP over 6s.",
stats: { Heal: 240 }
},
voidshard: {
name: "Void Shard", glyph: "gem", rar: "epic", type: "Crafting Gem",
slot: null, weight: 1, stack: 5, flavor: "A splinter of the space between worlds.",
stats: { Value: 900 }
},
scrollwind: {
name: "Scroll of Wending", glyph: "scroll", rar: "common", type: "Consumable",
slot: null, weight: 1, stack: 3, flavor: "Recall to the last sanctuary.",
stats: { Cooldown: "30m" }
},
pulseorb: {
name: "Pulse Orb", glyph: "orb", rar: "rare", type: "Trinket",
slot: null, weight: 2, stack: 1, flavor: "Throbs in time with your heartbeat.",
stats: { Energy: 60 }
},
iron: {
name: "Iron Ingot", glyph: "gem", rar: "common", type: "Material",
slot: null, weight: 2, stack: 20, flavor: "Standard smithing stock.",
stats: { Value: 12 }
}
};
const RAR_ORDER = ["common", "uncommon", "rare", "epic", "legendary"];
// ---- initial layout (slot index -> {id, count}) ----
let bag = new Array(GRID_SLOTS).fill(null);
const seed = [
["stormpike", 1], ["nightveil", 1], ["sentinel", 1], ["emberband", 1],
["driftbow", 1], ["aegis", 1], ["embervial", 7], ["voidshard", 3],
["scrollwind", 2], ["pulseorb", 1], ["iron", 14]
];
seed.forEach(([id, c], i) => { bag[i] = { id, count: c }; });
const equip = { head: null, chest: null, weapon: null, ring: null };
// ---- DOM ----
const grid = document.getElementById("grid");
const tip = document.getElementById("tip");
const toasts = document.getElementById("toasts");
const wtCur = document.getElementById("wt-cur");
const wtFill = document.getElementById("wt-fill");
const wtBar = document.getElementById("wt-bar");
const goldEl = document.getElementById("gold");
let gold = 12480;
// ---- build grid slots ----
for (let i = 0; i < GRID_SLOTS; i++) {
const s = document.createElement("div");
s.className = "slot";
s.dataset.idx = String(i);
s.setAttribute("role", "gridcell");
grid.appendChild(s);
}
const equipSlots = [...document.querySelectorAll(".equip__slot")];
// ---- toast helper ----
function toast(msg, kind) {
const el = document.createElement("div");
el.className = "toast" + (kind ? " toast--" + kind : "");
el.innerHTML = `<span class="toast__dot"></span><span>${msg}</span>`;
toasts.appendChild(el);
setTimeout(() => {
el.classList.add("out");
el.addEventListener("animationend", () => el.remove(), { once: true });
}, 2600);
}
// ---- create an item element ----
function makeItem(id, count) {
const def = ITEMS[id];
const el = document.createElement("div");
el.className = "item";
el.dataset.id = id;
el.dataset.rar = def.rar;
el.draggable = true;
el.tabIndex = 0;
el.setAttribute("role", "img");
el.setAttribute("aria-label", `${def.name}, ${def.rar} ${def.type}`);
el.innerHTML = `<span class="glyph glyph--${def.glyph}"></span>` +
(count > 1 ? `<span class="stack">${count}</span>` : "");
return el;
}
// ---- render everything ----
function render() {
// bag
[...grid.children].forEach((slotEl, i) => {
slotEl.querySelector(".item")?.remove();
const cell = bag[i];
if (cell) slotEl.appendChild(makeItem(cell.id, cell.count));
});
// equipment
equipSlots.forEach((slotEl) => {
slotEl.querySelector(".item")?.remove();
const id = equip[slotEl.dataset.equip];
slotEl.classList.toggle("has-item", !!id);
if (id) slotEl.appendChild(makeItem(id, 1));
});
updateWeight();
updateStats();
}
function totalWeight() {
let w = 0;
bag.forEach((c) => { if (c) w += ITEMS[c.id].weight * c.count; });
Object.values(equip).forEach((id) => { if (id) w += ITEMS[id].weight; });
return w;
}
function updateWeight() {
const w = totalWeight();
const pct = Math.min(100, (w / WEIGHT_MAX) * 100);
wtCur.textContent = w;
wtFill.style.width = pct + "%";
wtFill.classList.toggle("warn", pct >= 75 && pct < 100);
wtFill.classList.toggle("over", pct >= 100);
wtBar.setAttribute("aria-valuenow", String(w));
}
function updateStats() {
let pow = 90, arm = 40, crit = 5;
Object.values(equip).forEach((id) => {
if (!id) return;
const s = ITEMS[id].stats;
if (s.Power) pow += +String(s.Power).replace(/\D/g, "");
if (s.Armor) arm += +String(s.Armor).replace(/\D/g, "");
if (s.Crit) crit += +String(s.Crit).replace(/\D/g, "");
});
document.getElementById("stat-pow").textContent = pow;
document.getElementById("stat-arm").textContent = arm;
document.getElementById("stat-crit").textContent = crit + "%";
}
// ---- tooltip ----
function showTip(itemEl, x, y) {
const id = itemEl.dataset.id;
const def = ITEMS[id];
tip.style.setProperty("--rar", `var(--rar-${def.rar})`);
document.getElementById("tip-name").textContent = def.name;
document.getElementById("tip-rar").textContent = def.rar;
document.getElementById("tip-type").textContent = def.type;
const statsEl = document.getElementById("tip-stats");
statsEl.innerHTML = Object.entries(def.stats)
.map(([k, v]) => `<li><span>${k}</span><span class="pos">${v}</span></li>`)
.join("");
document.getElementById("tip-flavor").textContent = def.flavor;
const inEquip = itemEl.closest(".equip__slot");
document.getElementById("tip-cta").textContent =
inEquip ? "Right-click to unequip" :
def.slot ? "Right-click to equip" :
def.stats.Heal || def.type === "Consumable" ? "Right-click to use" : "";
moveTip(x, y);
tip.classList.add("show");
tip.setAttribute("aria-hidden", "false");
}
function moveTip(x, y) {
const pad = 14, w = tip.offsetWidth, h = tip.offsetHeight;
let left = x + 16, top = y + 16;
if (left + w + pad > innerWidth) left = x - w - 16;
if (top + h + pad > innerHeight) top = innerHeight - h - pad;
tip.style.left = Math.max(pad, left) + "px";
tip.style.top = Math.max(pad, top) + "px";
}
function hideTip() {
tip.classList.remove("show");
tip.setAttribute("aria-hidden", "true");
}
// ---- drag & drop ----
let dragSrc = null; // { kind:'bag', idx } | { kind:'equip', slot }
function locateItem(itemEl) {
const bagSlot = itemEl.closest(".slot");
if (bagSlot) return { kind: "bag", idx: +bagSlot.dataset.idx };
const eqSlot = itemEl.closest(".equip__slot");
if (eqSlot) return { kind: "equip", slot: eqSlot.dataset.equip };
return null;
}
grid.addEventListener("dragstart", onDragStart);
document.querySelector(".equip__doll").addEventListener("dragstart", onDragStart);
function onDragStart(e) {
const item = e.target.closest(".item");
if (!item) return;
dragSrc = locateItem(item);
item.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.dataset.id);
hideTip();
}
document.addEventListener("dragend", (e) => {
e.target.closest?.(".item")?.classList.remove("dragging");
clearDropHints();
dragSrc = null;
});
function clearDropHints() {
document.querySelectorAll(".drop-ok, .drop-bad").forEach((el) =>
el.classList.remove("drop-ok", "drop-bad"));
}
// bag slots accept anything
grid.addEventListener("dragover", (e) => {
const slot = e.target.closest(".slot");
if (!slot || !dragSrc) return;
e.preventDefault();
slot.classList.add("drop-ok");
});
grid.addEventListener("dragleave", (e) => {
e.target.closest(".slot")?.classList.remove("drop-ok");
});
grid.addEventListener("drop", (e) => {
const slot = e.target.closest(".slot");
if (!slot || !dragSrc) return;
e.preventDefault();
dropToBag(+slot.dataset.idx);
});
// equipment slots accept only matching types
equipSlots.forEach((slot) => {
slot.addEventListener("dragover", (e) => {
if (!dragSrc) return;
const id = draggedId();
const def = id && ITEMS[id];
const ok = def && def.slot === slot.dataset.accept;
e.preventDefault();
slot.classList.add(ok ? "drop-ok" : "drop-bad");
});
slot.addEventListener("dragleave", () => slot.classList.remove("drop-ok", "drop-bad"));
slot.addEventListener("drop", (e) => {
e.preventDefault();
dropToEquip(slot.dataset.equip);
});
});
function draggedId() {
if (!dragSrc) return null;
return dragSrc.kind === "bag" ? bag[dragSrc.idx]?.id : equip[dragSrc.slot];
}
function dropToBag(targetIdx) {
if (!dragSrc) return;
if (dragSrc.kind === "bag") {
if (dragSrc.idx === targetIdx) return;
const a = bag[dragSrc.idx];
const b = bag[targetIdx];
// merge stacks of the same stackable item
if (a && b && a.id === b.id && ITEMS[a.id].stack > 1) {
const moved = Math.min(a.count, ITEMS[a.id].stack - b.count);
if (moved > 0) {
b.count += moved;
a.count -= moved;
if (a.count <= 0) bag[dragSrc.idx] = null;
clearDropHints();
render();
return;
}
}
bag[targetIdx] = a;
bag[dragSrc.idx] = b; // swap (b may be null = move)
} else {
// moving an equipped item back into bag
const id = equip[dragSrc.slot];
const existing = bag[targetIdx];
if (existing && ITEMS[existing.id].slot === dragSrc.slot) {
// swap-equip: target item takes the gear slot
equip[dragSrc.slot] = existing.id;
bag[targetIdx] = { id, count: 1 };
toast(`Equipped ${ITEMS[existing.id].name}`, "good");
} else if (!existing) {
equip[dragSrc.slot] = null;
bag[targetIdx] = { id, count: 1 };
toast(`Unequipped ${ITEMS[id].name}`, "warn");
} else {
toast("That slot is occupied", "warn");
}
}
clearDropHints();
render();
}
function dropToEquip(slotName) {
const id = draggedId();
if (!id) { clearDropHints(); return; }
const def = ITEMS[id];
if (def.slot !== slotName) { toast(`${def.name} can't go there`, "warn"); clearDropHints(); return; }
if (dragSrc.kind === "bag") {
const prev = equip[slotName];
equip[slotName] = id;
// equippables are stack 1: source slot takes the previous gear (or empties)
bag[dragSrc.idx] = prev ? { id: prev, count: 1 } : null;
} else {
const prev = equip[slotName];
equip[slotName] = id;
equip[dragSrc.slot] = prev;
}
toast(`Equipped ${def.name}`, "good");
clearDropHints();
render();
}
// ---- hover tooltip (delegated) ----
document.addEventListener("mouseover", (e) => {
const item = e.target.closest(".item");
if (item) showTip(item, e.clientX, e.clientY);
});
document.addEventListener("mousemove", (e) => {
if (tip.classList.contains("show") && e.target.closest(".item")) moveTip(e.clientX, e.clientY);
});
document.addEventListener("mouseout", (e) => {
if (e.target.closest(".item") && !e.relatedTarget?.closest?.(".item")) hideTip();
});
// keyboard focus tooltip
document.addEventListener("focusin", (e) => {
const item = e.target.closest(".item");
if (item) { const r = item.getBoundingClientRect(); showTip(item, r.right, r.top); }
});
document.addEventListener("focusout", (e) => { if (e.target.closest(".item")) hideTip(); });
// ---- right-click: use / equip / unequip ----
document.addEventListener("contextmenu", (e) => {
const item = e.target.closest(".item");
if (!item) return;
e.preventDefault();
const loc = locateItem(item);
const id = item.dataset.id;
const def = ITEMS[id];
if (loc.kind === "equip") {
const free = bag.indexOf(null);
if (free === -1) { toast("Backpack is full", "warn"); return; }
bag[free] = { id, count: 1 };
equip[loc.slot] = null;
toast(`Unequipped ${def.name}`, "warn");
hideTip(); render(); return;
}
// in bag
if (def.slot) {
const cell = bag[loc.idx];
const prev = equip[def.slot];
equip[def.slot] = id;
bag[loc.idx] = prev ? { id: prev, count: 1 } :
(cell.count > 1 ? { id, count: cell.count - 1 } : null);
toast(`Equipped ${def.name}`, "good");
} else if (def.stats.Heal || def.type === "Consumable") {
const cell = bag[loc.idx];
cell.count -= 1;
if (cell.count <= 0) bag[loc.idx] = null;
toast(`Used ${def.name}`, "good");
} else {
toast(`${def.name} can't be used`, "warn");
return;
}
hideTip(); render();
});
// keyboard: Enter / E to use-equip the focused item
document.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key.toLowerCase() !== "e") return;
const item = document.activeElement?.closest?.(".item");
if (!item) return;
e.preventDefault();
item.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
});
// ---- sort button ----
document.getElementById("sort").addEventListener("click", () => {
const items = bag.filter(Boolean);
items.sort((a, b) => {
const ra = RAR_ORDER.indexOf(ITEMS[b.id].rar) - RAR_ORDER.indexOf(ITEMS[a.id].rar);
if (ra !== 0) return ra;
return ITEMS[a.id].name.localeCompare(ITEMS[b.id].name);
});
bag = new Array(GRID_SLOTS).fill(null);
items.forEach((it, i) => { bag[i] = it; });
render();
toast("Backpack sorted by rarity", "good");
});
// ---- footer actions ----
document.getElementById("repair").addEventListener("click", () => {
const cost = 340;
if (gold < cost) { toast("Not enough gold to repair", "warn"); return; }
gold -= cost;
goldEl.textContent = gold.toLocaleString();
toast(`Repaired all gear · -${cost} gold`, "good");
});
document.getElementById("loot").addEventListener("click", () => {
const pool = ["embervial", "iron", "voidshard", "scrollwind", "pulseorb"];
const id = pool[Math.floor(Math.random() * pool.length)];
const def = ITEMS[id];
// stack onto existing if stackable
if (def.stack > 1) {
const ex = bag.findIndex((c) => c && c.id === id && c.count < def.stack);
if (ex !== -1) { bag[ex].count = Math.min(def.stack, bag[ex].count + 1); render(); toast(`Looted ${def.name}`, "good"); return; }
}
const free = bag.indexOf(null);
if (free === -1) { toast("Backpack is full", "warn"); return; }
bag[free] = { id, count: 1 };
render();
toast(`Looted ${def.name}`, "good");
});
goldEl.textContent = gold.toLocaleString();
document.getElementById("wt-max").textContent = WEIGHT_MAX;
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ashen Vanguard — Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<main class="inv" role="application" aria-label="Character inventory">
<header class="inv__top">
<div class="brand">
<span class="brand__mark" aria-hidden="true"></span>
<div class="brand__txt">
<p class="brand__studio">NULLFORGE</p>
<h1 class="brand__game">Ashen Vanguard</h1>
</div>
</div>
<div class="hero">
<div class="hero__avatar" aria-hidden="true">
<span class="hero__rune"></span>
</div>
<div class="hero__meta">
<p class="hero__name">Kael Ardent <span class="hero__lvl">Lv 47</span></p>
<p class="hero__class">Vanguard · Stormcaller</p>
</div>
</div>
<div class="purse" title="Gold">
<span class="purse__coin" aria-hidden="true"></span>
<span class="purse__amt" id="gold" aria-live="polite">12,480</span>
<span class="purse__lbl">gold</span>
</div>
</header>
<div class="inv__body">
<!-- Equipment -->
<section class="equip" aria-label="Equipped gear">
<h2 class="panel__title">Equipped</h2>
<div class="equip__doll">
<div class="equip__slot" data-equip="head" data-accept="head" aria-label="Head slot"><span class="equip__hint">Head</span></div>
<div class="equip__slot" data-equip="chest" data-accept="chest" aria-label="Chest slot"><span class="equip__hint">Chest</span></div>
<div class="equip__slot" data-equip="weapon" data-accept="weapon" aria-label="Weapon slot"><span class="equip__hint">Weapon</span></div>
<div class="equip__slot" data-equip="ring" data-accept="ring" aria-label="Ring slot"><span class="equip__hint">Ring</span></div>
</div>
<div class="stats">
<div class="stat"><span>Power</span><b id="stat-pow">312</b></div>
<div class="stat"><span>Armor</span><b id="stat-arm">188</b></div>
<div class="stat"><span>Crit</span><b id="stat-crit">21%</b></div>
</div>
</section>
<!-- Backpack -->
<section class="bag" aria-label="Backpack">
<div class="bag__head">
<h2 class="panel__title">Backpack</h2>
<button class="btn btn--sort" id="sort" type="button">
<span class="btn__ico" aria-hidden="true">⤓</span> Sort
</button>
</div>
<div class="grid" id="grid" role="grid" aria-label="Inventory slots"></div>
<div class="cap">
<div class="cap__row">
<span class="cap__lbl">Carry weight</span>
<span class="cap__val"><b id="wt-cur">0</b> / <span id="wt-max">120</span></span>
</div>
<div class="cap__bar" role="progressbar" aria-valuemin="0" aria-valuemax="120" aria-valuenow="0" id="wt-bar">
<span class="cap__fill" id="wt-fill"></span>
</div>
</div>
</section>
</div>
<footer class="inv__foot">
<p class="hint">Drag to move · drop on a slot to swap · right-click an item to use / equip</p>
<div class="foot__btns">
<button class="btn btn--ghost" id="repair" type="button">Repair all</button>
<button class="btn btn--cta" id="loot" type="button">Loot bag</button>
</div>
</footer>
</main>
<!-- Tooltip -->
<div class="tip" id="tip" role="tooltip" aria-hidden="true">
<div class="tip__head">
<span class="tip__name" id="tip-name"></span>
<span class="tip__rar" id="tip-rar"></span>
</div>
<p class="tip__type" id="tip-type"></p>
<ul class="tip__stats" id="tip-stats"></ul>
<p class="tip__flavor" id="tip-flavor"></p>
<p class="tip__cta" id="tip-cta"></p>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Inventory Grid (drag · tooltips)
A dark sci-fi inventory screen for the fictional Nullforge title Ashen Vanguard. The backpack is a 6x4 grid of slots holding CSS-drawn item glyphs — swords, helms, potions, gems, scrolls — each framed by a rarity-colored border and glow (common, uncommon, rare, epic, legendary), with Orbitron stack counters on stackables. Beside it sits an equipment paper-doll with head, chest, weapon and ring slots that feed a live Power / Armor / Crit stat readout, plus an animated carry-weight bar that shifts from cyan to amber to red as you approach capacity, and a gold purse in the header.
Everything is wired with native HTML5 drag-and-drop: drag items between bag slots to move or swap them, drop matching stacks to merge them, and drag gear onto its equipment slot — valid targets light up cyan while invalid ones flash red. Hovering (or keyboard-focusing) any item raises a rarity-tinted tooltip with type, stat lines and flavor text; right-clicking uses a consumable, equips gear, or unequips from the doll, with Enter / E as the keyboard equivalent.
The footer adds a clip-cornered neon CTA to loot random items (stacking where possible), a repair action that deducts gold, and a sort button that reorders the bag by rarity then name. Every action confirms itself through sliding toasts, and the layout collapses gracefully down to a 4-column grid on narrow phones.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.