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..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,55 @@ 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 */ +/* ---------------------------------- */ + +.combat-sidebar { + .combatant-group { + .fa-chevron-down { transition: transform 250ms ease; } + &.collapsed .fa-chevron-down { transform: rotate(-90deg); } + + &.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; } + .group-numbers { + flex: 0 0 20px; + color: var(--color-text-light-5); + font-size: var(--font-size-12); + 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; } + } +} + /* ---------------------------------- */ /* Context Menus */ /* ---------------------------------- */ diff --git a/module/applications/combat/combat-tracker.mjs b/module/applications/combat/combat-tracker.mjs index 6b970761aa..3a5acdade4 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,81 @@ 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 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", "collapsible", "dnd5e2-collapsible"); + if ( !V13 ) groupContainer.classlist.add("directory-item"); + 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} +
+ <${V13 ? "strong" : "h4"} class="name"> + ${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", {});