From b8a8b495b5be363c4d7285baf97d392548657f17 Mon Sep 17 00:00:00 2001 From: Tyler <3deathsaves@gmail.com> Date: Fri, 12 Jun 2026 23:19:50 -0600 Subject: [PATCH] Show NPC speeds reduced to 0 instead of hiding them - Keep a movement type's pill on the NPC sheet when its base speed is non-zero but a condition or effect has reduced it to 0 - Display the value as 0 via a new displayZero flag on the trait pill partial - Add a tooltip naming the responsible effect (e.g. "Reduced to 0 by Grappled"), falling back to the normal speed when no source can be attributed Closes #6565 --- lang/en.json | 2 ++ module/applications/actor/npc-sheet.mjs | 32 ++++++++++++++++++-- templates/actors/parts/actor-trait-pills.hbs | 5 +-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lang/en.json b/lang/en.json index 6e04f5196f..669dae1848 100644 --- a/lang/en.json +++ b/lang/en.json @@ -3890,6 +3890,8 @@ }, "Hover": "Hover", "HoverSpeed": "{speed} (hover)", + "Reduced": "Normally {speed}", + "ReducedBy": "Reduced to 0 by {effect}", "Speed": "Combat Speed", "Type": { "Burrow": "Burrow", diff --git a/module/applications/actor/npc-sheet.mjs b/module/applications/actor/npc-sheet.mjs index 150ec7ecd9..13cdf1bea4 100644 --- a/module/applications/actor/npc-sheet.mjs +++ b/module/applications/actor/npc-sheet.mjs @@ -1,4 +1,4 @@ -import { formatNumber, getPluralRules, simplifyBonus, splitSemicolons } from "../../utils.mjs"; +import { formatLength, formatNumber, getPluralRules, simplifyBonus, splitSemicolons } from "../../utils.mjs"; import { createCheckboxInput } from "../fields.mjs"; import BaseActorSheet from "./api/base-actor-sheet.mjs"; import HabitatConfig from "./config/habitat-config.mjs"; @@ -370,14 +370,23 @@ export default class NPCActorSheet extends BaseActorSheet { context.tools = this._prepareSkillsTools(context, "tools"); // Speed + const movementSource = this.actor._source.system.attributes.movement; context.speed = [ ...Object.entries(CONFIG.DND5E.movementTypes).filter(([, m]) => !m.hidden).map(([k, { label }]) => { const value = attributes.movement[k]; - if ( !value ) return null; + const base = movementSource[k]; + if ( !value && !base ) return null; const data = { label, value }; if ( (k === "fly") && attributes.movement.hover ) data.icons = [{ icon: "fas fa-cloud", label: _loc("DND5E.MOVEMENT.Hover") }]; + if ( !value ) { + data.displayZero = true; + const sources = this.#movementReducers(k); + data.tooltip = sources.length + ? _loc("DND5E.MOVEMENT.ReducedBy", { effect: game.i18n.getListFormatter().format(sources) }) + : _loc("DND5E.MOVEMENT.Reduced", { speed: formatLength(base, attributes.movement.units) }); + } return data; }), ...splitSemicolons(attributes.movement.special).map(label => ({ label })) @@ -474,6 +483,25 @@ export default class NPCActorSheet extends BaseActorSheet { /* -------------------------------------------- */ + /** + * Identify the active effects currently reducing a movement type to zero. + * @param {string} key The movement type key. + * @returns {string[]} Names of the responsible effects. + */ + #movementReducers(key) { + const statuses = new Set(CONFIG.DND5E.conditionEffects.noMovement); + if ( key !== "walk" ) CONFIG.DND5E.conditionEffects.crawl.forEach(s => statuses.add(s)); + const path = `system.attributes.movement.${key}`; + const names = new Set(); + for ( const effect of this.actor.appliedEffects ) { + // Conditions zero movement in code (keyed off statuses); custom effects may override the field directly. + if ( effect.statuses.intersects(statuses) || effect.changes.some(c => c.key === path) ) names.add(effect.name); + } + return Array.from(names); + } + + /* -------------------------------------------- */ + /** @inheritDoc */ async _renderFrame(options) { const html = await super._renderFrame(options); diff --git a/templates/actors/parts/actor-trait-pills.hbs b/templates/actors/parts/actor-trait-pills.hbs index b94b0a9b35..3bf52efa45 100644 --- a/templates/actors/parts/actor-trait-pills.hbs +++ b/templates/actors/parts/actor-trait-pills.hbs @@ -15,13 +15,14 @@