diff --git a/.eslintrc b/.eslintrc
index cb549cfc..56796012 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -98,4 +98,4 @@
"fromUuidSync": "readonly",
"PoolTerm": "readonly"
}
-}
\ No newline at end of file
+}
diff --git a/src/lang/en.json b/src/lang/en.json
index 4a1ed8da..4f18ca78 100644
--- a/src/lang/en.json
+++ b/src/lang/en.json
@@ -8,6 +8,7 @@
"OSE.Reset": "Reset",
"OSE.Cancel": "Cancel",
"OSE.Roll": "Roll",
+ "OSE.Reroll": "Reroll",
"OSE.Success": "Success",
"OSE.Failure": "Failure",
@@ -304,6 +305,7 @@
"OSE.colors.orange": "Orange",
"OSE.colors.white": "White",
+ "OSE.colors.black": "Black",
"OSE.reaction.check": "Reaction Check",
"OSE.reaction.Hostile": "{name} is Hostile",
"OSE.reaction.Unfriendly": "{name} is Unfriendly",
@@ -311,6 +313,8 @@
"OSE.reaction.Indifferent": "{name} is Indifferent",
"OSE.reaction.Friendly": "{name} is Friendly",
+ "OSE.combat.SetCombatantAsActive": "Set Active",
+ "OSE.combat.SetCombatantGroups": "Set Combatant Groups",
"OSE.CombatFlag.SpellDeclared": "Spell Declared",
"OSE.CombatFlag.RetreatFromMeleeDeclared": "Retreat From Melee Declared",
diff --git a/src/module/combat.js b/src/module/combat.js
deleted file mode 100644
index 88d778f2..00000000
--- a/src/module/combat.js
+++ /dev/null
@@ -1,361 +0,0 @@
-/**
- * @file System-level odifications to the way combat works
- */
-import OSE from "./config";
-
-const OseCombat = {
- STATUS_SLOW: -789,
- STATUS_DIZZY: -790,
-
- debounce(callback, wait) {
- let timeoutId = null;
- return (...args) => {
- window.clearTimeout(timeoutId);
- timeoutId = window.setTimeout(() => {
- callback.apply(null, args);
- }, wait);
- };
- },
- async rollInitiative(combat, data) {
- // Check groups
- data.combatants = [];
- const groups = {};
- const combatants = combat?.combatants;
- combatants.forEach((cbt) => {
- const group = cbt.getFlag(game.system.id, "group");
- groups[group] = { present: true };
- data.combatants.push(cbt);
- });
- // Roll init
- for (const group in groups) {
- // Object.keys(groups).forEach((group) => {
- const roll = new Roll("1d6");
- await roll.evaluate();
- await roll.toMessage({
- flavor: game.i18n.format("OSE.roll.initiative", {
- group: CONFIG.OSE.colors[group],
- }),
- });
- groups[group].initiative = roll.total;
- // });
- }
- // Set init
- for (let i = 0; i < data.combatants.length; ++i) {
- if (game.user.isGM) {
- if (!data.combatants[i].actor) {
- return;
- }
- const actorData = data.combatants[i].actor?.system;
- if (actorData.isSlow) {
- await data.combatants[i].update({
- initiative: OseCombat.STATUS_SLOW,
- });
- } else {
- const group = data.combatants[i].getFlag(game.system.id, "group");
- this.debounce(
- data.combatants[i].update({ initiative: groups[group].initiative }),
- 500
- );
- }
- }
- }
-
- await combat.setupTurns();
- },
-
- async resetInitiative(combat, data) {
- const reroll = game.settings.get(game.system.id, "rerollInitiative");
- if (!["reset", "reroll"].includes(reroll)) {
- return;
- }
- combat.resetAll();
- },
-
- async individualInitiative(combat, data) {
- const updates = [];
- const rolls = [];
- const combatants = combat?.combatants;
- for (let i = 0; i < combatants.size; i++) {
- const c = combatants.contents[i];
- // check if actor initiative has already been set for round 1
- if (c?.initiative != null && combat.round === 0) {
- continue;
- }
- // This comes from foundry.js, had to remove the update turns thing
- // Roll initiative
- const cf = await c._getInitiativeFormula(c);
- const roll = await c.getInitiativeRoll(cf);
- rolls.push(roll);
- const data = { _id: c.id };
- updates.push(data);
- }
- // combine init rolls
- const pool = PoolTerm.fromRolls(rolls);
- const combinedRoll = await Roll.fromTerms([pool]);
- // get evaluated chat message
- const evalRoll = await combinedRoll.toMessage({}, { create: false });
- const rollArr = combinedRoll.terms[0].rolls;
- let msgContent = ``;
- for (const [i, roll] of rollArr.entries()) {
- // get combatant
- const cbt = game.combats.viewed.combatants.find(
- (c) => c.id === updates[i]._id
- );
- // add initiative value to update
- // check if actor is slow
- let value = cbt.actor?.system?.isSlow
- ? OseCombat.STATUS_SLOW
- : roll.total;
- // check if actor is defeated
- if (combat.settings.skipDefeated && cbt.isDefeated) {
- value = OseCombat.STATUS_DIZZY;
- }
- updates[i].initiative = value;
-
- // render template
- const template = `${OSE.systemPath()}/templates/chat/roll-individual-initiative.html`;
- const tData = {
- name: cbt.name,
- formula: roll.formula,
- result: roll.result,
- total: roll.total,
- };
- const rendered = await renderTemplate(template, tData);
- msgContent += rendered;
- }
- evalRoll.content = `
-
- ${game.i18n.localize("OSE.roll.individualInitGroup")}
- ${msgContent}
- `;
- ChatMessage.create(evalRoll);
- // update tracker
- if (game.user.isGM)
- await combat.updateEmbeddedDocuments("Combatant", updates);
- data.turn = 0;
- },
-
- format(object, html, user) {
- html.find(".initiative").each((_, span) => {
- span.innerHTML =
- span.innerHTML === `${OseCombat.STATUS_SLOW}`
- ? ''
- : span.innerHTML;
- span.innerHTML =
- span.innerHTML === `${OseCombat.STATUS_DIZZY}`
- ? ''
- : span.innerHTML;
- });
-
- html.find(".combatant").each((_, ct) => {
- // Append spellcast and retreat
- const controls = $(ct).find(".combatant-controls .combatant-control");
- const cmbtant = object.viewed.combatants.get(ct.dataset.combatantId);
- const moveInCombat = cmbtant.getFlag(game.system.id, "moveInCombat");
- const preparingSpell = cmbtant.getFlag(game.system.id, "prepareSpell");
- const moveActive = moveInCombat ? "active" : "";
- controls
- .eq(1)
- .after(
- ``
- );
- const spellActive = preparingSpell ? "active" : "";
- controls
- .eq(1)
- .after(
- ``
- );
- });
- OseCombat.announceListener(html);
-
- const init = game.settings.get(game.system.id, "initiative") === "group";
- if (!init) {
- return;
- }
-
- html.find('.combat-control[data-control="rollNPC"]').remove();
- html.find('.combat-control[data-control="rollAll"]').remove();
- const trash = html.find(
- '.encounters .combat-control[data-control="endCombat"]'
- );
- $(
- ''
- ).insertBefore(trash);
-
- html.find(".combatant").each((_, ct) => {
- // Can't roll individual inits
- $(ct).find(".roll").remove();
-
- // Get group color
- const cmbtant = object.viewed.combatants.get(ct.dataset.combatantId);
- const color = cmbtant.getFlag(game.system.id, "group");
-
- // Append colored flag
- const controls = $(ct).find(".combatant-controls");
- controls.prepend(
- ``
- );
- });
- OseCombat.addListeners(html);
- },
-
- updateCombatant(combatant, data) {
- const init = game.settings.get(game.system.id, "initiative");
- // Why do you reroll ?
- const actorData = combatant.actor?.system;
- if (actorData.isSlow) {
- data.initiative = -789;
- return;
- }
- if (data.initiative && init === "group") {
- const groupInit = data.initiative;
- const cmbtGroup = combatant.getFlag(game.system.id, "group");
- // Check if there are any members of the group with init
- game.combats.viewed.combatants.forEach((ct) => {
- const group = ct.getFlag(game.system.id, "group");
- if (
- ct.initiative &&
- ct.initiative != "-789.00" &&
- ct.id != data.id &&
- group === cmbtGroup &&
- game.user.isGM
- ) {
- // Set init
- combatant.update({ initiative: parseInt(groupInit) });
- }
- });
- }
- },
-
- announceListener(html) {
- html.find(".combatant-control.prepare-spell").click((ev) => {
- ev.preventDefault();
- // Toggle spell announcement
- const id = $(ev.currentTarget).closest(".combatant")[0].dataset
- .combatantId;
- const isActive = ev.currentTarget.classList.contains("active");
- const combatant = game.combat.combatants.get(id);
- combatant.setFlag(game.system.id, "prepareSpell", !isActive);
- });
- html.find(".combatant-control.move-combat").click((ev) => {
- ev.preventDefault();
- // Toggle spell announcement
- const id = $(ev.currentTarget).closest(".combatant")[0].dataset
- .combatantId;
- const isActive = ev.currentTarget.classList.contains("active");
- const combatant = game.combat.combatants.get(id);
- if (game.user.isGM) {
- combatant.setFlag(game.system.id, "moveInCombat", !isActive);
- }
- });
- },
-
- addListeners(html) {
- // Cycle through colors
- html.find(".combatant-control.flag").click((ev) => {
- if (!game.user.isGM) {
- return;
- }
- const currentColor = ev.currentTarget.style.color;
- const colors = Object.keys(CONFIG.OSE.colors);
- let index = colors.indexOf(currentColor);
- if (index + 1 === colors.length) {
- index = 0;
- } else {
- index++;
- }
- const id = $(ev.currentTarget).closest(".combatant")[0].dataset
- .combatantId;
- const combatant = game.combat.combatants.get(id);
- if (game.user.isGM) {
- combatant.setFlag(game.system.id, "group", colors[index]);
- }
- });
-
- html.find('.combat-control[data-control="reroll"]').click((ev) => {
- if (!game.combat) {
- return;
- }
- const data = {};
- OseCombat.rollInitiative(game.combat, data);
- if (game.user.isGM) {
- game.combat.update({ data }).then(() => {
- game.combat.setupTurns();
- });
- }
- });
- },
-
- addCombatant(combat, data, options, id) {
- const token = canvas.tokens.get(data.tokenId);
- let color = "black";
- const disposition = token?.disposition || token?.data?.disposition;
- switch (disposition) {
- case -1: {
- color = "red";
- break;
- }
-
- case 0: {
- color = "yellow";
- break;
- }
-
- case 1: {
- color = "green";
- break;
- }
- }
- data.flags = {
- ose: {
- group: color,
- },
- };
- combat.updateSource({ flags: { ose: { group: color } } });
- },
-
- activateCombatant(li) {
- const turn = game.combat.turns.findIndex(
- (turn) => turn.id === li.data("combatant-id")
- );
- if (game.user.isGM) {
- game.combat.update({ turn });
- }
- },
-
- addContextEntry(html, options) {
- options.unshift({
- name: "Set Active",
- icon: '',
- callback: OseCombat.activateCombatant,
- });
- },
-
- async preUpdateCombat(combat, data, diff, id) {
- const init = game.settings.get(game.system.id, "initiative");
- const reroll = game.settings.get(game.system.id, "rerollInitiative");
- if (!data.round) {
- return;
- }
- if (data.round !== 1) {
- if (reroll === "reset") {
- OseCombat.resetInitiative(combat, data, diff, id);
- return;
- }
- if (reroll === "keep") {
- return;
- }
- }
- if (init === "group") {
- OseCombat.rollInitiative(combat, data, diff, id);
- } else if (init === "individual") {
- OseCombat.individualInitiative(combat, data, diff, id);
- }
- },
-};
-
-export default OseCombat;
diff --git a/src/module/combat/combat-group.ts b/src/module/combat/combat-group.ts
new file mode 100644
index 00000000..be230ae0
--- /dev/null
+++ b/src/module/combat/combat-group.ts
@@ -0,0 +1,142 @@
+import OSE from "../config";
+import { OSECombat } from "./combat";
+import { OSEGroupCombatant } from "./combatant-group";
+
+export const colorGroups = OSE.colors;
+export const actionGroups = {
+ 'slow': "OSE.items.Slow",
+ 'cast': "OSE.spells.Cast",
+}
+
+/**
+ * An extension of Foundry's Combat class that implements side-based initiative.
+ *
+ * @todo Display the initiative results roll as a chat card
+ */
+export class OSEGroupCombat extends OSECombat {
+ // ===========================================================================
+ // STATIC MEMBERS
+ // ===========================================================================
+ static FORMULA = "1d6";
+
+ static get GROUPS () {
+ return {
+ ...colorGroups,
+ ...actionGroups,
+ };
+ }
+
+ // ===========================================================================
+ // INITIATIVE MANAGEMENT
+ // ===========================================================================
+
+ async #rollAbsolutelyEveryone() {
+ await this.rollInitiative();
+ }
+
+ async rollInitiative() {
+ const groupsToRollFor = this.availableGroups;
+ const rollPerGroup = groupsToRollFor.reduce((prev, curr) => ({
+ ...prev,
+ [curr]: new Roll(OSEGroupCombat.FORMULA)
+ }), {});
+
+ const results = await this.#prepareGroupInitiativeDice(rollPerGroup);
+
+ const updates = this.combatants.map(
+ (c) => ({ _id: c.id, initiative: results[c.group].initiative })
+ )
+
+ await this.updateEmbeddedDocuments("Combatant", updates);
+ await this.#rollInitiativeUIFeedback(results);
+ await this.activateCombatant(0);
+ return this;
+ }
+
+ async #prepareGroupInitiativeDice(rollPerGroup: unknown) {
+ const pool = foundry.dice.terms.PoolTerm.fromRolls(Object.values(rollPerGroup));
+ const evaluatedRolls = await Roll.fromTerms([pool]).roll()
+ const rollValues = evaluatedRolls.dice.map(d => d.total);
+ return this.availableGroups.reduce((prev, curr, i) => ({
+ ...prev,
+ [curr]: {
+ initiative: curr !== "slow" ? rollValues[i] : OSEGroupCombatant.INITIATIVE_VALUE_SLOWED,
+ roll: evaluatedRolls.dice[i]
+ }
+ }), {});
+ }
+
+ async #rollInitiativeUIFeedback(groups = []) {
+ const content = [
+ Object.keys(groups).map(
+ (k) => k === "slow" ? "" : this.#constructInitiativeOutputForGroup(k, groups[k].roll)
+ ).join("\n")
+ ];
+ const chatData = content.map(c => {
+ return {
+ speaker: {alias: game.i18n.localize("OSE.Initiative")},
+ sound: CONFIG.sounds.dice,
+ content: c
+ };
+ });
+ ChatMessage.implementation.createDocuments(chatData);
+ }
+
+ #constructInitiativeOutputForGroup(group, roll) {
+ return `
+
${game.i18n.format("OSE.roll.initiative", { group })}
+
+
+
${roll.formula}
+
+
${roll.total}
+
+
+ `;
+ }
+
+ // ===========================================================================
+ // GROUP GETTERS
+ //
+ // Get groups as:
+ // - a list of strings
+ // - a list of strings with combatants attached
+ // - a map of groups to their initiative results
+ // ===========================================================================
+
+ get availableGroups(): string[] {
+ return [...new Set(
+ this.combatants.map(c => c.group)
+ )]
+ }
+
+ get combatantsByGroup(): Record {
+ return this.availableGroups.reduce((prev, curr) => ({
+ ...prev,
+ [curr]: this.combatants.filter(c => c.group === curr)
+ }), {});
+ }
+
+ get groupInitiativeScores(): unknown {
+ const initiativeMap = new Map()
+ for (const group in this.combatantsByGroup) {
+ initiativeMap.set(group, this.combatantsByGroup[group][0].initiative)
+ }
+
+ return initiativeMap;
+ }
+}
diff --git a/src/module/combat/combat-set-groups.ts b/src/module/combat/combat-set-groups.ts
new file mode 100644
index 00000000..118da488
--- /dev/null
+++ b/src/module/combat/combat-set-groups.ts
@@ -0,0 +1,101 @@
+import OSE from "../config";
+import { colorGroups } from "./combat-group";
+
+const {
+ HandlebarsApplicationMixin,
+ ApplicationV2
+} = foundry.applications.api;
+
+export default class OSECombatGroupSelector extends HandlebarsApplicationMixin(ApplicationV2) {
+ _highlighted;
+
+
+ // ===========================================================================
+ // APPLICATION SETUP
+ // ===========================================================================
+ static DEFAULT_OPTIONS = {
+ id: "combat-set-groups-{id}",
+ classes: ["combat-set-groups", "scrollable"],
+ tag: "form",
+ window: {
+ frame: true,
+ positioned: true,
+ title: "OSE.combat.SetCombatantGroups",
+ icon: "fa-flag",
+ controls: [],
+ minimizable: false,
+ resizable: true,
+ contentTag: "section",
+ contentClasses: []
+ },
+ actions: {},
+ form: {
+ handler: undefined,
+ submitOnChange: true
+ },
+ position: {
+ width: 330,
+ height: "auto"
+ }
+ }
+
+ static PARTS = {
+ main: {
+ template: `/systems/ose-dev/dist/templates/apps/combat-set-groups.hbs`
+ }
+ }
+
+
+ // ===========================================================================
+ // RENDER SETUP
+ // ===========================================================================
+
+ async _prepareContext(_options) {
+ return {
+ groups: colorGroups,
+ combatants: game.combat.combatants,
+ }
+ }
+
+ _onRender(context, options) {
+ super._onRender(context, options);
+ for ( const li of this.element.querySelectorAll("[data-combatant-id]") ) {
+ li.addEventListener("mouseover", this.#onCombatantHoverIn.bind(this));
+ li.addEventListener("mouseout", this.#onCombatantHoverOut.bind(this));
+ }
+ this.element.addEventListener("change", this._updateObject);
+ }
+
+
+ // ===========================================================================
+ // UPDATING
+ // ===========================================================================
+
+ protected async _updateObject(event: Event): Promise {
+ const combatant = game.combat.combatants.get(event.target.name);
+ await combatant.setFlag(game.system.id, "group", event.target.value)
+ }
+
+
+ // ===========================================================================
+ // UI EVENTS
+ // ===========================================================================
+
+ #onCombatantHoverIn(event) {
+ event.preventDefault();
+ if ( !canvas.ready ) return;
+ const li = event.currentTarget;
+ const combatant = game.combat.combatants.get(li.dataset.combatantId);
+ const token = combatant.token?.object;
+ if ( token?.isVisible ) {
+ if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
+ this._highlighted = token;
+ }
+ }
+
+ #onCombatantHoverOut(event) {
+ event.preventDefault();
+ if ( this._highlighted ) this._highlighted._onHoverOut(event);
+ this._highlighted = null;
+ }
+}
diff --git a/src/module/combat/combat.ts b/src/module/combat/combat.ts
new file mode 100644
index 00000000..049c3411
--- /dev/null
+++ b/src/module/combat/combat.ts
@@ -0,0 +1,63 @@
+/**
+ * @file System-level odifications to the way combat works
+ */
+
+/**
+ * An extension of Foundry's Combat class that implements initiative for indivitual combatants.
+ *
+ * @todo Use a single chat card for rolling group initiative
+ */
+export class OSECombat extends Combat {
+ static FORMULA = "1d6 + @init";
+
+ get #rerollBehavior() {
+ return game.settings.get(game.system.id, "rerollInitiative");
+ }
+
+ // ===========================================================================
+ // INITIATIVE MANAGEMENT
+ // ===========================================================================
+
+ async #rollAbsolutelyEveryone() {
+ await this.rollInitiative(
+ this.combatants.map(c => c.id),
+ { formula: (this.constructor as typeof OSECombat).FORMULA }
+ );
+ }
+
+
+ // ===========================================================================
+ // COMBAT LIFECYCLE MANAGEMENT
+ // ===========================================================================
+
+ async startCombat() {
+ await super.startCombat();
+ if (this.#rerollBehavior !== "reset")
+ await this.#rollAbsolutelyEveryone();
+ return this;
+ }
+
+ async _onEndRound() {
+ switch(this.#rerollBehavior) {
+ case "reset":
+ this.resetAll();
+ break;
+ case "reroll":
+ this.#rollAbsolutelyEveryone();
+ break;
+ case "keep":
+ default:
+ break;
+ }
+ // @ts-expect-error - This method exists, but the types package doesn't have it
+ await super._onEndRound();
+ await this.activateCombatant(0)
+ }
+
+ async activateCombatant(turn: number) {
+ if (game.user.isGM) {
+ await game.combat.update({ turn });
+ }
+ }
+}
+
diff --git a/src/module/combat/combatant-group.ts b/src/module/combat/combatant-group.ts
new file mode 100644
index 00000000..b0e21341
--- /dev/null
+++ b/src/module/combat/combatant-group.ts
@@ -0,0 +1,37 @@
+import { OSECombatant } from "./combatant";
+
+export class OSEGroupCombatant extends OSECombatant {
+ get group() {
+ if (this.actor.system.isSlow)
+ return "slow";
+
+ return this.groupRaw;
+ }
+
+ get groupRaw() {
+ const assignedGroup = this.getFlag(game.system.id, "group");
+ if (assignedGroup)
+ return assignedGroup;
+
+ if (canvas.tokens) {
+ const token = canvas.tokens.get(this.token.id);
+ const disposition = token.document.disposition;
+ switch (disposition) {
+ case -1:
+ return "red";
+ case 0:
+ return "yellow";
+ case 1:
+ return "green";
+ }
+ }
+
+ return 'white';
+ }
+
+ set group(value) {
+ this.setFlag(game.system.id, 'group', value || 'black');
+ }
+}
+
+
diff --git a/src/module/combat/combatant.ts b/src/module/combat/combatant.ts
new file mode 100644
index 00000000..0a1def95
--- /dev/null
+++ b/src/module/combat/combatant.ts
@@ -0,0 +1,48 @@
+export class OSECombatant extends Combatant {
+ static INITIATIVE_VALUE_SLOWED = -789;
+ static INITIATIVE_VALUE_DEFEATED = -790;
+
+ // ===========================================================================
+ // BOOLEAN FLAGS
+ // ===========================================================================
+
+ get isCasting() {
+ return this.getFlag(game.system.id, "prepareSpell");
+ }
+ set isCasting(value) {
+ this.setFlag(game.system.id, 'prepareSpell', value)
+ }
+
+ get isSlow() {
+ return this.actor.system.isSlow;
+ }
+
+ get isDefeated() {
+ if (this.defeated)
+ return true;
+
+ return !this.defeated && (this.actor.system.hp.value === 0)
+ }
+
+ // ===========================================================================
+ // INITIATIVE MANAGEMENT
+ // ===========================================================================
+
+ getInitiativeRoll(formula: string) {
+ let term = formula || CONFIG.Combat.initiative.formula;
+ if (this.isSlow) term = `${OSECombatant.INITIATIVE_VALUE_SLOWED}`;
+ if (this.isDefeated) term = `${OSECombatant.INITIATIVE_VALUE_DEFEATED}`;
+
+ return new Roll(term);
+ }
+
+ async getData(options = {}) {
+ const context = await super.getData(options);
+ return foundry.utils.mergeObject(context, {
+ slow: this.isSlow,
+ casting: this.isCasting
+ })
+ }
+
+}
+
diff --git a/src/module/combat/sidebar.ts b/src/module/combat/sidebar.ts
new file mode 100644
index 00000000..3a926879
--- /dev/null
+++ b/src/module/combat/sidebar.ts
@@ -0,0 +1,132 @@
+import OSE from "../config";
+import { OSEGroupCombat } from "./combat-group";
+import OSECombatGroupSelector from "./combat-set-groups";
+import { OSECombatant } from "./combatant";
+
+export class OSECombatTab extends CombatTracker {
+ // ===========================================================================
+ // APPLICATION SETUP
+ // ===========================================================================
+
+ /** @inheritdoc */
+ static get defaultOptions() {
+ return foundry.utils.mergeObject(super.defaultOptions, {
+ template: `${OSE.systemPath()}/templates/sidebar/combat-tracker.hbs`,
+ });
+ }
+
+ static GROUP_CONFIG_APP = new OSECombatGroupSelector();
+
+
+ // ===========================================================================
+ // RENDERING
+ // ===========================================================================
+
+ async getData(options) {
+ const context = await super.getData(options);
+ const isGroupInitiative = game.settings.get(game.system.id, "initiative") === "group";
+
+ // @ts-expect-error - We don't have type data for the combat tracker turn object
+ const turns = context.turns.map((turn) => {
+ const combatant = game.combat.combatants.get(turn.id);
+ turn.isSlowed = turn.initiative === `${OSECombatant.INITIATIVE_VALUE_SLOWED}`
+ turn.isCasting = !!combatant.getFlag(game.system.id, "prepareSpell");
+ turn.isRetreating = !!combatant.getFlag(game.system.id, "moveInCombat");
+ turn.isOwnedByUser = !!combatant.actor.isOwner;
+ turn.group = combatant.group;
+ return turn;
+ });
+
+ const groups = turns.reduce((arr, turn) => {
+ const idx = arr.findIndex(r => r.group === turn.group);
+
+ if (idx !== -1) {
+ arr[idx].turns.push(turn);
+ return arr;
+ }
+
+ return [...arr, {
+ group: turn.group,
+ label: OSEGroupCombat.GROUPS[turn.group],
+ initiative: turn.initiative,
+ turns: [turn]
+ }];
+ }, []);
+
+ return foundry.utils.mergeObject(context, {
+ turns,
+ groups,
+ isGroupInitiative
+ })
+ }
+
+
+ // ===========================================================================
+ // UI EVENTS
+ // ===========================================================================
+
+ activateListeners(html: JQuery) {
+ super.activateListeners(html);
+ const trackerHeader = html.find("#combat > header");
+
+ // Reroll group initiative
+ html.find('.combat-button[data-control="reroll"]').click((ev) => {
+ game.combat.rollInitiative();
+ });
+
+ html.find('.combat-button[data-control="set-groups"]').click((ev) => {
+ OSECombatTab.GROUP_CONFIG_APP.render(true, { focus: true });
+ });
+ }
+
+ async #toggleFlag(combatant: OSECombatant, flag: string) {
+ const isActive = !!combatant.getFlag(game.system.id, flag);
+ await combatant.setFlag(game.system.id, flag, !isActive);
+ }
+
+ /**
+ * Handle a Combatant control toggle
+ * @private
+ * @param {Event} event The originating mousedown event
+ */
+ async _onCombatantControl(event: any) {
+ event.preventDefault();
+ event.stopPropagation();
+ const btn = event.currentTarget;
+ const li = btn.closest(".combatant");
+ const combat = this.viewed;
+ const c = combat.combatants.get(li.dataset.combatantId);
+
+ switch ( btn.dataset.control ) {
+ // Toggle combatant spellcasting flag
+ case "casting":
+ return this.#toggleFlag(c as OSECombatant, "prepareSpell");
+ // Toggle combatant retreating flag
+ case "retreat":
+ return this.#toggleFlag(c as OSECombatant, "moveInCombat");
+ // Fall back to the superclass's button events
+ default:
+ return super._onCombatantControl(event);
+ }
+ }
+
+ // ===========================================================================
+ // ADDITIONS TO THE COMBATANT CONTEXT MENU
+ // ===========================================================================
+
+ _getEntryContextOptions() {
+ const options = super._getEntryContextOptions();
+ return [
+ {
+ name: game.i18n.localize("OSE.combat.SetCombatantAsActive"),
+ icon: '',
+ callback: (li) => {
+ const combatantId = li.data('combatant-id')
+ const turnToActivate = this.viewed.turns.findIndex(t => t.id === combatantId);
+ this.viewed.activateCombatant(turnToActivate);
+ }
+ },
+ ...options
+ ];
+ }
+}
diff --git a/src/module/helpers-handlebars.ts b/src/module/helpers-handlebars.ts
index 9e3ab071..569562cc 100644
--- a/src/module/helpers-handlebars.ts
+++ b/src/module/helpers-handlebars.ts
@@ -73,6 +73,11 @@ const registerHelpers = async () => {
);
Handlebars.registerHelper("ceil", (val) => Math.ceil(val));
+
+ Handlebars.registerHelper(
+ 'partial',
+ (path) => `${OSE.systemPath()}/templates/${path}`
+ )
};
export default registerHelpers;
diff --git a/src/module/preloadTemplates.ts b/src/module/preloadTemplates.ts
index 348e736d..122f01d9 100644
--- a/src/module/preloadTemplates.ts
+++ b/src/module/preloadTemplates.ts
@@ -23,6 +23,11 @@ const preloadHandlebarsTemplates = async () => {
// Party Sheet
`${OSE.systemPath()}/templates/apps/party-sheet.html`,
// `${OSE.systemPath()}/templates/apps/party-xp.html`,
+ // Combat Tab
+ `${OSE.systemPath()}/templates/sidebar/combat-tracker.hbs`,
+ `${OSE.systemPath()}/templates/sidebar/combat-tracker-combatant-individual.hbs`,
+ `${OSE.systemPath()}/templates/sidebar/combat-tracker-combatant-group.hbs`,
+ `${OSE.systemPath()}/templates/apps/combat-set-groups.hbs`,
];
return loadTemplates(templatePaths);
};
diff --git a/src/module/settings.ts b/src/module/settings.ts
index 8ff8a884..6090df86 100644
--- a/src/module/settings.ts
+++ b/src/module/settings.ts
@@ -15,6 +15,7 @@ const registerSettings = () => {
default: "group",
scope: "world",
type: String,
+ requiresReload: true,
config: true,
choices: {
individual: "OSE.Setting.InitiativeIndividual",
diff --git a/src/ose.js b/src/ose.js
index 3c5af853..2ce71cf3 100644
--- a/src/ose.js
+++ b/src/ose.js
@@ -18,7 +18,6 @@ import OseItem from "./module/item/entity";
import OseItemSheet from "./module/item/item-sheet";
import * as chat from "./module/helpers-chat";
-import OseCombat from "./module/combat";
import OSE from "./module/config";
import registerFVTTModuleAPIs from "./module/fvttModuleAPIs";
import handlebarsHelpers from "./module/helpers-handlebars";
@@ -33,6 +32,14 @@ import * as treasure from "./module/helpers-treasure";
import "./e2e";
import polyfill from "./module/polyfill";
+// Combat
+import { OSEGroupCombat } from "./module/combat/combat-group";
+import { OSEGroupCombatant } from "./module/combat/combatant-group";
+import { OSECombat } from "./module/combat/combat";
+import { OSECombatant } from "./module/combat/combatant";
+import { OSECombatTab } from "./module/combat/sidebar";
+
+
polyfill();
@@ -41,22 +48,39 @@ polyfill();
/* -------------------------------------------- */
Hooks.once("init", async () => {
- /**
- * Set an initiative formula for the system
- *
- * @type {string}
- */
- CONFIG.Combat.initiative = {
- formula: "1d6 + @init",
- decimals: 2,
- };
+ // Give modules a chance to add encumbrance schemes
+ // They can do so by adding their encumbrance schemes
+ // to CONFIG.OSE.encumbranceOptions
+ Hooks.call("ose-setup-encumbrance");
CONFIG.OSE = OSE;
+ // if (game.system.id === 'ose-dev') {
+ CONFIG.debug = {
+ ...CONFIG.debug,
+ combat: true,
+ }
+ // }
+
+ // Register custom system settings
+ registerSettings();
+
+ const isGroupInitiative = game.settings.get(game.system.id, "initiative") === "group";
+ if (isGroupInitiative) {
+ CONFIG.Combat.documentClass = OSEGroupCombat;
+ CONFIG.Combatant.documentClass = OSEGroupCombatant;
+ CONFIG.Combat.initiative = { decimals: 2, formula: OSEGroupCombat.FORMULA }
+ } else {
+ CONFIG.Combat.documentClass = OSECombat;
+ CONFIG.Combatant.documentClass = OSECombatant;
+ CONFIG.Combat.initiative = { decimals: 2, formula: OSECombat.FORMULA }
+ }
+
+ CONFIG.ui.combat = OSECombatTab;
+
game.ose = {
rollItemMacro: macros.rollItemMacro,
rollTableMacro: macros.rollTableMacro,
- oseCombat: OseCombat,
};
// Init Party Sheet handler
@@ -65,14 +89,6 @@ Hooks.once("init", async () => {
// Custom Handlebars helpers
handlebarsHelpers();
- // Give modules a chance to add encumbrance schemes
- // They can do so by adding their encumbrance schemes
- // to CONFIG.OSE.encumbranceOptions
- Hooks.call("ose-setup-encumbrance");
-
- // Register custom system settings
- registerSettings();
-
// Register APIs of Foundry VTT Modules we explicitly support that provide custom hooks
registerFVTTModuleAPIs();
@@ -178,21 +194,6 @@ Hooks.on("renderSidebarTab", async (object, html) => {
}
});
-Hooks.on("preCreateCombatant", (combat, data, options, id) => {
- const init = game.settings.get(game.system.id, "initiative");
- if (init === "group") {
- OseCombat.addCombatant(combat, data, options, id);
- }
-});
-
-Hooks.on("updateCombatant", OseCombat.debounce(OseCombat.updateCombatant), 100);
-Hooks.on("renderCombatTracker", OseCombat.debounce(OseCombat.format, 100));
-Hooks.on("preUpdateCombat", OseCombat.preUpdateCombat);
-Hooks.on(
- "getCombatTrackerEntryContext",
- OseCombat.debounce(OseCombat.addContextEntry, 100)
-);
-
Hooks.on("renderChatLog", (app, html) => OseItem.chatListeners(html));
Hooks.on("getChatLogEntryContext", chat.addChatMessageContextOptions);
Hooks.on("renderChatMessage", chat.addChatMessageButtons);
diff --git a/src/scss/apps.scss b/src/scss/apps.scss
index b9f64b57..40fd8ea5 100644
--- a/src/scss/apps.scss
+++ b/src/scss/apps.scss
@@ -9,17 +9,20 @@
.attribute-list {
.form-fields {
flex: 0 0 50px;
+
input {
text-align: center;
font-weight: bold;
}
}
}
+
.roll-stats {
flex: 0 0 65px;
padding: 5px;
margin-left: 4px;
border-left: 1px solid $colorTan;
+
.form-group {
.form-fields {
span {
@@ -36,22 +39,27 @@
.item-list {
margin: 0;
padding: 0;
+
.item-entry {
list-style: none;
line-height: 30px;
+
.item-header {
padding: 0px;
margin-bottom: 8px;
height: 30px;
+
.item-image {
flex-basis: 30px;
flex-grow: 0;
background-size: contain;
background-repeat: no-repeat;
}
+
.item-name {
text-indent: 8px;
}
+
.field-short {
font-size: 12px;
flex-basis: 45px;
@@ -68,13 +76,16 @@
max-height: 250px;
padding: 0 4px 0;
overflow-y: auto;
+
input {
flex: 0 60px;
}
+
label {
white-space: nowrap;
overflow: hidden;
}
+
.share-tag {
flex: 0 35px;
text-align: center;
@@ -87,6 +98,7 @@
padding: 1px 4px;
}
}
+
.form-group {
button {
line-height: 18px;
@@ -98,13 +110,16 @@
.ose.dialog.party-sheet {
min-width: 250px;
min-height: 250px;
+
.window-content {
padding: 0;
}
+
#party-sheet {
display: flex;
flex-direction: column;
}
+
.header {
color: whitesmoke;
background: $darkBackground;
@@ -112,9 +127,11 @@
line-height: 20px;
text-align: left;
padding: 2px 10px;
+
.item-control {
padding: 0 2px;
}
+
button {
max-width: 25%;
line-height: 15px;
@@ -123,6 +140,7 @@
border: 1px solid #b5b3a4;
}
}
+
.body {
width: 100%;
flex-grow: 1;
@@ -147,41 +165,51 @@
overflow: auto;
list-style: none;
padding: 0;
+
.actor {
border-bottom: 1px solid $colorTan;
border-top: 1px solid $colorTan;
+
.fas {
padding: 0 2px;
font-size: 10px;
}
+
margin-bottom: 2px;
font-size: 12px;
text-align: center;
+
.fields .field-row {
white-space: nowrap;
+
&:nth-child(odd) {
background-color: rgba(0, 0, 0, 0.1);
}
}
+
.field-img {
flex: 0 0 50px;
position: relative;
+
&:hover {
.img-btns button {
display: block;
}
}
+
img {
border: none;
width: 45px;
height: 45px;
}
+
.img-btns {
position: absolute;
bottom: 6px;
left: 3px;
width: 45px;
height: 12px;
+
button {
display: none;
cursor: pointer;
@@ -199,6 +227,7 @@
}
}
}
+
.field-name {
overflow: hidden;
font-weight: 600;
@@ -213,6 +242,7 @@
text-align: center;
line-height: 20px;
}
+
input {
width: calc(100% - 45px);
}
@@ -223,6 +253,7 @@
label {
font-weight: bold;
}
+
ol {
list-style: outside;
}
@@ -244,20 +275,24 @@
padding: 5px 8px;
cursor: pointer;
filter: grayscale(1) opacity(0.5);
+
&.active,
&:hover {
filter: none;
}
}
}
+
@keyframes activated {
from {
background: none;
}
+
to {
background: rgba(0, 0, 0, 0.12);
}
}
+
.results {
.table-result.active {
animation: 0.7s infinite alternate activated;
@@ -273,6 +308,7 @@
.ose.chat-block {
margin: 0;
+
.chat-header {
height: 46px;
margin: 4px 0;
@@ -282,10 +318,12 @@
color: white;
padding: 2px;
box-shadow: 0 0 2px #fff inset;
+
.chat-title {
margin: 4px 0;
height: 30px;
overflow: hidden;
+
h2 {
border: none;
line-height: 34px;
@@ -295,32 +333,40 @@
word-break: break-all;
}
}
+
.chat-img {
flex: 0 0 42px;
background-size: cover;
}
}
+
.chat-target {
text-align: right;
font-style: italic;
padding: 2px;
}
+
.chat-details {
padding: 4px;
font-size: 13px;
}
+
.roll-result {
font-size: 13px;
text-align: center;
+
&.roll-success {
color: #18520b;
}
+
&.roll-fail {
color: #aa0200;
}
}
+
.damage-roll {
position: relative;
+
.dice-damage {
display: none;
position: absolute;
@@ -331,6 +377,7 @@
box-shadow: 0 0 2px #fff inset;
bottom: 1px;
right: 10px;
+
button {
padding: 2px 5px;
width: 22px;
@@ -339,6 +386,7 @@
cursor: pointer;
}
}
+
&:hover {
.dice-damage {
display: block;
@@ -367,6 +415,7 @@
margin: 0;
line-height: 36px;
color: $colorOlive;
+
&:hover {
color: #111;
}
@@ -377,16 +426,20 @@
.item-category-title {
line-height: 30px;
}
+
.item-list {
list-style: none;
margin: 0;
padding: 0;
+
.item-entry {
line-height: 30px;
+
.item-header {
padding: 0;
margin: 0 0 8px 0;
height: 30px;
+
.item-image {
padding: 0;
flex-basis: 30px;
@@ -394,14 +447,17 @@
background-size: contain;
background-repeat: no-repeat;
}
+
.item-name {
text-indent: 8px;
}
}
+
.item-summary {
margin: 0 0 0 8px;
padding: 0;
line-height: normal;
+
.tag {
list-style: none;
}
@@ -412,41 +468,49 @@
.card-content {
margin: 5px 0;
+
.treasure-list {
padding: 0;
list-style: none;
+
.treasure {
img {
flex: 0 0 36px;
border: none;
}
+
div {
text-indent: 10px;
font-size: 14px;
font-weight: bold;
}
+
line-height: 36px;
}
+
.sub {
padding-left: 25px;
line-height: 28px;
+
img {
flex: 0 0 28px;
border: none;
}
+
div {
text-indent: 10px;
font-size: 14px;
}
}
}
+
h3 {
font-size: 12px;
margin: 0;
font-weight: bold;
}
- > * {
+ >* {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
@@ -495,14 +559,17 @@
background: #c7d0c0;
border: 1px solid #006c00;
}
+
&.failure {
color: inherit;
background: #ffdddd;
border: 1px solid #6e0000;
}
+
&.critical {
color: green;
}
+
&.fumble {
color: red;
}
@@ -510,6 +577,7 @@
.app .compendium .directory-item {
position: relative;
+
.tag-list {
max-width: 260px;
overflow: hidden;
@@ -520,6 +588,7 @@
margin: 0;
list-style: none;
display: flex;
+
.tag {
pointer-events: none;
white-space: nowrap;
@@ -538,6 +607,7 @@
.sidebar-tab.directory .directory-list {
.directory-item {
position: relative;
+
.tag-list {
max-width: 200px;
overflow: hidden;
@@ -548,6 +618,7 @@
margin: 0;
list-style: none;
display: flex;
+
.tag {
pointer-events: none;
white-space: nowrap;
@@ -562,3 +633,128 @@
}
}
}
+
+.combat-set-groups {
+ container-name: combat-set-groups;
+ container-type: inline-size;
+
+ --grid: 1fr;
+
+ @container combat-set-groups (min-width: 80ch) {
+ ol {
+ --grid: repeat(2, 1fr);
+ }
+ }
+
+ @container combat-set-groups (min-width: 160ch) {
+ ol {
+ --grid: repeat(3, 1fr);
+ }
+ }
+
+ @container combat-set-groups (min-width: 240ch) {
+ ol {
+ --grid: repeat(4, 1fr);
+ }
+ }
+
+ .window-content {
+ overflow-y: scroll;
+ max-height: 700px;
+ }
+
+ ol {
+ list-style-type: none;
+ display: grid;
+ grid-template-columns: var(--grid);
+ gap: 0.75em 2em;
+ margin: 0;
+ padding: 0;
+ }
+
+ &__combatant {
+ display: grid;
+ grid-template-columns: 1fr fit-content;
+ align-items: center;
+ gap: 0.25em;
+ font-size: var(--font-size-16);
+
+ &__header {
+ display: flex;
+ gap: 0.5em;
+ align-items: center;
+ }
+
+ &__name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ img {
+ max-width: 32px;
+ height: auto;
+ }
+
+ &__group-options {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.5em 1em;
+
+ label.checkbox {
+ align-items: baseline;
+ opacity: 0.5;
+ display: block;
+ padding: 0.25em;
+ border-radius: 4px;
+ text-align: center;
+ background-color: var(--combat-set-option-bg);
+ color: var(--combat-set-option-font-color, white);
+ font-weight: bold;
+ cursor: pointer;
+ line-height: 1.5em;
+
+
+ input {
+ display: none;
+ }
+
+ &:has(input:checked) {
+ opacity: 1;
+ }
+
+
+ &:has(input[value="green"]) {
+ --combat-set-option-bg: green;
+ }
+
+ &:has(input[value="red"]) {
+ --combat-set-option-bg: red;
+ }
+
+ &:has(input[value="yellow"]) {
+ --combat-set-option-bg: yellow;
+ --combat-set-option-font-color: black;
+ }
+
+ &:has(input[value="purple"]) {
+ --combat-set-option-bg: purple;
+ }
+
+ &:has(input[value="blue"]) {
+ --combat-set-option-bg: blue;
+ }
+
+ &:has(input[value="orange"]) {
+ --combat-set-option-bg: orange;
+ --combat-set-option-font-color: black;
+ }
+
+ &:has(input[value="white"]) {
+ --combat-set-option-bg: var(--ose-group-color-white);
+ --combat-set-option-font-color: black;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/scss/variables.scss b/src/scss/variables.scss
index f9456ff5..3f0fc920 100644
--- a/src/scss/variables.scss
+++ b/src/scss/variables.scss
@@ -13,3 +13,8 @@ $colorCrimson: #44191a;
$borderGroove: 2px groove #eeede0;
$sheetBackground: whitesmoke;
$inputBackground: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
+
+:root {
+ --ose-group-color-slow: #1d3e59;
+ --ose-group-color-white: #92bbc9;
+}
\ No newline at end of file
diff --git a/src/templates/apps/combat-set-groups.hbs b/src/templates/apps/combat-set-groups.hbs
new file mode 100644
index 00000000..16ab4298
--- /dev/null
+++ b/src/templates/apps/combat-set-groups.hbs
@@ -0,0 +1,13 @@
+
+{{#each combatants}}
+-
+
+
+ {{radioBoxes this.id ../groups checked=this.groupRaw localize=true}}
+
+
+{{/each}}
+
diff --git a/src/templates/sidebar/combat-tracker-combatant-group.hbs b/src/templates/sidebar/combat-tracker-combatant-group.hbs
new file mode 100644
index 00000000..26812ae7
--- /dev/null
+++ b/src/templates/sidebar/combat-tracker-combatant-group.hbs
@@ -0,0 +1,78 @@
+
+
+
+
+
+{{#each this.turns}}
+ -
+
+
+
{{this.name}}
+
+ {{#if this.isOwnedByUser}}
+
+
+
+
+
+
+ {{/if}}
+ {{#if @root.user.isGM}}
+
+
+
+
+
+
+ {{/if}}
+ {{#if this.canPing}}
+
+
+
+ {{/if}}
+ {{#unless @root.user.isGM}}
+
+
+
+ {{/unless}}
+
+ {{#each this.effects}}
+
+ {{/each}}
+
+
+
+
+
+
+ {{#if this.defeated}}
+
+ {{else if this.hasResource}}
+ {{this.resource}}
+ {{/if}}
+
+
+ {{/each}}
+
+
diff --git a/src/templates/sidebar/combat-tracker-combatant-individual.hbs b/src/templates/sidebar/combat-tracker-combatant-individual.hbs
new file mode 100644
index 00000000..ae5f85ef
--- /dev/null
+++ b/src/templates/sidebar/combat-tracker-combatant-individual.hbs
@@ -0,0 +1,70 @@
+
+
+
+
{{this.name}}
+
+
+ {{#if this.isOwnedByUser}}
+
+
+
+
+
+
+ {{/if}}
+ {{#if @root.user.isGM}}
+
+
+
+
+
+
+ {{/if}}
+ {{#if this.canPing}}
+
+
+
+ {{/if}}
+ {{#unless @root.user.isGM}}
+
+
+
+ {{/unless}}
+
+ {{#each this.effects}}
+
+ {{/each}}
+
+
+
+
+ {{#if this.hasResource}}
+
+ {{this.resource}}
+
+ {{/if}}
+
+
+ {{#if this.defeated}}
+
+ {{else if this.hasRolled}}
+ {{#if this.isSlowed}}
+
+ {{else}}
+
{{this.initiative}}
+ {{/if}}
+ {{else if this.owner}}
+
+ {{/if}}
+
+
diff --git a/src/templates/sidebar/combat-tracker.hbs b/src/templates/sidebar/combat-tracker.hbs
new file mode 100644
index 00000000..782495d6
--- /dev/null
+++ b/src/templates/sidebar/combat-tracker.hbs
@@ -0,0 +1,101 @@
+
+
+
+
+ {{#if isGroupInitiative}}
+ {{#each groups}}
+ {{> (partial 'sidebar/combat-tracker-combatant-group.hbs') }}
+ {{/each}}
+ {{else}}
+ {{#each turns}}
+ {{> (partial 'sidebar/combat-tracker-combatant-individual.hbs') }}
+ {{/each}}
+ {{/if}}
+
+
+
+
diff --git a/system.json b/system.json
index e5c51a7f..6fa4acbd 100644
--- a/system.json
+++ b/system.json
@@ -4,11 +4,11 @@
"title": "Old-School Essentials Development Build",
"description": "Simple, robust rules compatible with decades of Basic/Expert material",
"version": "AUTOMATICALLY REPLACED BY GITHUB WORKFLOW ACTION",
- "minimumCoreVersion": "10",
+ "minimumCoreVersion": "12",
"compatibleCoreVersion": "12",
"compatibility": {
- "minimum": "10",
- "verified": "11",
+ "minimum": "12",
+ "verified": "12",
"maximum": "12"
},
"author": "Maintained by VTT Red with 18 contributors",