Combat rework: damage types, races with variants, armour weights, tailoring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 14:16:12 +01:00
parent a35bcd6512
commit ba7556de4d
7 changed files with 715 additions and 254 deletions

View file

@ -439,7 +439,7 @@ function skillPage(id) {
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id);
const ladder = ["smithing", "leatherworking", "tailoring", "herblore", "combat"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
// Ungrouped actions (the building site) stay above the tabs.
@ -522,40 +522,57 @@ function toolPanel(skill) {
: `<span class="muted">No tool.</span>`}</div>`;
}
// What a monster is weak to and shrugs off, and how your weapon fares against it.
function weaknessLine(kind, odds) {
const table = C.weakness[kind];
if (!table) return "";
const names = (pick) => Object.entries(table).filter(([, m]) => pick(m)).map(([s]) => `${C.styles[s].name}s`).join(", ");
const weak = names((m) => m > 1), tough = names((m) => m < 1);
const yours = odds.weakness > 1 ? ` · <b class="good">your ${C.styles[odds.style].name} hits it hard</b>`
: odds.weakness < 1 ? ` · <b class="bad">your ${C.styles[odds.style].name} does half damage</b>` : "";
return `<div class="small muted">${weak ? `Weak to ${weak}` : ""}${weak && tough ? " · " : ""}${tough ? `shrugs off ${tough}` : ""}${yours}</div>`;
// A damage type's icon: what an enemy attacks with, or (tinted) what it's vulnerable to or resists.
function dmgIcon(type, how = "") {
const t = C.damage_types[type], name = t.name.toLowerCase();
const tip = how === "vuln" ? `Vulnerable to ${name} (+${Math.round((C.vulnerable - 1) * 100)}%)`
: how === "resist" ? `Resists ${name} (-${Math.round((1 - C.resistant) * 100)}%)`
: how === "attack" ? `Attacks with ${name}` : `${t.name} damage`;
return `<span class="dmg ${how}" title="${esc(tip)}">${glyph(t.glyph, t.tint)}</span>`;
}
function resistIcons(v) {
return v.vulnerable.map((k) => dmgIcon(k, "vuln")).join("") + v.resists.map((k) => dmgIcon(k, "resist")).join("");
}
// Who turns up in an encounter: each variant, what it attacks with, and how it takes each damage type.
// A variant your weapon is good against is green, one that resists it red.
function variantRows(e, o) {
return `<div class="variants">${e.variants.map((v, i) => {
const fit = o.variants[i].affinity;
return `<div class="variant ${fit > 1 ? "good" : fit < 1 ? "bad" : ""}">
<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
<span class="v-name">${esc(v.name)}${v.archetype === "elite" ? ` <span class="muted">· rare</span>` : ""}</span>
<span class="v-hp muted">${fmt(v.hp)} HP</span>
<span class="v-res">${resistIcons(v)}</span>
</div>`;
}).join("")}</div>`;
}
// The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel() {
const f = S.me.action?.fight;
if (!f) return "";
const side = (who, name) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b><span class="small muted" data-f="${who}-hp"></span></div>
const side = (who, name, foe) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b>${foe ? `<span class="v-res">${resistIcons(foe)}</span>` : ""}<span class="small muted" data-f="${who}-hp"></span></div>
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>
</div>`;
return `<div class="panel fight" data-fight="${S.me.action.start}">
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy)}</div>
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy, f.foe)}</div>
<ul class="fight-log" data-f="log"></ul>
</div>`;
}
function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e;
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (kind === "block") return `<li class="good">You block ${foe}'s blow</li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b></li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b></li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b>${type}</li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b>${type}</li>`;
}
function animateFight(now) {
@ -605,16 +622,16 @@ function styleNote(me) {
const own = C.class_style[me.cls], traits = {
sword: `${st.interval}s${me.equip.shield ? `, shield blocks ${Math.round(C.block_chance * 100)}%` : ""}`,
hammer: `${st.interval}s, double crits`,
staff: `${st.interval}s, accurate, ignores armour`,
staff: `${st.interval}s, accurate`,
bow: `${st.interval}s, shoots first`,
};
return `<p class="small">${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${traits[styleId]}.
return `<p class="small">${dmgIcon(st.damage)} ${weapon ? st.name[0].toUpperCase() + st.name.slice(1) : "Fists"}: ${C.damage_types[st.damage].name.toLowerCase()}, ${traits[styleId]}.
${own === styleId ? `<b class="good">Trade weapon: +15% damage.</b>` : `Trade weapon: ${esc(C.styles[own].name)} (+15%).`}</p>`;
}
function combatPanel() {
const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
const stat = (id) => gearStats(item(id));
const slot = (name, label) => {
const id = me.equip[name];
return `<div class="gear-slot">${id
@ -636,11 +653,17 @@ function combatPanel() {
</div>
<div class="panel"><h2>Equipment</h2>
<div class="gear">${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}</div>
<p class="small muted">Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% damage)</p>
<p class="small muted">Power ${me.power} · armour ${me.armour} (-${Math.round(me.reduction * 100)}% physical) · ward ${me.ward} (-${Math.round(me.ward_reduction * 100)}% magic)</p>
${styleNote(me)}
</div></div>`;
}
// Power, armour and ward, and the armour's weight.
function gearStats(it) {
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`, it.ward && `+${it.ward} ward`];
return stats.filter(Boolean).join(", ") + (it.weight ? ` (${C.weights[it.weight].name.toLowerCase()})` : "");
}
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats(id) {
const it = item(id);
@ -652,7 +675,8 @@ function itemStats(id) {
const st = C.styles[it.style], trade = Object.entries(C.class_style).find(([, s]) => s === it.style)?.[0];
parts.push(`${it.two_handed ? "two-handed " : ""}${st.name}, ${st.interval}s${trade ? ` (${C.classes[trade].name.toLowerCase()})` : ""}`);
}
if (it.armour) parts.push(`+${it.armour} armour`);
if (it.armour || it.ward) parts.push(gearStats({ ...it, power: 0 }));
if (it.reagent) parts.push(`Reagent for ${C.weights[it.reagent].name.toLowerCase()} armour`);
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
@ -685,10 +709,10 @@ function actionCard(a) {
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
extra = `<div class="small">${HEART} ${fmt(e.hp)} HP · hits up to <b>${o.their_max}</b> every ${e.interval}s${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
extra = `${variantRows(e, o)}
<div class="small">Hits up to <b>${o.their_max}</b>${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
<div class="small">You hit <b>${pct(o.hit)}</b> (max ${o.max_hit}) · it hits <b>${pct(o.their_hit)}</b></div>
<div class="small">~<b>${dur(o.kill_time)}</b> a kill · <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>
${weaknessLine(e.kind, o)}`;
<div class="small">~<b>${dur(o.kill_time)}</b> a kill · <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`;
}
@ -811,7 +835,7 @@ function equipRow(id, it) {
function equipLabel(it) {
if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`;
const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
const stats = gearStats(it);
return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`;
}
@ -1147,7 +1171,7 @@ function characterSheet(uid) {
const id = p.equip[slot];
const it = id && item(id);
const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster`
: [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
: 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>
@ -1183,7 +1207,8 @@ function characterSheet(uid) {
<div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div>
<div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div>
${mine ? `<div class="stat"><div class="k">Prayer points</div><div class="v">${fmt(p.pp)}</div><div class="note">${p.prayers.length ? p.prayers.map((id) => esc(C.prayers.find((x) => x.id === id).name)).join(", ") : "No prayers active"}</div></div>` : ""}
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div>
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less physical</div></div>
<div class="stat"><div class="k">Ward</div><div class="v">${p.ward}</div><div class="note">${Math.round(p.ward_reduction * 100)}% less magic</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${p.slots_used} / ${p.slots} bank slots</div></div>` : ""}
</div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Before After
Before After

View file

@ -443,3 +443,16 @@ button.sheet-skill:hover { background: var(--panel-2); }
.battle-result { font-weight: 700; font-size: 17px; margin: 6px 0 8px; }
.battle-result.win { color: var(--good); }
.battle-result.loss { color: var(--bad); }
/* Damage types and encounter rosters */
.dmg { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 5px; vertical-align: -4px; }
.dmg .gi { width: 13px; height: 13px; }
.dmg.vuln { background: rgba(95, 174, 90, .22); box-shadow: inset 0 0 0 1px rgba(95, 174, 90, .6); }
.dmg.resist { background: rgba(226, 100, 92, .2); box-shadow: inset 0 0 0 1px rgba(226, 100, 92, .55); }
.variants { display: grid; gap: 3px; margin: 2px 0 4px; font-size: 13px; }
.variant { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px; align-items: center; }
.variant.good .v-name { color: var(--good); }
.variant.bad .v-name { color: var(--bad); }
.v-attack, .v-res { display: inline-flex; gap: 2px; }
.v-hp { font-size: 12px; }
.fighter-name .v-res { margin-left: 6px; }