Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#3667] Add grouping to initiative tracker #4897

Open
wants to merge 2 commits into
base: 4.2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dnd5e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
63 changes: 49 additions & 14 deletions less/v2/apps.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
/* ---------------------------------- */
Expand Down
87 changes: 86 additions & 1 deletion module/applications/combat/combat-tracker.mjs
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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 = `
<div class="group-header flexrow">
<img class="token-image" alt="${img.alt}" src="${img.src || img.dataset.src}">
<div class="token-name flexcol">
<${V13 ? "strong" : "h4"} class="name">
${game.i18n.format("DND5E.COMBAT.Group.Title", { name })}
</${V13 ? "strong" : "h4"}>
<div class="group-numbers">${count}</div>
</div>
<div class="token-initiative">
<i class="fa-solid fa-chevron-down fa-fw" inert></i>
</div>
</div>
<div class="collapsible-content">
<div class="wrapper">
<ol class="group-children ${V13 ? "" : "directory-list"}"></ol>
</div>
</div>
`;
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");
});
}
}
}
53 changes: 53 additions & 0 deletions module/documents/combat.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
*/
export default class Combat5e extends Combat {

/* -------------------------------------------- */
/* Properties */
/* -------------------------------------------- */

/**
* Expansion state for groups within this combat.
* @type {Set<string>}
*/
expandedGroups = new Set();

/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */

/** @inheritDoc */
async startCombat() {
await super.startCombat();
Expand Down Expand Up @@ -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<string, { combatants: Combatant5e[], expanded: boolean }>}
*/
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
Expand Down
12 changes: 12 additions & 0 deletions module/documents/combatant.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", {});
Expand Down