Character sheet: reload button for ranged weapons

A reload icon sits beside the ammo count in the readied-weapon strip and
the weapons table, for any weapon with a magazine. It tops the weapon up
from its reserve (rules.reloadFor, spot-checked), posts the new count and
the weapon's reload time to chat, and warns when the weapon is already
full or the reserve is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-16 18:21:57 +01:00
co-authored by Claude Opus 5
parent 1e0deb2ef0
commit 750cbd257d
6 changed files with 49 additions and 3 deletions
+4
View File
@@ -217,6 +217,7 @@
"RINGBRP.Sheet.Damage": "Damage", "RINGBRP.Sheet.Damage": "Damage",
"RINGBRP.Sheet.Range": "Range", "RINGBRP.Sheet.Range": "Range",
"RINGBRP.Sheet.Ammo": "Ammo", "RINGBRP.Sheet.Ammo": "Ammo",
"RINGBRP.Sheet.ReloadHint": "Reload from reserve",
"RINGBRP.Sheet.Points": "pts", "RINGBRP.Sheet.Points": "pts",
"RINGBRP.Sheet.Integrity": "Integrity", "RINGBRP.Sheet.Integrity": "Integrity",
"RINGBRP.Sheet.ClearWound": "Clear this wound", "RINGBRP.Sheet.ClearWound": "Clear this wound",
@@ -387,6 +388,8 @@
"RINGBRP.Notify.NoSkill": "Nothing on this sheet knows how to use {name}.", "RINGBRP.Notify.NoSkill": "Nothing on this sheet knows how to use {name}.",
"RINGBRP.Notify.OutOfRange": "Too far away to hit. Get closer or use something with more reach.", "RINGBRP.Notify.OutOfRange": "Too far away to hit. Get closer or use something with more reach.",
"RINGBRP.Notify.Empty": "Empty. Reload before firing.", "RINGBRP.Notify.Empty": "Empty. Reload before firing.",
"RINGBRP.Notify.ReloadFull": "{name} is already fully loaded.",
"RINGBRP.Notify.NoReserve": "No reserve ammunition left for {name}.",
"RINGBRP.Notify.NoLeg": "No unresolved phase remains.", "RINGBRP.Notify.NoLeg": "No unresolved phase remains.",
"RINGBRP.Notify.BadDamage": "Damage was not a number — the hit was not applied. See the console.", "RINGBRP.Notify.BadDamage": "Damage was not a number — the hit was not applied. See the console.",
"RINGBRP.Notify.BlowStopped": "That blow was stopped. There is no damage to roll.", "RINGBRP.Notify.BlowStopped": "That blow was stopped. There is no damage to roll.",
@@ -855,6 +858,7 @@
"RINGBRP.Fire.Adds": "adds {dice} damage", "RINGBRP.Fire.Adds": "adds {dice} damage",
"RINGBRP.Fire.Second": "Two shots", "RINGBRP.Fire.Second": "Two shots",
"RINGBRP.Fire.SecondEffect": "both hurried", "RINGBRP.Fire.SecondEffect": "both hurried",
"RINGBRP.Fire.Reloaded": "{name} reloaded: {loaded}/{capacity}, {reserve} in reserve. Takes {n} round(s).",
"RINGBRP.Fire.Reloading": "{name} — reloading, {n} round(s)", "RINGBRP.Fire.Reloading": "{name} — reloading, {n} round(s)",
"RINGBRP.Fire.slow.Label": "Slow", "RINGBRP.Fire.slow.Label": "Slow",
"RINGBRP.Fire.single.Label": "Single", "RINGBRP.Fire.single.Label": "Single",
+25 -1
View File
@@ -35,7 +35,7 @@ import {
RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor, RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor,
FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor, FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor,
BLAST, blastBandFor, blastShareFor, BLAST_COVER, blastAfterCover, BLAST, blastBandFor, blastShareFor, BLAST_COVER, blastAfterCover,
BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND, BURST, burstHitsFor, burstRoundsSpent, BURST_FUMBLE, BURST_ONE_MAJOR_WOUND, reloadFor,
BURST_HITS, LAMP, lampReachFor, lampCovers, BURST_HITS, LAMP, lampReachFor, lampCovers,
CONTACT_OUTCOMES, contactDeltaFor, impressionDeltaFor, CONTACT_OUTCOMES, contactDeltaFor, impressionDeltaFor,
COVERAGE, coverageFor, armourCoversKind, locationArmourFor, COVERAGE, coverageFor, armourCoversKind, locationArmourFor,
@@ -5005,6 +5005,9 @@ class RingBRPCharacterSheet extends ActorSheet {
el.addEventListener("click", () => this._onRollLocation())); el.addEventListener("click", () => this._onRollLocation()));
root.querySelectorAll("[data-action='roll-attack']").forEach(el => root.querySelectorAll("[data-action='roll-attack']").forEach(el =>
el.addEventListener("click", ev => this._onRollAttack(ev))); el.addEventListener("click", ev => this._onRollAttack(ev)));
root.querySelectorAll("[data-action='reload-weapon']").forEach(el =>
el.addEventListener("click", ev => { ev.preventDefault(); ev.stopPropagation();
this._onReloadWeapon(ev); }));
// Hovering a limb highlights its row and vice versa, so the picture and the // Hovering a limb highlights its row and vice versa, so the picture and the
// numbers are obviously the same thing. // numbers are obviously the same thing.
const link = (sel, on) => root.querySelectorAll(sel).forEach(el => { const link = (sel, on) => root.querySelectorAll(sel).forEach(el => {
@@ -5287,6 +5290,27 @@ class RingBRPCharacterSheet extends ActorSheet {
return res; return res;
} }
/** Top the weapon up from its reserve and say how long it takes. */
async _onReloadWeapon(ev) {
const weapon = this._itemFrom(ev);
const ammo = weapon?.system?.ammo;
if (!(ammo?.capacity > 0)) return;
const r = reloadFor(ammo.capacity, ammo.loaded, ammo.reserve);
if (r.moved <= 0) {
const key = (Number(ammo.loaded) || 0) >= ammo.capacity ? "RINGBRP.Notify.ReloadFull"
: "RINGBRP.Notify.NoReserve";
return ui.notifications.warn(game.i18n.format(key, { name: weapon.name }));
}
await weapon.update({ "system.ammo.loaded": r.loaded, "system.ammo.reserve": r.reserve });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
content: `<div class="ringbrp-chat"><strong>${
game.i18n.format("RINGBRP.Fire.Reloaded", {
name: weapon.name, loaded: r.loaded, capacity: ammo.capacity,
reserve: r.reserve, n: Number(ammo.reloadRounds) || 1 })}</strong></div>`
});
}
async _onRollDamage(ev) { async _onRollDamage(ev) {
const weapon = this._itemFrom(ev); const weapon = this._itemFrom(ev);
if (!weapon) return; if (!weapon) return;
+12
View File
@@ -1077,6 +1077,18 @@ export function burstRoundsSpent(size, loaded) {
return Math.min(Number(loaded) || 0, b.rounds); return Math.min(Number(loaded) || 0, b.rounds);
} }
/**
* A reload tops the weapon up from its reserve. Whatever the magazine has room for
* comes out of the reserve, and no more than the reserve holds.
*/
export function reloadFor(capacity, loaded, reserve) {
const cap = Math.max(0, Number(capacity) || 0);
const have = Math.min(cap, Math.max(0, Number(loaded) || 0));
const spare = Math.max(0, Number(reserve) || 0);
const moved = Math.min(cap - have, spare);
return { loaded: have + moved, reserve: spare - moved, moved };
}
/** A fumbled burst is the interesting one: it is a jam, or it is worse. */ /** A fumbled burst is the interesting one: it is a jam, or it is worse. */
export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"]; export const BURST_FUMBLE = ["RINGBRP.Burst.Jam", "RINGBRP.Burst.Empty", "RINGBRP.Burst.Wild"];
+2
View File
@@ -714,6 +714,8 @@
.rb-weapontable .rb-col-ready { width: 40px; text-align: center; } .rb-weapontable .rb-col-ready { width: 40px; text-align: center; }
.rb-weapontable td { vertical-align: middle; } .rb-weapontable td { vertical-align: middle; }
.rb-weapontable .rb-equip { font-size: 15px; } .rb-weapontable .rb-equip { font-size: 15px; }
.rb-reload { margin-left: 4px; font-size: 11px; color: var(--rb-muted); cursor: pointer; }
.rb-reload:hover { color: var(--rb-yellow); }
.rb-weapon-icon { .rb-weapon-icon {
width: 40px; height: 40px; margin-right: 9px; width: 40px; height: 40px; margin-right: 9px;
border: 1px solid var(--rb-hairline); background: var(--rb-void); border: 1px solid var(--rb-hairline); background: var(--rb-void);
+2 -2
View File
@@ -115,7 +115,7 @@
<a data-action="roll-attack">{{w.name}}</a> <a data-action="roll-attack">{{w.name}}</a>
<span class="rb-strip-dmg" data-action="roll-damage">{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}</span> <span class="rb-strip-dmg" data-action="roll-damage">{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}</span>
{{#if w.system.rangeSummary}}<span class="rb-strip-range">{{w.system.rangeSummary}}</span>{{/if}} {{#if w.system.rangeSummary}}<span class="rb-strip-range">{{w.system.rangeSummary}}</span>{{/if}}
{{#if w.system.ammo.capacity}}<span class="rb-strip-ammo">{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}</span>{{/if}} {{#if w.system.ammo.capacity}}<span class="rb-strip-ammo">{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}</span><a class="rb-reload" data-action="reload-weapon" data-tooltip="{{localize 'RINGBRP.Sheet.ReloadHint'}} ({{localize 'RINGBRP.Item.Reserve'}} {{w.system.ammo.reserve}})"><i class="fa-solid fa-rotate-right"></i></a>{{/if}}
</span> </span>
{{else}} {{else}}
<span class="rb-strip-empty">{{localize "RINGBRP.Sheet.NothingReadied"}}</span> <span class="rb-strip-empty">{{localize "RINGBRP.Sheet.NothingReadied"}}</span>
@@ -239,7 +239,7 @@
<td class="rb-td-icon"><img class="rb-li-icon rb-weapon-icon" src="{{w.img}}" alt=""/><a data-action="roll-attack" class="rb-weapon-name">{{w.name}}</a></td> <td class="rb-td-icon"><img class="rb-li-icon rb-weapon-icon" src="{{w.img}}" alt=""/><a data-action="roll-attack" class="rb-weapon-name">{{w.name}}</a></td>
<td><a data-action="roll-damage" class="rb-weapon-dmg">{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}</a></td> <td><a data-action="roll-damage" class="rb-weapon-dmg">{{#if (rbEq w.system.damage.mode "byBand")}}{{w.system.damage.short}}/{{w.system.damage.medium}}/{{w.system.damage.long}}{{else}}{{w.system.damage.formula}}{{/if}}</a></td>
<td>{{w.system.rangeSummary}}</td> <td>{{w.system.rangeSummary}}</td>
<td>{{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}}{{/if}}</td> <td>{{#if w.system.ammo.capacity}}{{w.system.ammo.loaded}}/{{w.system.ammo.capacity}} <a class="rb-reload" data-action="reload-weapon" data-tooltip="{{localize 'RINGBRP.Sheet.ReloadHint'}} ({{localize 'RINGBRP.Item.Reserve'}} {{w.system.ammo.reserve}})"><i class="fa-solid fa-rotate-right"></i></a>{{/if}}</td>
<td class="rb-col-num">{{w.system.massKg}} kg</td> <td class="rb-col-num">{{w.system.massKg}} kg</td>
<td class="rb-actions"> <td class="rb-actions">
<a data-action="item-edit"><i class="fas fa-edit"></i></a> <a data-action="item-edit"><i class="fas fa-edit"></i></a>
+4
View File
@@ -320,6 +320,10 @@ const checks = [
["burstHitsFor(critical, full) capped", RULES.burstHitsFor("critical","full"), 7], ["burstHitsFor(critical, full) capped", RULES.burstHitsFor("critical","full"), 7],
["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1], ["burstHitsFor(success, short)", RULES.burstHitsFor("success","short"), 1],
["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0], ["burstHitsFor(failure, long)", RULES.burstHitsFor("failure","long"), 0],
["reloadFor(6 cap, 2 loaded, 12)", JSON.stringify(RULES.reloadFor(6,2,12)), '{"loaded":6,"reserve":8,"moved":4}'],
["reloadFor(6 cap, 0 loaded, 3)", JSON.stringify(RULES.reloadFor(6,0,3)), '{"loaded":3,"reserve":0,"moved":3}'],
["reloadFor(full)", RULES.reloadFor(6,6,12).moved, 0],
["reloadFor(no reserve)", RULES.reloadFor(6,1,0).moved, 0],
["burstRoundsSpent(long, 4 loaded)", RULES.burstRoundsSpent("long",4), 4], ["burstRoundsSpent(long, 4 loaded)", RULES.burstRoundsSpent("long",4), 4],
// The mode is the gate. A musket may not rip a burst however the API is called. // The mode is the gate. A musket may not rip a burst however the API is called.
["burstSizesFor(slow) is empty", RULES.burstSizesFor("slow").length, 0], ["burstSizesFor(slow) is empty", RULES.burstSizesFor("slow").length, 0],