diff --git a/assets/turn-marker.png b/assets/turn-marker.png new file mode 100644 index 0000000..fa70749 Binary files /dev/null and b/assets/turn-marker.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 6fab321..aabd56c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,7 @@ export default [ languageOptions: { globals: { CONFIG: `writable`, + CONST: `readonly`, game: `readonly`, Handlebars: `readonly`, Hooks: `readonly`, @@ -31,6 +32,10 @@ export default [ renderTemplate: `readonly`, TextEditor: `readonly`, fromUuid: `readonly`, + Combat: `readonly`, + Combatant: `readonly`, + canvas: `readonly`, + Token: `readonly`, }, }, }, diff --git a/module/Apps/ActorSheets/HeroSummaryCardV1.mjs b/module/Apps/ActorSheets/HeroSummaryCardV1.mjs index 16b9895..0dc19bd 100644 --- a/module/Apps/ActorSheets/HeroSummaryCardV1.mjs +++ b/module/Apps/ActorSheets/HeroSummaryCardV1.mjs @@ -116,7 +116,7 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi ctx.fate.selected = ctx.actor.system.fate; ctx.fate.options = [ { label: `RipCrypt.common.empty`, v: `` }, - ...gameTerms.FatePath + ...Object.values(gameTerms.FatePath) .map(v => ({ label: `RipCrypt.common.path.${v}`, value: v })), ]; return ctx; diff --git a/module/Apps/sidebar/CombatTracker.mjs b/module/Apps/sidebar/CombatTracker.mjs new file mode 100644 index 0000000..8cffd77 --- /dev/null +++ b/module/Apps/sidebar/CombatTracker.mjs @@ -0,0 +1,37 @@ +const { CombatTracker } = foundry.applications.sidebar.tabs; + +export class RipCryptCombatTracker extends CombatTracker { + /** + * Changes the way the combat tracker renders combatant rows to account for + * multiple combatants being in the same combat "group", thus all going at the + * same time. + * + * @override + */ + async _prepareTurnContext(combat, combatant, index) { + const turn = await super._prepareTurnContext(combat, combatant, index); + + turn.hasDecimals = true; + turn.initiative = combatant.dynamicInitiative; + + const groupKey = combatant?.groupKey; + if (groupKey && combat.started) { + turn.active ||= combat.combatant?.groupKey === groupKey; + if (turn.active && !turn.css.includes(`active`)) { + turn.css += ` active`; + }; + }; + + return turn; + }; + + async _onRender(...args) { + await super._onRender(...args); + + // Purge the combat controls that I don't want to exist because they don't + // make sense in the system. + this.element?.querySelector(`[data-action="resetAll"]`)?.remove(); + this.element?.querySelector(`[data-action="rollNPC"]`)?.remove(); + this.element?.querySelector(`[data-action="rollAll"]`)?.remove(); + }; +}; diff --git a/module/data/Actor/Hero.mjs b/module/data/Actor/Hero.mjs index 6f2e3ed..ab5d3d5 100644 --- a/module/data/Actor/Hero.mjs +++ b/module/data/Actor/Hero.mjs @@ -90,7 +90,7 @@ export class HeroData extends foundry.abstract.TypeDataModel { trim: true, nullable: false, choices: () => { - return gameTerms.FatePath.concat(``); + return Object.values(gameTerms.FatePath).concat(``); }, }), level: new fields.SchemaField({ diff --git a/module/documents/combat.mjs b/module/documents/combat.mjs new file mode 100644 index 0000000..fa76361 --- /dev/null +++ b/module/documents/combat.mjs @@ -0,0 +1,139 @@ +/* +Resources: +- Combat : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combat.mjs +- Combatant : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combatant.mjs +- CombatTracker : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/applications/combat/combat-tracker.mjs +*/ + +export class RipCryptCombat extends Combat { + + get groups() { + let groups = new Map(); + + for (const combatant of this.combatants) { + const groupKey = combatant.groupKey; + if (!groupKey) { continue }; + + if (groups.has(groupKey)) { + groups.get(groupKey).push(combatant); + } else { + groups.set(groupKey, [combatant]); + }; + }; + + return groups; + }; + + /** + * @override + * Sorts combatants for the combat tracker in the following way: + * - Distance from the current fate ordinal. (0 -> 3) + * - Coin Flip result (if disposition matches flip result, then 0, otherwise, 0.5) + */ + _sortCombatants(a, b) { + const ia = Number.isNumeric(a.dynamicInitiative) ? a.dynamicInitiative : -Infinity; + const ib = Number.isNumeric(b.dynamicInitiative) ? b.dynamicInitiative : -Infinity; + + const delta = ia - ib; + if (Math.sign(delta) !== 0) { + return delta; + }; + + // fallback to alphabetical sort + return a.name < b.name ? -1 : 1; + }; + + async nextTurn() { + if (this.round === 0) {return this.nextRound()} + + const turn = this.turn ?? -1; + + const groupKey = this.turns[turn]?.groupKey; + + // Determine the next turn number + let nextTurn = null; + for (let i = turn + 1; i < this.turns.length; i++) { + const combatant = this.turns[i]; + if (combatant.groupKey !== groupKey) { + nextTurn = i; + break; + }; + }; + + // Maybe advance to the next round + if ((nextTurn === null) || (nextTurn >= this.turns.length)) {return this.nextRound()} + + const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, nextTurn); + + // Update the document, passing data through a hook first + const updateData = {round: this.round, turn: nextTurn}; + const updateOptions = {direction: 1, worldTime: {delta: advanceTime}}; + Hooks.callAll(`combatTurn`, this, updateData, updateOptions); + await this.update(updateData, updateOptions); + return this; + }; + + async previousTurn() { + if (this.round === 0) { return this } + if ((this.turn === 0) || (this.turns.length === 0)) {return this.previousRound()} + + const currentTurn = (this.turn ?? this.turns.length) - 1; + let previousTurn = null; + const groupKey = this.combatant.groupKey; + for (let i = currentTurn; i >= 0; i--) { + const combatant = this.turns[i]; + if (combatant.groupKey !== groupKey) { + previousTurn = i; + break; + } + } + + if (previousTurn < 0) { + if (this.round === 1) { + this.round = 0; + return this; + }; + return this.previousRound(); + } + + const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, previousTurn); + + // Update the document, passing data through a hook first + const updateData = {round: this.round, turn: previousTurn}; + const updateOptions = {direction: -1, worldTime: {delta: advanceTime}}; + Hooks.callAll(`combatTurn`, this, updateData, updateOptions); + await this.update(updateData, updateOptions); + return this; + }; + + /** + * Overridden to make it so that there can be multiple tokens with turn markers + * visible at the same time. + * + * @protected + * @internal + * @override + */ + _updateTurnMarkers() { + if (!canvas.ready) { return }; + + const tokenGroup = this.combatant?.groupKey; + for (const token of canvas.tokens.turnMarkers) { + const actor = token.actor ?? token.baseActor; + if (actor?.groupKey !== tokenGroup) { + token.renderFlags.set({refreshTurnMarker: true}); + } + } + + if (!this.active) { return }; + const currentToken = this.combatant?.token?._object; + if (!tokenGroup && currentToken) { + currentToken.renderFlags.set({refreshTurnMarker: true}); + } else { + const group = this.groups.get(tokenGroup) ?? []; + for (const combatant of group) { + combatant.token?._object?.renderFlags.set({ refreshTurnMarker: true }); + } + } + } +}; diff --git a/module/documents/combatant.mjs b/module/documents/combatant.mjs new file mode 100644 index 0000000..6de765b --- /dev/null +++ b/module/documents/combatant.mjs @@ -0,0 +1,70 @@ +import { distanceBetweenFates } from "../utils/distanceBetweenFates.mjs"; + +export class RipCryptCombatant extends Combatant { + + get disposition() { + switch (this.token?.disposition) { + case CONST.TOKEN_DISPOSITIONS.HOSTILE: + return `hostile`; + case CONST.TOKEN_DISPOSITIONS.FRIENDLY: + return `friendly`; + }; + return `unknown`; + }; + + /** + * Used by the Combat tracker to order combatants according to their + * fate path and the coin flip. + */ + get dynamicInitiative() { + let total = 0; + + const start = game.settings.get(`ripcrypt`, `currentFate`); + const end = this.actor?.system?.fate || this.baseActor?.system?.fate; + total += distanceBetweenFates(start, end); + + const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`); + if (whoFirst) { + const disposition = this.disposition; + if (disposition === `unknown`) { + total += 0.25; + } else if (whoFirst !== disposition) { + total += 0.5; + }; + }; + + return total; + }; + + get groupKey() { + const path = this.token?.actor?.system?.fate; + + // Disallow grouping things that don't have a fate path + if (!path) { return null }; + + // Token Disposition (group into: friendlies, unknown, hostiles) + let disposition = this.disposition; + + return `${path}:${disposition}`; + }; + + /** + * Used to create the turn marker when the combatant is added if they're in + * the group whose turn it is. + * + * @override + */ + _onCreate() { + this.token?._object?._refreshTurnMarker(); + }; + + /** + * Used to remove the turn marker when the combatant is removed from combat + * if they had it visible so that it doesn't stick around infinitely. + * + * @override + */ + _onDelete() { + this.token?._object?._refreshTurnMarker(); + }; +}; diff --git a/module/documents/token.mjs b/module/documents/token.mjs new file mode 100644 index 0000000..458230e --- /dev/null +++ b/module/documents/token.mjs @@ -0,0 +1,34 @@ +const { TokenTurnMarker } = foundry.canvas.placeables.tokens; + +export class RipCryptToken extends Token { + /** + * Overridden using a slightly modified implementation in order to make it so + * that the turn marker shows up on tokens if they're in the same group as the + * currently active combatant + * + * @override + */ + _refreshTurnMarker() { + // Should a Turn Marker be active? + const {turnMarker} = this.document; + const markersEnabled = CONFIG.Combat.settings.turnMarker.enabled + && (turnMarker.mode !== CONST.TOKEN_TURN_MARKER_MODES.DISABLED); + const combatant = game.combat?.active ? game.combat.combatant : null; + const isTurn = combatant && (combatant.groupKey === this.combatant?.groupKey); + const isDefeated = combatant && combatant.isDefeated; + const markerActive = markersEnabled && isTurn && !isDefeated; + + // Activate a Turn Marker + if (markerActive) { + if (!this.turnMarker) { + this.turnMarker = this.addChildAt(new TokenTurnMarker(this), 0); + }; + canvas.tokens.turnMarkers.add(this); + this.turnMarker.draw(); + } else if (this.turnMarker) { + canvas.tokens.turnMarkers.delete(this); + this.turnMarker.destroy(); + this.turnMarker = null; + } + } +}; diff --git a/module/gameTerms.mjs b/module/gameTerms.mjs index de258d9..3e3bfff 100644 --- a/module/gameTerms.mjs +++ b/module/gameTerms.mjs @@ -11,12 +11,12 @@ export const gameTerms = Object.preventExtensions({ FLECT: `flect`, FRACT: `fract`, }), - FatePath: [ - `North`, - `East`, - `South`, - `West`, - ], + FatePath: Object.freeze({ + NORTH: `North`, + EAST: `East`, + SOUTH: `South`, + WEST: `West`, + }), Access: [ `Common`, `Uncommon`, diff --git a/module/hooks/init.mjs b/module/hooks/init.mjs index 50d4a6b..35c434d 100644 --- a/module/hooks/init.mjs +++ b/module/hooks/init.mjs @@ -4,6 +4,7 @@ import { CombinedHeroSheet } from "../Apps/ActorSheets/CombinedHeroSheet.mjs"; import { DelveTourApp } from "../Apps/DelveTourApp.mjs"; import { HeroSkillsCardV1 } from "../Apps/ActorSheets/HeroSkillsCardV1.mjs"; import { HeroSummaryCardV1 } from "../Apps/ActorSheets/HeroSummaryCardV1.mjs"; +import { RipCryptCombatTracker } from "../Apps/sidebar/CombatTracker.mjs"; // Data Models import { AmmoData } from "../data/Item/Ammo.mjs"; @@ -18,7 +19,10 @@ import { WeaponData } from "../data/Item/Weapon.mjs"; import { CryptDie } from "../dice/CryptDie.mjs"; // Documents +import { RipCryptCombat } from "../documents/combat.mjs"; +import { RipCryptCombatant } from "../documents/combatant.mjs"; import { RipCryptItem } from "../documents/item.mjs"; +import { RipCryptToken } from "../documents/token.mjs"; // Misc import helpers from "../handlebarHelpers/_index.mjs"; @@ -32,6 +36,7 @@ import { registerWorldSettings } from "../settings/worldSettings.mjs"; Hooks.once(`init`, () => { Logger.log(`Initializing`); + CONFIG.Combat.initiative.decimals = 2; CONFIG.ui.crypt = DelveTourApp; // #region Settings @@ -53,6 +58,10 @@ Hooks.once(`init`, () => { // #endregion // #region Class Changes + CONFIG.ui.combat = RipCryptCombatTracker; + CONFIG.Combat.documentClass = RipCryptCombat; + CONFIG.Combatant.documentClass = RipCryptCombatant; + CONFIG.Token.objectClass = RipCryptToken; CONFIG.Item.documentClass = RipCryptItem; CONFIG.Dice.terms.d = CryptDie; // #endregion @@ -94,6 +103,7 @@ Hooks.once(`init`, () => { // #region Token Attrs CONFIG.Actor.trackableAttributes.hero = HeroData.trackableAttributes; + // #endregion registerCustomComponents(); Handlebars.registerHelper(helpers); diff --git a/module/hooks/ready.mjs b/module/hooks/ready.mjs index 919f869..c71bc25 100644 --- a/module/hooks/ready.mjs +++ b/module/hooks/ready.mjs @@ -1,3 +1,4 @@ +import { filePath } from "../consts.mjs"; import { Logger } from "../utils/Logger.mjs"; Hooks.once(`ready`, () => { @@ -21,4 +22,15 @@ Hooks.once(`ready`, () => { if (game.settings.get(`ripcrypt`, `showDelveTour`)) { ui.crypt.render({ force: true }); }; + + // MARK: 1-time updates + if (!game.settings.get(`ripcrypt`, `firstLoadFinished`)) { + // Update the turnMarker to be the RipCrypt defaults + const combatConfig = game.settings.get(`core`, `combatTrackerConfig`); + combatConfig.turnMarker.src = filePath(`assets/turn-marker.png`); + combatConfig.turnMarker.animation = `spinPulse`; + game.settings.set(`core`, `combatTrackerConfig`, combatConfig); + } + + game.settings.set(`ripcrypt`, `firstLoadFinished`, true); }); diff --git a/module/settings/metaSettings.mjs b/module/settings/metaSettings.mjs index 57580b9..7694088 100644 --- a/module/settings/metaSettings.mjs +++ b/module/settings/metaSettings.mjs @@ -8,4 +8,35 @@ export function registerMetaSettings() { ui.crypt.render({ parts: [ `delveConditions` ]}); }, }); + + game.settings.register(`ripcrypt`, `currentFate`, { + scope: `world`, + type: String, + config: false, + requiresReload: false, + onChange: async () => { + await ui.crypt.render({ parts: [ `fate` ] }); + await game.combat.setupTurns(); + await ui.combat.render({ parts: [ `tracker` ] }); + }, + }); + + game.settings.register(`ripcrypt`, `whoFirst`, { + scope: `world`, + type: String, + config: false, + requiresReload: false, + initial: `friendly`, + onChange: async () => { + await game.combat.setupTurns(); + await ui.combat.render({ parts: [ `tracker` ] }); + }, + }); + + game.settings.register(`ripcrypt`, `firstLoadFinished`, { + scope: `world`, + type: Boolean, + initial: false, + requiresReload: false, + }); }; diff --git a/module/utils/Logger.mjs b/module/utils/Logger.mjs index fc1b51c..70c6481 100644 --- a/module/utils/Logger.mjs +++ b/module/utils/Logger.mjs @@ -15,7 +15,7 @@ const augmentedProps = new Set([ export const Logger = new Proxy(console, { get(target, prop, _receiver) { if (augmentedProps.has(prop)) { - return (...args) => target[prop](game.system.id, `|`, ...args); + return target[prop].bind(target, game.system.id, `|`); }; return target[prop]; }, diff --git a/module/utils/distanceBetweenFates.mjs b/module/utils/distanceBetweenFates.mjs new file mode 100644 index 0000000..4eaaddd --- /dev/null +++ b/module/utils/distanceBetweenFates.mjs @@ -0,0 +1,27 @@ +import { gameTerms } from "../gameTerms.mjs"; +import { Logger } from "./Logger.mjs"; + +const { FatePath } = gameTerms; + +export function isOppositeFates(a, b) { + return (a === FatePath.NORTH && b === FatePath.SOUTH) + || (a === FatePath.EAST && b === FatePath.WEST); +}; + +export function distanceBetweenFates(start, end) { + if (!start || !end) { + Logger.error(`Start and End must both have a defined value, given`, {start, end}); + return undefined; + }; + + if (isOppositeFates(start, end)) { + return 2; + }; + + let isForward = start === FatePath.SOUTH && end === FatePath.WEST; + isForward ||= start === FatePath.NORTH && end === FatePath.EAST; + if (isForward) { + return 1; + }; + return 3; +};