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.formula} + ${roll.total} +
+
    + ${roll.results.map(r => ` +
  1. ${r.result}
  2. + `).join("\n")} +
+
+
+
+

${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}} +
  1. + + {{this.name}} + {{this.name}} + +
    + {{radioBoxes this.id ../groups checked=this.groupRaw localize=true}} +
    +
  2. +{{/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 @@ +
  • +
    +

    {{localize this.label}}

    + + + + {{#if (eq this.group "slow")}} + + {{else}} + {{this.initiative}} + {{/if}} + +
    + +
      + +{{#each this.turns}} +
    1. + {{this.name}} +
      +

      {{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}} +
      +
    2. + {{/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}} +
    +

    {{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 user.isGM}} + + {{/if}} + +
    + {{#if user.isGM}} + {{#if isGroupInitiative}} + + + + + + + {{else}} + + + + + + + {{/if}} + {{/if}} + + {{#if combatCount}} + {{#if combat.round}} +

    {{localize 'COMBAT.Round'}} {{combat.round}}

    + {{else}} +

    {{localize 'COMBAT.NotStarted'}}

    + {{/if}} + {{else}} +

    {{localize "COMBAT.None"}}

    + {{/if}} + + {{#if user.isGM}} + + + + + + + {{/if}} + + + +
    +
    + +
      + {{#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",