From 9c460bed51511b3ae662fc43951923d617cbfac8 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Tue, 17 Dec 2024 11:20:29 -0800 Subject: [PATCH 1/2] [#3667] Add grouping to initiative tracker Adds automatic grouping to the initiative tracker. This will group any combatants with the same initiative and the same base actor into a group. Groups are displayed as collapsible sections in the combat tracker that use the name from the base actor's prototype token. It lists the number of combatants in the group, and when one of the group members is active it displays how far through the group that actor is. Currently only offers the one grouping mode, but could be expanded in the future to support different grouping such as splitting it into "friendly" and "hostile" groups. Closes #3667 --- dnd5e.mjs | 2 + lang/en.json | 15 ++++ less/v2/apps.less | 22 +++++ module/applications/combat/combat-tracker.mjs | 86 ++++++++++++++++++- module/documents/combat.mjs | 53 ++++++++++++ module/documents/combatant.mjs | 12 +++ 6 files changed, 189 insertions(+), 1 deletion(-) diff --git a/dnd5e.mjs b/dnd5e.mjs index 17d1c31fc3..8e34e0934e 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -587,6 +587,8 @@ Hooks.on("renderJournalPageSheet", applications.journal.JournalSheet5e.onRenderJ Hooks.on("targetToken", canvas.Token5e.onTargetToken); +Hooks.on("renderCombatTracker", (app, html, data) => app.renderGroups(html instanceof HTMLElement ? html : html[0])); + Hooks.on("preCreateScene", (doc, createData, options, userId) => { // Set default grid units based on metric length setting const units = utils.defaultUnits("length"); diff --git a/lang/en.json b/lang/en.json index 1512f7d36d..d673b46f16 100644 --- a/lang/en.json +++ b/lang/en.json @@ -818,6 +818,21 @@ "DND5E.ClassOriginal": "Original Class", "DND5E.ClassSaves": "Saving Throws", "DND5E.Confirm": "Confirm", + +"DND5E.COMBAT": { + "Group": { + "ActiveCount": "{current} of {combatants}", + "Title": "{name} Group" + } +}, + +"DND5E.COMBATANT": { + "Counted": { + "one": "{number} combatant", + "other": "{number} combatants" + } +}, + "DND5E.CompendiumBrowser": { "Title": "Compendium Browser", "Action": { diff --git a/less/v2/apps.less b/less/v2/apps.less index 6a3c22eb18..0187f05f51 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -1559,6 +1559,28 @@ dialog.dnd5e2.application { } } +/* ---------------------------------- */ +/* Combat Tracker */ +/* ---------------------------------- */ + +.combat-sidebar { + .combatant-group { + .fa-chevron-down { transition: transform 250ms ease; } + &.collapsed .fa-chevron-down { transform: rotate(-90deg); } + + &.active::after { transition: border-color 250ms ease; } + &.active:not(.collapsed)::after { border-color: color-mix(in oklab, var(--color-border-highlight), transparent); } + + .group-header { cursor: pointer; } + .group-numbers { + flex: 0 0 20px; + color: var(--color-text-light-5); + font-size: var(--font-size-12); + line-height: 20px; + } + } +} + /* ---------------------------------- */ /* Context Menus */ /* ---------------------------------- */ diff --git a/module/applications/combat/combat-tracker.mjs b/module/applications/combat/combat-tracker.mjs index 6b970761aa..2177264bd4 100644 --- a/module/applications/combat/combat-tracker.mjs +++ b/module/applications/combat/combat-tracker.mjs @@ -1,10 +1,18 @@ -import { formatNumber } from "../../utils.mjs"; +import ContextMenu5e from "../context-menu.mjs"; +import { formatNumber, getPluralRules } from "../../utils.mjs"; + +/** + * @typedef {object} CombatGroupData + * @property {boolean} expanded + */ /** * An extension of the base CombatTracker class to provide some 5e-specific functionality. * @extends {CombatTracker} */ export default class CombatTracker5e extends CombatTracker { + + /** @inheritDoc */ async getData(options={}) { const context = await super.getData(options); context.turns.forEach(turn => { @@ -23,4 +31,80 @@ export default class CombatTracker5e extends CombatTracker { if ( (btn.dataset.control === "rollInitiative") && combatant?.actor ) return combatant.actor.rollInitiativeDialog(); return super._onCombatantControl(event); } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _contextMenu(html) { + if ( !(html instanceof HTMLElement) ) html = html[0]; + new ContextMenu5e( + html.querySelector(".directory-list"), ".directory-item:not(.combatant-group)", this._getEntryContextOptions() + ); + } + + /* -------------------------------------------- */ + + /** + * Adjust initiative tracker to group combatants. + * @param {HTMLElement} html The combat tracker being rendered. + */ + renderGroups(html) { + if ( !this.viewed ) return; + const groups = this.viewed.createGroups(); + const list = html.querySelector(".directory-list"); + list.classList.add("dnd5e2"); + for ( const [key, { combatants, expanded }] of groups.entries() ) { + const children = list.querySelectorAll(Array.from(combatants).map(c => `[data-combatant-id="${c.id}"]`).join(", ")); + if ( !children.length ) continue; + const groupContainer = document.createElement("li"); + groupContainer.classList.add("combatant", "combatant-group", "directory-item", "collapsible"); + if ( !expanded ) groupContainer.classList.add("collapsed"); + + // Determine the count + let activeEntry; + for ( const [index, element] of children.entries() ) { + if ( element.classList.contains("active") ) activeEntry = index; + } + let count = game.i18n.format(`DND5E.COMBATANT.Counted.${getPluralRules().select(children.length)}`, { + number: formatNumber(children.length) + }); + if ( activeEntry !== undefined ) { + groupContainer.classList.add("active"); + count = game.i18n.format("DND5E.COMBAT.Group.ActiveCount", { + combatants: count, current: formatNumber(activeEntry + 1) + }); + } + + const name = combatants[0].token.baseActor.prototypeToken.name; + const img = children[0].querySelector("img"); + groupContainer.innerHTML = ` +
+ ${img.alt} +
+

+ ${game.i18n.format("DND5E.COMBAT.Group.Title", { name })} +

+
${count}
+
+
+ +
+
+
+
+
    +
    +
    + `; + groupContainer.dataset.groupKey = key; + children[0].before(groupContainer); + groupContainer.querySelector(".group-children").replaceChildren(...children); + groupContainer.addEventListener("click", event => { + if ( event.target.closest(".collapsible-content") ) return; + if ( groupContainer.classList.contains("collapsed") ) this.viewed.expandedGroups.add(key); + else this.viewed.expandedGroups.delete(key); + groupContainer.classList.toggle("collapsed"); + }); + } + } } diff --git a/module/documents/combat.mjs b/module/documents/combat.mjs index 70fb305601..ef6ad19e73 100644 --- a/module/documents/combat.mjs +++ b/module/documents/combat.mjs @@ -3,6 +3,20 @@ */ export default class Combat5e extends Combat { + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * Expansion state for groups within this combat. + * @type {Set} + */ + expandedGroups = new Set(); + + /* -------------------------------------------- */ + /* Methods */ + /* -------------------------------------------- */ + /** @inheritDoc */ async startCombat() { await super.startCombat(); @@ -44,10 +58,49 @@ export default class Combat5e extends Combat { return this; } + /* -------------------------------------------- */ + + /** @override */ + _sortCombatants(a, b) { + // Initiative takes top priority + if ( a.initiative !== b.initiative ) return super._sortCombatants(a, b); + + // Separate out combatants with different base actors + if ( !a.token?.baseActor || !b.token?.baseActor || (a.token?.baseActor !== b.token?.baseActor) ) { + const name = c => `${c.token?.baseActor?.name ?? ""}.${c.token?.baseActor?.id ?? ""}`; + return name(a).localeCompare(name(b), game.i18n.lang); + } + + // Otherwise sort based on combatant name + return a.name.localeCompare(b.name, game.i18n.lang); + } + /* -------------------------------------------- */ /* Helpers */ /* -------------------------------------------- */ + /** + * Determine which group each combatant should be added to, or if a new group should be created. + * @returns {Map} + */ + createGroups() { + const groups = new Map(); + for ( const combatant of this.combatants ) { + const key = combatant.getGroupingKey(); + if ( key === null ) continue; + if ( !groups.has(key) ) groups.set(key, { combatants: [], expanded: this.expandedGroups.has(key) }); + groups.get(key).combatants.push(combatant); + } + + for ( const [key, { combatants }] of groups.entries() ) { + if ( combatants.length <= 1 ) groups.delete(key); + } + + return groups; + } + + /* -------------------------------------------- */ + /** * Reset combat specific uses. * @param {object} types Which types of recovery to handle, and whether they should be performed on all combatants diff --git a/module/documents/combatant.mjs b/module/documents/combatant.mjs index d1187b70ad..9f0b30184a 100644 --- a/module/documents/combatant.mjs +++ b/module/documents/combatant.mjs @@ -2,6 +2,18 @@ * Custom combatant with custom initiative roll handling. */ export default class Combatant5e extends Combatant { + + /** + * Key for the group to which this combatant should belong, or `null` if it can't be grouped. + * @returns {boolean|null} + */ + getGroupingKey() { + if ( !this.token?.baseActor || (this.initiative === null) ) return null; + return `${Math.floor(this.initiative).paddedString(4)}:${this.token.baseActor.id}`; + } + + /* -------------------------------------------- */ + /** @override */ getInitiativeRoll(formula) { if ( !this.actor ) return new CONFIG.Dice.D20Roll(formula ?? "1d20", {}); From 62330623111b1fd7dcb5108ba0c6f8b0ee39de22 Mon Sep 17 00:00:00 2001 From: Jeff Hitchcock Date: Fri, 20 Dec 2024 12:27:25 -0800 Subject: [PATCH 2/2] [#3667] Adjust HTML & styles to work with V13 Tweak the generated HTML to work with the AppV2 combat tracker in V13. Also moved the `collapsible` styles so they are usable without the `dnd5e2` class so long as the `dnd5e2-collapsible` class is also used. --- less/v2/apps.less | 43 ++++++++++++------- module/applications/combat/combat-tracker.mjs | 13 +++--- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/less/v2/apps.less b/less/v2/apps.less index 0187f05f51..57ef879c11 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -231,20 +231,6 @@ } } - .collapsible { - &.collapsed { - .fa-caret-down { transform: rotate(-90deg); } - .collapsible-content { grid-template-rows: 0fr; } - } - .fa-caret-down { transition: transform 250ms ease; } - .collapsible-content { - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 250ms ease; - > .wrapper { overflow: hidden; } - } - } - .unlist { list-style: none; padding: 0; @@ -1559,6 +1545,24 @@ dialog.dnd5e2.application { } } +/* ---------------------------------- */ +/* Collapsible Content */ +/* ---------------------------------- */ + +.dnd5e2 .collapsible, .collapsible.dnd5e2-collapsible { + &.collapsed { + .fa-caret-down { transform: rotate(-90deg); } + .collapsible-content { grid-template-rows: 0fr; } + } + .fa-caret-down { transition: transform 250ms ease; } + .collapsible-content { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 250ms ease; + > .wrapper { overflow: hidden; } + } +} + /* ---------------------------------- */ /* Combat Tracker */ /* ---------------------------------- */ @@ -1568,7 +1572,7 @@ dialog.dnd5e2.application { .fa-chevron-down { transition: transform 250ms ease; } &.collapsed .fa-chevron-down { transform: rotate(-90deg); } - &.active::after { transition: border-color 250ms ease; } + &.active::after, &.active::before { transition: border-color 250ms ease; } &.active:not(.collapsed)::after { border-color: color-mix(in oklab, var(--color-border-highlight), transparent); } .group-header { cursor: pointer; } @@ -1579,6 +1583,15 @@ dialog.dnd5e2.application { line-height: 20px; } } + + /* V13 Syles */ + .combat-tracker { + .combatant-group { + display: block; + &.active:not(.collapsed)::before { border-color: color-mix(in oklab, var(--color-warm-2), transparent); } + } + .group-children { padding: 0; } + } } /* ---------------------------------- */ diff --git a/module/applications/combat/combat-tracker.mjs b/module/applications/combat/combat-tracker.mjs index 2177264bd4..3a5acdade4 100644 --- a/module/applications/combat/combat-tracker.mjs +++ b/module/applications/combat/combat-tracker.mjs @@ -51,13 +51,14 @@ export default class CombatTracker5e extends CombatTracker { renderGroups(html) { if ( !this.viewed ) return; const groups = this.viewed.createGroups(); - const list = html.querySelector(".directory-list"); - list.classList.add("dnd5e2"); + const V13 = game.release.generation >= 13; + const list = html.querySelector(".directory-list, .combat-tracker"); for ( const [key, { combatants, expanded }] of groups.entries() ) { const children = list.querySelectorAll(Array.from(combatants).map(c => `[data-combatant-id="${c.id}"]`).join(", ")); if ( !children.length ) continue; const groupContainer = document.createElement("li"); - groupContainer.classList.add("combatant", "combatant-group", "directory-item", "collapsible"); + groupContainer.classList.add("combatant", "combatant-group", "collapsible", "dnd5e2-collapsible"); + if ( !V13 ) groupContainer.classlist.add("directory-item"); if ( !expanded ) groupContainer.classList.add("collapsed"); // Determine the count @@ -81,9 +82,9 @@ export default class CombatTracker5e extends CombatTracker {
    ${img.alt}
    -

    + <${V13 ? "strong" : "h4"} class="name"> ${game.i18n.format("DND5E.COMBAT.Group.Title", { name })} -

    +
    ${count}
    @@ -92,7 +93,7 @@ export default class CombatTracker5e extends CombatTracker {
    -
      +
        `;