From 812813c45503738c1265489f8ae0200157c99a69 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 20 Oct 2026 15:03:22 +0100 Subject: [PATCH] Character sheet: equipment grid on a warrior outline, slot details, compact tools Co-Authored-By: Claude Opus 5.5 --- game.py | 4 ++- static/app.js | 91 ++++++++++++++++++++++++++++++++++++----------- static/icons.svg | 8 +++++ static/styles.css | 28 +++++++++++++++ 4 files changed, 110 insertions(+), 21 deletions(-) diff --git a/game.py b/game.py index 5822d6c..2e7879a 100644 --- a/game.py +++ b/game.py @@ -3560,7 +3560,9 @@ ACTION_GLYPHS.update({ for _index, (_area, _level, _prefix, _races) in enumerate(AREAS): for _race, *_rest in _races: ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) -UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", +UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", + "cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "visored-helm", "breastplate", + "leg-armor", "pointy-sword", "round-shield", *(kind["glyph"] for kind in DAMAGE_TYPES.values())] diff --git a/static/app.js b/static/app.js index c6a484f..8025627 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = "") => `${icon}${text === "" ? "" : `${text}`}`; const hands = (two) => `${two ? "2H" : "1H"}`; 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 `
${row.join("")}${req}${bonus || ""}
`; } @@ -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 `
-
${label}
- ${id ? `
${it.icon}${esc(it.name)}${stat} - ${mine ? `` : ""}
` - : `
${empty}
`} -
`; - }; - 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) {

${esc(cls.combat)}.

Equipment

-
${Object.entries(SLOT_NAMES).map(([slot, label]) => gearSlot(slot, label, "Nothing")).join("")}
+ ${paperDoll(p, mine)}

Tools

-
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}
- ${mine ? `

Equip from the .

` : ""} + ${toolSlots(p)}

Skills

${skillRows}
`; } +// 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 ``; +} + +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 ? `
${it.icon}${esc(it.name)} + ${label}${two ? " (two-handed weapon)" : ""} + ${mine && !two ? `` : ""}
+ ${gearChips(it, p)}` + : `
${label}: nothing worn.${mine ? ` Equip from the .` : ""}
`; + const st = C.styles[(p.equip.weapon && item(p.equip.weapon).style) || "sword"], dt = C.damage_types[st.damage]; + return `
+
${glyph("swordman", "#e6e8ee")} + ${DOLL.map(([slot, name, outline]) => dollSlot(p, slot, name, outline, slot === pick)).join("")}
+
+
${detail}
+
+ ${glyph(dt.glyph, dt.tint)} ${dt.name}, a swing every ${st.interval}s + Power${p.power} + ${glyph("checked-shield", "#d4dae2")} Armour${p.armour} -${Math.round(p.reduction * 100)}% physical + ${glyph("magic-shield", "#a77be8")} Ward${p.ward} -${Math.round(p.ward_reduction * 100)}% magic +
+
+
`; +} + +// 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 `
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => { + const id = p.equip[`tool_${s.id}`], it = id && item(id); + return ` + ${it ? it.icon : s.icon}`; + }).join("")}
`; +} + // --- 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(); diff --git a/static/icons.svg b/static/icons.svg index c983e02..b750888 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -24,6 +24,7 @@ + @@ -38,6 +39,7 @@ + @@ -56,6 +58,8 @@ + + @@ -82,6 +86,7 @@ + @@ -107,7 +112,9 @@ + + @@ -142,6 +149,7 @@ + diff --git a/static/styles.css b/static/styles.css index 38cb634..1fd92f4 100644 --- a/static/styles.css +++ b/static/styles.css @@ -470,3 +470,31 @@ button.sheet-skill:hover { background: var(--panel-2); } .stat-grid .gstat.muted { font-weight: 500; } .hands { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #d6b48a; border: 1px solid rgba(214, 180, 138, .5); border-radius: 4px; padding: 0 4px; } + +/* Paper doll: a compact slot grid on a warrior's outline, details beside it */ +.doll-wrap { display: grid; grid-template-columns: auto 1fr; gap: 18px; align-items: start; } +.doll { position: relative; display: grid; grid-template-columns: repeat(3, 52px); gap: 8px; padding: 6px; } +.doll-figure { position: absolute; inset: -4px; display: grid; place-items: center; opacity: .09; pointer-events: none; } +.doll-figure .gi { width: 100%; height: 100%; } +.doll-slot { position: relative; z-index: 1; width: 52px; height: 52px; border-radius: 10px; display: grid; place-items: center; + background: var(--panel-2); border: 1px solid var(--line); padding: 0; cursor: pointer; color: inherit; } +.doll-slot .gi { width: 34px; height: 34px; } +.doll-slot.empty .gi { width: 26px; height: 26px; opacity: .35; } +.doll-slot.future { background: transparent; border-style: dashed; opacity: .45; cursor: default; } +.doll-slot.future .gi { opacity: .3; } +.doll-slot.ghost > .gi { opacity: .3; } +.doll-slot.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); } +.doll-slot .hands { position: absolute; top: 2px; right: 2px; font-size: 9px; padding: 0 3px; } +.doll-slot.small { width: 40px; height: 40px; cursor: default; } +.doll-slot.small .gi { width: 26px; height: 26px; } +.doll-side { display: grid; gap: 12px; min-width: 0; } +.doll-stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; align-items: center; } +.doll-stats > span:first-child { grid-column: 1 / -1; margin-bottom: 2px; } +.doll-stats b { justify-self: end; } +.doll-stats .gi { width: 14px; height: 14px; } +.worn-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.worn-head > span:nth-child(2) { display: grid; } +.worn-head .ico .gi { width: 22px; height: 22px; } +.worn-head .btn { margin-left: auto; } +.tool-slots { display: flex; flex-wrap: wrap; gap: 8px; } +@media (max-width: 520px) { .doll-wrap { grid-template-columns: 1fr; justify-items: center; } .doll-side { width: 100%; } }