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 bank .
` : ""}
+ ${toolSlots(p)}
`;
}
+// 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 `
+ ${id ? item(id).icon : glyph(outline, "#98a1b4")}${two ? `2H ` : ""} `;
+}
+
+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 bank .` : ""}
`;
+ 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%; } }