Character sheet: equipment grid on a warrior outline, slot details, compact tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 15:03:22 +01:00
parent a40b837541
commit 812813c455
4 changed files with 110 additions and 21 deletions

View file

@ -11,6 +11,7 @@ let S = null; // latest state
let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item
let dollPick = null; // equipment slot shown on the character sheet
let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false;
@ -668,7 +669,7 @@ const GEAR_SLOTS = ["weapon", "shield", "helmet", "body", "legs"];
// A weapon's or armour piece's stats in a small grid. Weapons: damage type and power | 1H or 2H | swing
// time; armour: armour | weight | ward. Below: what it needs to wear, and any bonus for you.
function gearChips(it) {
function gearChips(it, who = S.me) {
const cell = (icon, text, tip, cls = "") => `<span class="gstat ${cls}" title="${esc(tip)}">${icon}${text === "" ? "" : `<span>${text}</span>`}</span>`;
const hands = (two) => `<span class="hands">${two ? "2H" : "1H"}</span>`;
let row = ["", "", ""];
@ -689,10 +690,10 @@ function gearChips(it) {
let req = "", bonus = "";
if (it.req) {
const [skill, level] = it.req;
req = cell(skillOf(skill).icon, level, `Needs ${skillOf(skill).name.toLowerCase()} ${level}`, S.me.levels[skill] < level ? "short" : "");
req = cell(skillOf(skill).icon, level, `Needs ${skillOf(skill).name.toLowerCase()} ${level}`, who.levels[skill] < level ? "short" : "");
}
if (it.style && C.class_style[S.me.cls] === it.style) {
bonus = cell(C.classes[S.me.cls].icon, "+15%", "Your trade's weapon: +15% damage", "trade");
if (it.style && C.class_style[who.cls] === it.style) {
bonus = cell(C.classes[who.cls].icon, "+15%", "The trade's own weapon: +15% damage", "trade");
}
return `<div class="stat-grid">${row.join("")}${req}<span></span>${bonus || "<span></span>"}</div>`;
}
@ -1200,19 +1201,6 @@ function characterSheet(uid) {
const days = Math.max(1, Math.ceil((serverNow() - p.joined) / 86400));
const guard = Math.round(p.power);
const gearSlot = (slot, label, empty) => {
const id = p.equip[slot];
const it = id && item(id);
const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster`
: gearStats(it);
return `<div class="sheet-slot ${id ? "" : "empty"}">
<div class="k">${label}</div>
${id ? `<div class="sheet-item"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b><span class="muted small">${stat}</span></span>
${mine ? `<button class="btn sm" data-unequip="${slot}" title="Back to the bank">✕</button>` : ""}</div>`
: `<div class="muted small">${empty}</div>`}
</div>`;
};
const skillRows = skills.map((s) => {
const level = p.levels[s.id], xp = p.xp[s.id] || 0;
const floor = C.xp_table[level], ceil = C.xp_table[level + 1];
@ -1255,16 +1243,75 @@ function characterSheet(uid) {
<p class="small">${esc(cls.combat)}.</p>
</div>
<div class="panel"><h2>Equipment</h2>
<div class="sheet-gear">${Object.entries(SLOT_NAMES).map(([slot, label]) => gearSlot(slot, label, "Nothing")).join("")}</div>
${paperDoll(p, mine)}
<h2 style="margin-top:16px">Tools</h2>
<div class="sheet-gear">${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}</div>
${mine ? `<p class="small muted">Equip from the <button class="linkish" data-nav="bank">bank</button>.</p>` : ""}
${toolSlots(p)}
</div>
</div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
</div>`;
}
// The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with
// room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded.
// Picking a slot shows what's in it beside the grid.
const DOLL = [
["cape", "Cape", "cape"], ["helmet", "Head", "visored-helm"], ["amulet", "Amulet", "gem-pendant"],
["weapon", "Main hand", "pointy-sword"], ["body", "Body", "breastplate"], ["shield", "Off hand", "round-shield"],
["gloves", "Gloves", "gauntlet"], ["legs", "Legs", "leg-armor"], ["ring", "Ring", "ring"],
["ammo", "Ammo", "quiver"], ["boots", "Boots", "leather-boot"], ["trinket", "Trinket", "crystal-ball"],
];
function dollSlot(p, slot, label, outline, picked) {
const known = slot in p.equip, two = slot === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed;
const id = two ? p.equip.weapon : p.equip[slot];
const tip = !known ? `${label}: not in the game yet` : id ? `${label}: ${item(id).name}` : `${label}: empty`;
return `<button class="doll-slot ${known ? "" : "future"} ${id ? "" : "empty"} ${two ? "ghost" : ""} ${picked ? "on" : ""}"
${known ? `data-doll="${slot}"` : "disabled"} title="${esc(tip)}">
${id ? item(id).icon : glyph(outline, "#98a1b4")}${two ? `<span class="hands">2H</span>` : ""}</button>`;
}
function paperDoll(p, mine) {
const worn = DOLL.filter(([slot]) => p.equip[slot]).map(([slot]) => slot);
const pick = (dollPick && slot2(dollPick, p)) || (worn.includes("weapon") ? "weapon" : worn[0]) || "weapon";
const label = DOLL.find(([slot]) => slot === pick)[1];
const two = pick === "shield" && p.equip.weapon && item(p.equip.weapon).two_handed;
const id = two ? p.equip.weapon : p.equip[pick], it = id && item(id);
const detail = it ? `<div class="worn-head"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b>
<span class="muted small">${label}${two ? " (two-handed weapon)" : ""}</span></span>
${mine && !two ? `<button class="btn sm" data-unequip="${pick}" title="Back to the bank">✕</button>` : ""}</div>
${gearChips(it, p)}`
: `<div class="muted small">${label}: nothing worn.${mine ? ` Equip from the <button class="linkish" data-nav="bank">bank</button>.` : ""}</div>`;
const st = C.styles[(p.equip.weapon && item(p.equip.weapon).style) || "sword"], dt = C.damage_types[st.damage];
return `<div class="doll-wrap">
<div class="doll"><span class="doll-figure">${glyph("swordman", "#e6e8ee")}</span>
${DOLL.map(([slot, name, outline]) => dollSlot(p, slot, name, outline, slot === pick)).join("")}</div>
<div class="doll-side">
<div class="doll-detail">${detail}</div>
<div class="doll-stats">
<span>${glyph(dt.glyph, dt.tint)} ${dt.name}, a swing every ${st.interval}s</span>
<span class="muted">Power</span><b>${p.power}</b>
<span class="muted">${glyph("checked-shield", "#d4dae2")} Armour</span><b>${p.armour} <span class="muted small">-${Math.round(p.reduction * 100)}% physical</span></b>
<span class="muted">${glyph("magic-shield", "#a77be8")} Ward</span><b>${p.ward} <span class="muted small">-${Math.round(p.ward_reduction * 100)}% magic</span></b>
</div>
</div>
</div>`;
}
// A picked slot only counts if it's one this character has.
function slot2(slot, p) {
return slot in p.equip ? slot : null;
}
// Tools: one small slot per gathering skill.
function toolSlots(p) {
return `<div class="tool-slots">${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => {
const id = p.equip[`tool_${s.id}`], it = id && item(id);
return `<span class="doll-slot small ${id ? "" : "empty"}" title="${esc(it ? `${it.name}: ${Math.round(it.speed * 100)}% faster ${s.name.toLowerCase()}` : `${s.name}: no tool`)}">
${it ? it.icon : s.icon}</span>`;
}).join("")}</div>`;
}
// --- registration ---------------------------------------------------------
function classCards() {
@ -1354,6 +1401,10 @@ document.addEventListener("click", (e) => {
if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {});
if (d.doll) {
dollPick = d.doll;
return render();
}
if ("select" in d) {
selected = d.select || null;
render();