diff --git a/src/declarations/foundry/common/documents/combat.d.ts b/src/declarations/foundry/common/documents/combat.d.ts new file mode 100644 index 00000000..619aa8ff --- /dev/null +++ b/src/declarations/foundry/common/documents/combat.d.ts @@ -0,0 +1,9 @@ +namespace foundry { + namespace documents { + declare class BaseCombat< + Schema extends + foundry.abstract.DataSchema = foundry.abstract.DataSchema, + Parent extends Document | null = null, + > extends foundry.abstract.Document {} + } +} diff --git a/src/declarations/foundry/common/documents/combatant.d.ts b/src/declarations/foundry/common/documents/combatant.d.ts new file mode 100644 index 00000000..0023d22a --- /dev/null +++ b/src/declarations/foundry/common/documents/combatant.d.ts @@ -0,0 +1,9 @@ +namespace foundry { + namespace documents { + declare class BaseCombatant< + Schema extends + foundry.abstract.DataSchema = foundry.abstract.DataSchema, + Parent extends Document | null = null, + > extends foundry.abstract.Document {} + } +} diff --git a/src/index.ts b/src/index.ts index 16d5a231..02d0c500 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,10 @@ Hooks.once('init', async () => { CONFIG.Item.dataModels = dataModels.item.config; CONFIG.Item.documentClass = documents.CosmereItem; + CONFIG.Combat.documentClass = documents.CosmereCombat; + CONFIG.Combatant.documentClass = documents.CosmereCombatant; + CONFIG.ui.combat = applications.combat.CosmereCombatTracker; + Actors.unregisterSheet('core', ActorSheet); Actors.registerSheet('cosmere-rpg', applications.actor.CharacterSheet, { types: ['character'], diff --git a/src/lang/en.json b/src/lang/en.json index e857c7c0..1e8e34ce 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -14,7 +14,7 @@ "placeholder": "[Path]" }, "Specialties": { - "placeholder": "[Specialty]" + "placeholder": "[Specialty]" }, "Expertise": { "name": "Expertise", @@ -352,7 +352,16 @@ "Vital": "Vital", "Healing": "Healing" }, - "Currencies": {} + "Currencies": {}, + "Combat": { + "FastPlayers": "Fast Characters", + "SlowPlayers": "Slow Characters", + "FastAdversaries": "Fast Adversaries", + "SlowAdversaries": "Slow Adversaries", + "ToggleTurn": "Toggle Turn Speed", + "ResetActivation": "Reset Activation", + "Activate": "Mark as having acted" + } }, "DICE": { "RollMode": "Roll Mode", diff --git a/src/style.scss b/src/style.scss index 1f946d34..4485697f 100644 --- a/src/style.scss +++ b/src/style.scss @@ -1,6 +1,7 @@ @import './style/sheets/module.scss'; -// @import './style/chat/module.scss'; +@import './style/sidebar/combat.scss'; +// @import './style/chat/module.scss'; /* ----------------------------------------- */ /* Globals */ /* ----------------------------------------- */ @@ -34,10 +35,10 @@ @font-face { font-family: 'cosmere-dingbats'; - src: url("https://dl.dropboxusercontent.com/scl/fi/9909gen4fd0oveyzfposx/CosmereDingbats-Regular.otf?rlkey=ig6odq9hxyo1st8kt3ujp1czz&st=72qrads3&raw=1") + src: url('https://dl.dropboxusercontent.com/scl/fi/9909gen4fd0oveyzfposx/CosmereDingbats-Regular.otf?rlkey=ig6odq9hxyo1st8kt3ujp1czz&st=72qrads3&raw=1'); } i.cosmere-icon { font-family: 'cosmere-dingbats'; font-style: normal; -} \ No newline at end of file +} diff --git a/src/style/sidebar/combat.scss b/src/style/sidebar/combat.scss new file mode 100644 index 00000000..04c49861 --- /dev/null +++ b/src/style/sidebar/combat.scss @@ -0,0 +1,35 @@ +.cosmere-combatant-buttons { + display: flex; + text-align: center; + justify-content: flex-end; +} + +.cosmere-turn-speed-control, +.cosmere-activate-control { + display: block; + width: 40px; + height: var(--sidebar-item-height); + background-size: 32px; + background-position: center; + background-repeat: no-repeat; + margin: 0 0.1em; +} + +.cosmere-turn-speed-control { + background-image: url(/icons/svg/clockwork.svg); +} + +.cosmere-activate-control { + background-image: url(/icons/svg/combat.svg); +} + +.cosmere-activate-control-activated { + filter: brightness(40%); +} + +.combat-phase { + padding: 0.5rem 0px; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/system/applications/combat/combat-tracker.ts b/src/system/applications/combat/combat-tracker.ts new file mode 100644 index 00000000..f57074fc --- /dev/null +++ b/src/system/applications/combat/combat-tracker.ts @@ -0,0 +1,190 @@ +import { ActorType, TurnSpeed } from '@src/system/types/cosmere'; +import { CosmereCombatant } from '@src/system/documents/combatant'; + +/** + * Overrides default tracker template to implement slow/fast buckets and combatant activation button. + */ +export class CosmereCombatTracker extends CombatTracker { + // Note: lint rules wants this to be exposed as a readonly field, but base class implements a getter. + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + override get template() { + return 'systems/cosmere-rpg/templates/combat/combat-tracker.hbs'; + } + + /** + * modifies data being sent to the combat tracker template to add turn speed, type and activation status and splitting turns between the initiative phases. + */ + override async getData( + options?: Partial | undefined, + ): Promise { + const data = (await super.getData(options)) as { + turns: CosmereTurn[]; + fastPlayers: CosmereTurn[]; + slowPlayers: CosmereTurn[]; + fastNPC: CosmereTurn[]; + slowNPC: CosmereTurn[]; + }; + //add combatant type, speed, and activation status to existing turn data. + data.turns = data.turns.map((turn) => { + const combatant: CosmereCombatant = + this.viewed!.getEmbeddedDocument( + 'Combatant', + turn.id, + {}, + ) as CosmereCombatant; + const newTurn: CosmereTurn = { + ...turn, + turnSpeed: combatant.getFlag( + 'cosmere-rpg', + 'turnSpeed', + ) as TurnSpeed, + type: combatant.actor.type, + activated: combatant.getFlag( + 'cosmere-rpg', + 'activated', + ) as boolean, + }; + //strips active player formatting + newTurn.css = ''; + return newTurn; + }); + + //split turn data into individual turn "buckets" to separate them in the combat tracker ui + data.fastPlayers = data.turns.filter((turn) => { + return ( + turn.type === ActorType.Character && + turn.turnSpeed === TurnSpeed.Fast + ); + }); + data.slowPlayers = data.turns.filter((turn) => { + return ( + turn.type === ActorType.Character && + turn.turnSpeed === TurnSpeed.Slow + ); + }); + data.fastNPC = data.turns.filter((turn) => { + return ( + turn.type === ActorType.Adversary && + turn.turnSpeed === TurnSpeed.Fast + ); + }); + data.slowNPC = data.turns.filter((turn) => { + return ( + turn.type === ActorType.Adversary && + turn.turnSpeed === TurnSpeed.Slow + ); + }); + + return data; + } + + /** + * add listeners to toggleTurnSpeed and activation buttons + */ + override activateListeners(html: JQuery): void { + super.activateListeners(html); + html.find(`[data-control='toggleSpeed']`).on( + 'click', + this._onClickToggleTurnSpeed.bind(this), + ); + html.find(`[data-control='activateCombatant']`).on( + 'click', + this._onActivateCombatant.bind(this), + ); + } + + /** + * toggles combatant turn speed on clicking the "fast/slow" button on the combat tracker window + * */ + protected _onClickToggleTurnSpeed(event: Event) { + event.preventDefault(); + event.stopPropagation(); + const btn = event.currentTarget as HTMLElement; + const li = btn.closest('.combatant')!; + const combatant: CosmereCombatant = this.viewed!.getEmbeddedDocument( + 'Combatant', + li.dataset.combatantId!, + {}, + ) as CosmereCombatant; + void combatant.toggleTurnSpeed(); + } + + /** + * activates the combatant when clicking the activation button + */ + protected _onActivateCombatant(event: Event) { + event.preventDefault(); + event.stopPropagation(); + const btn = event.currentTarget as HTMLElement; + const li = btn.closest('.combatant')!; + const combatant: CosmereCombatant = this.viewed!.getEmbeddedDocument( + 'Combatant', + li.dataset.combatantId!, + {}, + ) as CosmereCombatant; + void combatant.setFlag('cosmere-rpg', 'activated', true); + } + + /** + * toggles combatant turn speed on clicking the "fast/slow" option in the turn tracker context menu + */ + protected _onContextToggleTurnSpeed(li: JQuery) { + const combatant: CosmereCombatant = this.viewed!.getEmbeddedDocument( + 'Combatant', + li.data('combatant-id') as string, + {}, + ) as CosmereCombatant; + combatant.toggleTurnSpeed(); + } + + /** + * resets combatants activation status to hasn't activated + */ + protected _onContextResetActivation(li: JQuery) { + const combatant: CosmereCombatant = this.viewed!.getEmbeddedDocument( + 'Combatant', + li.data('combatant-id') as string, + {}, + ) as CosmereCombatant; + void combatant.setFlag('cosmere-rpg', 'activated', false); + } + + /** + * Overwrites combatants context menu options, adding toggle turn speed and reset activation options. Removes initiative rolling options from base implementation. + */ + _getEntryContextOptions(): ContextMenuEntry[] { + const menu: ContextMenuEntry[] = [ + { + name: 'COSMERE.Combat.ToggleTurn', + icon: '', + callback: this._onContextToggleTurnSpeed.bind(this), + }, + { + name: 'COSMERE.Combat.ResetActivation', + icon: '', + callback: this._onContextResetActivation.bind(this), + }, + ]; + //pushes existing context menu options, filtering out the initiative reroll and initiative clear options + menu.push( + ...super + ._getEntryContextOptions() + .filter( + (i) => + i.name !== 'COMBAT.CombatantReroll' && + i.name !== 'COMBAT.CombatantClear', + ), + ); + return menu; + } +} + +interface CosmereTurn { + id: string; + css: string; + pending: number; + finished: number; + type?: ActorType; + turnSpeed?: TurnSpeed; + activated?: boolean; +} diff --git a/src/system/applications/combat/index.ts b/src/system/applications/combat/index.ts new file mode 100644 index 00000000..3b4ffe51 --- /dev/null +++ b/src/system/applications/combat/index.ts @@ -0,0 +1 @@ +export * from './combat-tracker'; diff --git a/src/system/applications/index.ts b/src/system/applications/index.ts index 68e5df89..3830d2cf 100644 --- a/src/system/applications/index.ts +++ b/src/system/applications/index.ts @@ -1 +1,2 @@ export * as actor from './actor'; +export * as combat from './combat'; diff --git a/src/system/documents/actor.ts b/src/system/documents/actor.ts index f39675c2..8a3b8abd 100644 --- a/src/system/documents/actor.ts +++ b/src/system/documents/actor.ts @@ -1,4 +1,4 @@ -import { Skill, Attribute } from '@system/types/cosmere'; +import { Skill, Attribute, ActorType } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; import { CommonActorDataModel } from '@system/data/actor/common'; import { CharacterActorDataModel } from '@system/data/actor/character'; @@ -36,6 +36,10 @@ interface RollSkillOptions { export class CosmereActor< T extends CommonActorDataModel = CommonActorDataModel, > extends Actor { + // Redeclare `actor.type` to specifically be of `ActorType`. + // This way we avoid casting everytime we want to check/use its type + declare type: ActorType; + /** * Utility function to get the modifier for a given skill for this actor. * @param skill The skill to get the modifier for diff --git a/src/system/documents/combat.ts b/src/system/documents/combat.ts new file mode 100644 index 00000000..6724fb1e --- /dev/null +++ b/src/system/documents/combat.ts @@ -0,0 +1,22 @@ +export class CosmereCombat extends Combat { + //sets all defeated combatants activation status to true (already activated), and all others to false(hasn't activated yet) + resetActivations() { + for (const combatant of this.turns) { + void combatant.setFlag( + 'cosmere-rpg', + 'activated', + combatant.isDefeated ? true : false, + ); + } + } + + override async startCombat(): Promise { + this.resetActivations(); + return super.startCombat(); + } + + override async nextRound(): Promise { + this.resetActivations(); + return super.nextRound(); + } +} diff --git a/src/system/documents/combatant.ts b/src/system/documents/combatant.ts new file mode 100644 index 00000000..2ad66b09 --- /dev/null +++ b/src/system/documents/combatant.ts @@ -0,0 +1,56 @@ +import { DocumentModificationOptions } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/document.mjs'; +import { SchemaField } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/fields.mjs'; +import { CosmereActor } from './actor'; +import { ActorType, TurnSpeed } from '@src/system/types/cosmere'; + +export class CosmereCombatant extends Combatant { + override get actor(): CosmereActor { + return super.actor as CosmereActor; + } + + /** + * on creation, combatants turn speed is set to slow, activation status to false, and then sets the initiative, bypassing the need to roll + */ + protected override _onCreate( + data: SchemaField.InnerAssignmentType, + options: DocumentModificationOptions, + userID: string, + ) { + super._onCreate(data, options, userID); + void this.setFlag('cosmere-rpg', 'turnSpeed', TurnSpeed.Slow); + void this.setFlag('cosmere-rpg', 'activated', false); + void this.combat?.setInitiative( + this.id!, + this.generateInitiative(this.actor.type, TurnSpeed.Slow), + ); + } + + /** + * Utility function to generate initiative without rolling + * @param type The actor type so that npc's will come after player characters + * @param speed Whether the combatants is set to take a slow or fast turn + */ + generateInitiative(type: ActorType, speed: TurnSpeed): number { + let initiative = this.actor.system.attributes.spd.value; + if (type === ActorType.Character) initiative += 500; + if (speed === TurnSpeed.Fast) initiative += 1000; + return initiative; + } + + /** + * Utility function to flip the combatants current turn speed between slow and fast. It then updates initiative to force an update of the combat-tracker ui + */ + toggleTurnSpeed() { + const currentSpeed = this.getFlag( + 'cosmere-rpg', + 'turnSpeed', + ) as TurnSpeed; + const newSpeed = + currentSpeed === TurnSpeed.Slow ? TurnSpeed.Fast : TurnSpeed.Slow; + void this.setFlag('cosmere-rpg', 'turnSpeed', newSpeed); + void this.combat?.setInitiative( + this.id!, + this.generateInitiative(this.actor.type, newSpeed), + ); + } +} diff --git a/src/system/documents/index.ts b/src/system/documents/index.ts index 399742ba..bf795a1a 100644 --- a/src/system/documents/index.ts +++ b/src/system/documents/index.ts @@ -1,2 +1,4 @@ export * from './actor'; export * from './item'; +export * from './combat'; +export * from './combatant'; diff --git a/src/system/types/cosmere.ts b/src/system/types/cosmere.ts index 7b6bdc5b..bb173236 100644 --- a/src/system/types/cosmere.ts +++ b/src/system/types/cosmere.ts @@ -251,3 +251,8 @@ export const enum ItemType { Injury = 'injury', } + +export const enum TurnSpeed { + Fast = 'fast', + Slow = 'slow', +} diff --git a/src/system/util/handlebars/index.ts b/src/system/util/handlebars/index.ts index 3fe6a4b7..ed0b8dc2 100644 --- a/src/system/util/handlebars/index.ts +++ b/src/system/util/handlebars/index.ts @@ -432,6 +432,7 @@ export async function preloadHandlebarsTemplates() { 'systems/cosmere-rpg/templates/actors/parts/actions.hbs', 'systems/cosmere-rpg/templates/actors/parts/inventory.hbs', // 'systems/cosmere-rpg/templates/chat/parts/roll-details.hbs', + 'systems/cosmere-rpg/templates/combat/combatant.hbs', ]; return await loadTemplates( diff --git a/src/templates/combat/combat-tracker.hbs b/src/templates/combat/combat-tracker.hbs new file mode 100644 index 00000000..7d241488 --- /dev/null +++ b/src/templates/combat/combat-tracker.hbs @@ -0,0 +1,99 @@ +
+
+ {{#if user.isGM}} + + {{/if}} + +
+ {{#if user.isGM}} + {{/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 fastPlayers}} +
  1. {{localize 'COSMERE.Combat.FastPlayers'}}

  2. + {{#each fastPlayers}} + {{> combatant this}} + {{/each}} + {{/if}} + {{#if fastNPC}} +
  3. {{localize 'COSMERE.Combat.FastAdversaries'}}

  4. + {{#each fastNPC}} + {{> combatant}} + {{/each}} + {{/if}} + {{#if slowPlayers}} +
  5. {{localize 'COSMERE.Combat.SlowPlayers'}}

  6. + {{#each slowPlayers}} + {{> combatant}} + {{/each}} + {{/if}} + {{#if slowNPC}} +
  7. {{localize 'COSMERE.Combat.SlowAdversaries'}}

  8. + {{#each slowNPC}} + {{> combatant}} + {{/each}} + {{/if}} +
+ + +
\ No newline at end of file diff --git a/src/templates/combat/combatant.hbs b/src/templates/combat/combatant.hbs new file mode 100644 index 00000000..2f47d436 --- /dev/null +++ b/src/templates/combat/combatant.hbs @@ -0,0 +1,48 @@ +
  • + {{this.name}} +
    +

    {{this.name}}

    +
    + {{#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.owner}} + + {{/if}} + +
    +
  • \ No newline at end of file