diff --git a/rollup.config.js b/rollup.config.js index 4befb132..1358f83f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -27,21 +27,10 @@ export default { copy({ targets: [ { src: 'src/system.json', dest: 'build' }, - { src: 'src/templates/*.hbs', dest: 'build/templates/' }, - { - src: 'src/templates/actors/*.hbs', - dest: 'build/templates/actors/', - }, - { - src: 'src/templates/roll/*.hbs', - dest: 'build/templates/roll/', - }, - { - src: 'src/templates/item/dialog/*.hbs', - dest: 'build/templates/item/dialog/', - }, - { src: 'src/lang/*.json', dest: 'build/lang/' } + { src: 'src/templates/**/*.hbs', dest: 'build/' }, + { src: 'src/lang/*.json', dest: 'build/' } ], + flatten: false }), ], }; diff --git a/src/declarations/foundry/client/data/abstract/client-document.d.ts b/src/declarations/foundry/client/data/abstract/client-document.d.ts index 75a138bc..548eeedd 100644 --- a/src/declarations/foundry/client/data/abstract/client-document.d.ts +++ b/src/declarations/foundry/client/data/abstract/client-document.d.ts @@ -1,15 +1,20 @@ -type Mixin< +declare type Mixin< MixinClass extends new (...args: any[]) => any, BaseClass extends abstract new (...args: any[]) => any, -> = MixinClass & BaseClass; +> = BaseClass & MixinClass; declare function _ClientDocumentMixin< Schema extends foundry.abstract.DataModel = foundry.abstract.DataModel, Parent extends foundry.abstract.Document | null = null, BaseClass extends typeof foundry.abstract.Document, ->(base: BaseClass): Mixin; +>(base: BaseClass): Mixin; + +declare class _ClientDocument { + /** + * Lazily obtain a FormApplication instance used to configure this Document, or null if no sheet is available. + */ + get sheet(): Application | foundry.applications.api.ApplicationV2 | null; -declare class ClientDocument { /** * Apply transformations of derivations to the values of the source data object. * Compute data fields whose values are not stored to the database. diff --git a/src/declarations/foundry/client/data/documents/actor.d.ts b/src/declarations/foundry/client/data/documents/actor.d.ts index 73cf7c57..8f4a70d6 100644 --- a/src/declarations/foundry/client/data/documents/actor.d.ts +++ b/src/declarations/foundry/client/data/documents/actor.d.ts @@ -6,7 +6,7 @@ declare class Actor< public readonly name: string; public readonly system: D; - get items(): I[]; + get items(): Collection; public getRollData(): object; } diff --git a/src/declarations/foundry/common/abstract/data.d.ts b/src/declarations/foundry/common/abstract/data.d.ts index 4e9ac77d..d4c60ea0 100644 --- a/src/declarations/foundry/common/abstract/data.d.ts +++ b/src/declarations/foundry/common/abstract/data.d.ts @@ -273,6 +273,8 @@ namespace foundry { */ get uuid(): string; + get id(): string; + /* --- Model permissions --- */ /** diff --git a/src/index.ts b/src/index.ts index bdcec60f..fccac7ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import COSMERE from './system/config'; import './style.scss'; -import './system/util/handlebars'; +import { preloadHandlebarsTemplates } from './system/util/handlebars'; import * as applications from './system/applications'; import * as dataModels from './system/data'; @@ -19,7 +19,7 @@ declare global { } } -Hooks.once('init', () => { +Hooks.once('init', async () => { CONFIG.COSMERE = COSMERE; CONFIG.Actor.dataModels = dataModels.actor.config; @@ -48,4 +48,7 @@ Hooks.once('init', () => { // @league-of-foundry-developers/foundry-vtt-types/src/foundry/client-esm/dice/terms/term.d.mts // @ts-expect-error see note CONFIG.Dice.rolls.push(dice.D20Roll); + + // Load templates + await preloadHandlebarsTemplates(); }); diff --git a/src/lang/en.json b/src/lang/en.json index 6ec99287..2f9580bc 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -60,7 +60,8 @@ "ActionCosts": { "Action": "Action", "Reaction": "Reaction", - "FreeAction": "Free Action" + "FreeAction": "Free Action", + "Special": "Special" }, "Attribute": { "name": "Attribute", @@ -150,6 +151,18 @@ "Type": { "Humanoid": "Humanoid", "Animal": "Animal" + }, + "Sheet": { + "Actions": { + "label": "Actions" + }, + "Inventory": { + "label": "Inventory", + "Item": { + "Quantity": "Quantity", + "Weight": "Weight" + } + } } }, "AttributeGroup": { @@ -168,13 +181,42 @@ }, "Item": { "Type": { - "Weapon": "Weapon", - "Armor": "Armor", - "Equipment": "Equipment", - "Ancestry": "Ancestry", - "Path": "Path", - "Talent": "Talent", - "Action": "Action" + "Weapon": { + "label": "Weapon", + "label_plural": "Weapons" + }, + "Armor": { + "label": "Armor", + "label_plural": "Armor" + }, + "Equipment": { + "label": "Equipment", + "label_plural": "Equipment" + }, + "Ancestry": { + "label": "Ancestry", + "label_plural": "Ancestries" + }, + "Path": { + "label": "Path", + "label_plural": "Paths" + }, + "Talent": { + "label": "Talent", + "label_plural": "Talents" + }, + "Action": { + "label": "Action", + "label_plural": "Actions" + }, + "Trait": { + "label": "Trait", + "label_plural": "Traits" + }, + "Injury": { + "label": "Injury", + "label_plural": "Injuries" + } }, "Weapon": { "Type": { @@ -213,16 +255,29 @@ "Presentable": "Presentable" } }, + "Action": { + "Type": { + "Basic": { + "label": "Basic Action", + "label_plural": "Basic Actions" + } + } + }, "Activation": { - "ConsumeType": { - "Resource": "Resource", - "Charge": "Charge", - "Item": "Item" + "Type": { + "Action": "Action", + "SkillTest": "Skill Test", + "Utility": "Utility" }, - "Resources": { - "Charge": { - "Singular": "Charge", - "Plural": "Charges" + "ConsumeType": { + "ActorResource": { + "Label": "Actor Resource" + }, + "ItemResource": { + "Label": "Item Resource" + }, + "Item": { + "Label": "Item" } } }, @@ -238,14 +293,64 @@ "Death": "Death" } }, - "DefaultFlavor": "[actor] uses their [item]" + "Resources": { + "Charge": { + "Singular": "Charge", + "Plural": "Charges" + }, + "Use": { + "Singular": "Use", + "Plural": "Uses" + }, + "Recharge": { + "PerScene": "Per scene" + } + }, + "Equip": { + "Types": { + "Wear": { + "Label": "Worn" + }, + "Hold": { + "Label": "Held" + } + }, + "Hold": { + "MainHand": { + "Label": "Main Hand" + }, + "OffHand": { + "Label": "Off Hand" + }, + "TwoHanded": { + "Label": "Two Handed" + } + }, + "Equip": { + "Label": "Equip" + }, + "Unequip": { + "Label": "Unequip" + }, + "Unequipped": { + "Label": "Unequipped" + } + }, + "DefaultFlavor": "[actor] uses [item]" + }, + "Attack": { + "Type": { + "Melee": "Melee", + "Ranged": "Ranged" + } }, "DamageTypes": { "Energy": "Energy", "Impact": "Impact", "Keen": "Keen", "Spirit": "Spirit", - "Vital": "Vital" + "Vital": "Vital", + "Healing": "Healing" } }, "DICE": { @@ -259,6 +364,11 @@ "None": "None", "Advantage": "Advantage", "Disadvantage": "Disadvantage" + }, + "Damage": { + "Label": "Damage", + "Apply": "Apply", + "Graze": "Graze" } }, "DIALOG": { @@ -272,6 +382,7 @@ "None": "None", "Formula": "Formula", "SkillTest": "Skill Test", + "Cost": "Cost", "DistanceUnit": "ft.", "Button": { "Roll": "Roll", diff --git a/src/style.scss b/src/style.scss index c2b3f56e..eb37a732 100644 --- a/src/style.scss +++ b/src/style.scss @@ -1 +1,12 @@ @import './style/sheets/module.scss'; +// @import './style/chat/module.scss'; + +@font-face { + font-family: 'cosmere-dingbats'; + 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; +} diff --git a/src/style/sheets/actor/character.scss b/src/style/sheets/actor/character.scss index b2365e6e..8ad0f885 100644 --- a/src/style/sheets/actor/character.scss +++ b/src/style/sheets/actor/character.scss @@ -113,7 +113,7 @@ .label, p { text-align: center; - font-size: 10pt; + font-size: 8pt; } } @@ -123,3 +123,24 @@ font-weight: normal; } } + +.documents { + flex: 1; + display: flex; + flex-direction: row; + + .favorites { + width: 33%; + } + + .tabs { + flex: 1; + display: flex; + flex-direction: column; + align-items: unset; + + .tab-body { + flex: 1; + } + } +} \ No newline at end of file diff --git a/src/style/sheets/sheet.scss b/src/style/sheets/sheet.scss index 2fa8adad..28bdfc46 100644 --- a/src/style/sheets/sheet.scss +++ b/src/style/sheets/sheet.scss @@ -6,6 +6,7 @@ .sheet-body { display: flex; flex-direction: column; + flex: 1 !important; } .box-title { @@ -27,3 +28,184 @@ .rollable { cursor: pointer; } + +.sheet { + .tabs { + nav { + align-items: center; + + a { + font-weight: bold; + text-align: center; + + &.active { + text-decoration: underline; + } + } + } + + .tab-body { + position: relative; + + .scroll-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: scroll; + } + } + } + + .actions-list, + .inventory-list { + margin: 0; + padding: 0; + + .item-image { + width: 2rem; + height: 2rem; + } + + .item-header { + display: flex; + flex-direction: row; + padding-right: 1rem; + margin-top: .2rem; + height: 2rem; + align-items: center; + + &:not(:first-of-type) { + margin-top: 1rem; + } + + .name { + font-weight: bold; + font-size: 13pt; + } + + .subtitle { + font-size: 8pt; + margin-bottom: 0.3rem; + opacity: .75; + } + + .col { + font-size: 9pt; + } + } + + .item { + display: flex; + flex-direction: row; + padding-right: 1rem; + margin-top: .2rem; + + .item-name { + .name { + margin-left: .75rem; + } + } + + &:hover { + background: rgba(0, 0, 0, 0.15); + } + } + + .item-name { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + + .name { + display: flex; + flex-direction: column; + + .title { + font-weight: bold; + + i { + margin-left: .5rem; + } + } + + .subtitle { + font-size: 8pt; + opacity: .75; + } + } + } + + .col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-right: 2rem; + width: 5rem; + + .val { + font-weight: bold; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-top: .1rem; + } + + .sub { + font-size: 8pt; + opacity: .75; + } + + &.equip { + width: 1.5rem; + position: relative; + + .value { + font-size: 14pt; + color: rgba(0, 0, 0, 0.3); + + &.equipped { + color: #232323; + } + } + } + } + + .item-controls { + display: flex; + align-items: center; + justify-content: flex-end; + width: 2.5rem; + + > a { + margin: .2rem; + } + } + + .dropdown { + display: none; + position: absolute; + top: 100%; + margin: 0; + background: #fffbf2; + list-style: none; + padding: 0.2rem; + font-size: 12pt; + z-index: 10; + box-shadow: 0 0.1rem 0.3rem 0 rgba(0, 0, 0, .3); + border: 1px solid gray; + + &.active { + display: block; + } + + .option-select:not(.selected) { + opacity: .5; + } + } + } +} \ No newline at end of file diff --git a/src/system/applications/actor/base-sheet.ts b/src/system/applications/actor/base-sheet.ts index 1cd22afb..076d993e 100644 --- a/src/system/applications/actor/base-sheet.ts +++ b/src/system/applications/actor/base-sheet.ts @@ -1,7 +1,19 @@ -import { AttributeGroup, Skill } from '@system/types/cosmere'; +import { + AttributeGroup, + Skill, + ItemType, + ActionType, +} from '@system/types/cosmere'; import { CosmereActor } from '@system/documents/actor'; +import { CosmereItem } from '@system/documents/item'; + +import { ActionItemDataModel, ActionItemData } from '@system/data/item'; + +type TabId = 'actions' | 'inventory'; export class BaseSheet extends ActorSheet { + private currentTab: TabId = 'actions'; + get template() { return `systems/cosmere-rpg/templates/actors/${this.actor.type}-sheet.hbs`; } @@ -17,6 +29,10 @@ export class BaseSheet extends ActorSheet { attributeGroups: ( Object.keys(CONFIG.COSMERE.attributeGroups) as AttributeGroup[] ).map(this.getDataForAttributeGroup.bind(this)), + + items: this.getItemData(), + + tab: this.currentTab, }; } @@ -30,11 +46,38 @@ export class BaseSheet extends ActorSheet { 'click', this.onRollSkillTest.bind(this), ); + + // Item listeners + html.find('.item [data-action]').on( + 'click', + this.onItemAction.bind(this), + ); } + + html.find('nav a.tab-item[data-tab]').on( + 'click', + this.onSelectTab.bind(this), + ); } /* --- Internal functions --- */ + private onSelectTab(event: Event) { + event.preventDefault(); + + // Get the tab id + const tabId = $(event.currentTarget!).data('tab') as TabId; + + // Ensure tab was changed + if (tabId === this.currentTab) return; + + // Change which tab is active + this.currentTab = tabId; + + // Re-render + this.render(true); + } + private onRollSkillTest(event: Event) { event.preventDefault(); @@ -44,6 +87,39 @@ export class BaseSheet extends ActorSheet { void this.actor.rollSkill(skillId); } + private onItemAction(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + // Get the action + const action = $(event.currentTarget!) + .closest('[data-action]') + .data('action') as string; + + console.log('action', action); + + // Get the item id + const itemId = $(event.currentTarget!) + .closest('[data-item-id]') + .data('item-id') as string; + + // Find the item + const item = this.actor.items.get(itemId); + if (!item) return; + + switch (action) { + case 'use': + void this.actor.useItem(item); + break; + case 'view': + case 'edit': + item.sheet?.render(true); + break; + case 'delete': + void this.actor.deleteEmbeddedDocuments('Item', [item.id]); + } + } + /* ---------------------- */ private getDataForAttributeGroup(groupId: AttributeGroup) { @@ -114,4 +190,86 @@ export class BaseSheet extends ActorSheet { ...this.actor.system.resources[groupConfig.resource], }; } + + private getItemData() { + return { + actions: this.getActionsData(), + inventory: this.getInventoryData(), + }; + } + + private getActionsData() { + // Get all activatable items + const activatableItems = this.actor.items + .filter((item) => item.hasActivation()) + .filter( + (item) => + !item.isEquippable() || + item.system.equipped || + item.system.alwaysEquipped, + ); + + // Get all items that are not actions (but are activatable, e.g. weapons) + const nonActionItems = activatableItems.filter( + (item) => !(item.system instanceof ActionItemDataModel), + ); + + // Get action items + const actionItems = activatableItems.filter( + (item) => item.system instanceof ActionItemDataModel, + ) as CosmereItem[]; + + // Get action types + const actionTypes = Object.keys( + CONFIG.COSMERE.action.types, + ) as ActionType[]; + + return [ + ...this.categorizeItemsByType(nonActionItems), + ...actionTypes + .map((type) => ({ + id: type, + label: CONFIG.COSMERE.action.types[type].labelPlural, + subtitle: CONFIG.COSMERE.action.types[type].subtitle, + items: actionItems.filter( + (i) => (i.system.type as ActionType) === type, + ), + })) + .filter((section) => section.items.length > 0), + ]; + } + + private getInventoryData() { + // Assume all physical items are part of inventory + const physicalItems = this.actor.items.filter((item) => + item.isPhysical(), + ); + + return this.categorizeItemsByType(physicalItems); + } + + private categorizeItemsByType(items: CosmereItem[]) { + // Get item types + const types = Object.keys(CONFIG.COSMERE.items.types) as ItemType[]; + + // Categorize items by types + const categories = types.reduce( + (result, type) => { + // Get all physical items of type + result[type] = items.filter((item) => item.type === type); + + return result; + }, + {} as Record, + ); + + // Set up sections + return (Object.keys(categories) as ItemType[]) + .filter((type) => categories[type].length > 0) + .map((type) => ({ + id: type, + label: CONFIG.COSMERE.items.types[type].labelPlural, + items: categories[type], + })); + } } diff --git a/src/system/config.ts b/src/system/config.ts index e36fb7f6..30252e49 100644 --- a/src/system/config.ts +++ b/src/system/config.ts @@ -18,8 +18,15 @@ import { DeflectSource, ActivationType, ItemConsumeType, + ActionType, ActionCostType, DamageType, + ItemType, + AttackType, + ItemRechargeType, + ItemResource, + EquipType, + HoldType, } from './types/cosmere'; const COSMERE: CosmereRPGConfig = { @@ -146,10 +153,12 @@ const COSMERE: CosmereRPGConfig = { attributes: { [Attribute.Strength]: { label: 'COSMERE.Actor.Attribute.Strength.long', + labelShort: 'COSMERE.Actor.Attribute.Strength.short', skills: [Skill.Athletics, Skill.HeavyWeapons], }, [Attribute.Speed]: { label: 'COSMERE.Actor.Attribute.Speed.long', + labelShort: 'COSMERE.Actor.Attribute.Speed.short', skills: [ Skill.Agility, Skill.LightWeapons, @@ -159,6 +168,7 @@ const COSMERE: CosmereRPGConfig = { }, [Attribute.Intellect]: { label: 'COSMERE.Actor.Attribute.Intellect.long', + labelShort: 'COSMERE.Actor.Attribute.Intellect.short', skills: [ Skill.Crafting, Skill.Deduction, @@ -168,14 +178,17 @@ const COSMERE: CosmereRPGConfig = { }, [Attribute.Willpower]: { label: 'COSMERE.Actor.Attribute.Willpower.long', + labelShort: 'COSMERE.Actor.Attribute.Willpower.short', skills: [Skill.Discipline, Skill.Intimidation], }, [Attribute.Awareness]: { label: 'COSMERE.Actor.Attribute.Awareness.long', + labelShort: 'COSMERE.Actor.Attribute.Awareness.short', skills: [Skill.Insight, Skill.Perception, Skill.Survival], }, [Attribute.Presence]: { label: 'COSMERE.Actor.Attribute.Presence.long', + labelShort: 'COSMERE.Actor.Attribute.Presence.short', skills: [Skill.Deception, Skill.Leadership, Skill.Persuasion], }, }, @@ -289,21 +302,108 @@ const COSMERE: CosmereRPGConfig = { }, items: { + types: { + [ItemType.Weapon]: { + label: 'COSMERE.Item.Type.Weapon.label', + labelPlural: 'COSMERE.Item.Type.Weapon.label_plural', + }, + [ItemType.Armor]: { + label: 'COSMERE.Item.Type.Armor.label', + labelPlural: 'COSMERE.Item.Type.Armor.label_plural', + }, + [ItemType.Equipment]: { + label: 'COSMERE.Item.Type.Equipment.label', + labelPlural: 'COSMERE.Item.Type.Equipment.label_plural', + }, + [ItemType.Ancestry]: { + label: 'COSMERE.Item.Type.Ancestry.label', + labelPlural: 'COSMERE.Item.Type.Ancestry.label_plural', + }, + [ItemType.Path]: { + label: 'COSMERE.Item.Type.Path.label', + labelPlural: 'COSMERE.Item.Type.Path.label_plural', + }, + [ItemType.Talent]: { + label: 'COSMERE.Item.Type.Talent.label', + labelPlural: 'COSMERE.Item.Type.Talent.label_plural', + }, + [ItemType.Action]: { + label: 'COSMERE.Item.Type.Action.label', + labelPlural: 'COSMERE.Item.Type.Action.label_plural', + }, + [ItemType.Trait]: { + label: 'COSMERE.Item.Type.Trait.label', + labelPlural: 'COSMERE.Item.Type.Trait.label_plural', + }, + [ItemType.Injury]: { + label: 'COSMERE.Item.Type.Injury.label', + labelPlural: 'COSMERE.Item.Type.Injury.label_plural', + }, + }, activation: { types: { + [ActivationType.Action]: { + label: 'COSMERE.Item.Activation.Type.Action', + }, [ActivationType.SkillTest]: { - label: 'COSMERE.GENERIC.SkillTest', + label: 'COSMERE.Item.Activation.Type.SkillTest', + }, + [ActivationType.Utility]: { + label: 'COSMERE.Item.Activation.Type.Utility', }, }, consumeTypes: { - [ItemConsumeType.Resource]: { - label: 'COSMERE.Item.Activation.ConsumeType.Resource', + [ItemConsumeType.ActorResource]: { + label: 'COSMERE.Item.Activation.ConsumeType.ActorResource.Label', }, - [ItemConsumeType.Charge]: { - label: 'COSMERE.Item.Activation.ConsumeType.Charge', + [ItemConsumeType.ItemResource]: { + label: 'COSMERE.Item.Activation.ConsumeType.ItemResource.Label', }, [ItemConsumeType.Item]: { - label: 'COSMERE.Item.Activation.ConsumeType.Item', + label: 'COSMERE.Item.Activation.ConsumeType.Item.Label', + }, + }, + }, + resources: { + types: { + [ItemResource.Use]: { + label: 'COSMERE.Item.Resources.Use.Singular', + labelPlural: 'COSMERE.Item.Resources.Use.Plural', + }, + [ItemResource.Charge]: { + label: 'COSMERE.Item.Resources.Charge.Singular', + labelPlural: 'COSMERE.Item.Resources.Charge.Plural', + }, + }, + recharge: { + [ItemRechargeType.PerScene]: { + label: 'COSMERE.Item.Resources.Recharge.PerScene', + }, + }, + }, + equip: { + types: { + [EquipType.Wear]: { + label: 'COSMERE.Item.Equip.Types.Wear.Label', + icon: '', + }, + [EquipType.Hold]: { + label: 'COSMERE.Item.Equip.Types.Hold.Label', + icon: '', + }, + }, + hold: { + [HoldType.MainHand]: { + label: 'COSMERE.Item.Equip.Hold.MainHand.Label', + icon: '', + }, + [HoldType.OffHand]: { + label: 'COSMERE.Item.Equip.Hold.OffHand.Label', + icon: '', + }, + [HoldType.TwoHanded]: { + label: 'COSMERE.Item.Equip.Hold.TwoHanded.Label', + icon: '', }, }, }, @@ -437,15 +537,37 @@ const COSMERE: CosmereRPGConfig = { }, }, - actionCosts: { - [ActionCostType.Action]: { - label: 'COSMERE.Actor.ActionCosts.Action', + action: { + types: { + [ActionType.Basic]: { + label: 'COSMERE.Item.Action.Type.Basic.label', + labelPlural: 'COSMERE.Item.Action.Type.Basic.label_plural', + }, }, - [ActionCostType.Reaction]: { - label: 'COSMERE.Actor.ActionCosts.Reaction', + costs: { + [ActionCostType.Action]: { + label: 'COSMERE.Actor.ActionCosts.Action', + }, + [ActionCostType.Reaction]: { + label: 'COSMERE.Actor.ActionCosts.Reaction', + }, + [ActionCostType.FreeAction]: { + label: 'COSMERE.Actor.ActionCosts.FreeAction', + }, + [ActionCostType.Special]: { + label: 'COSMERE.Actor.ActionCosts.Special', + }, }, - [ActionCostType.FreeAction]: { - label: 'COSMERE.Actor.ActionCosts.FreeAction', + }, + + attack: { + types: { + [AttackType.Melee]: { + label: 'COSMERE.Attack.Type.Melee', + }, + [AttackType.Ranged]: { + label: 'COSMERE.Attack.Type.Ranged', + }, }, }, @@ -467,6 +589,9 @@ const COSMERE: CosmereRPGConfig = { label: 'COSMERE.DamageTypes.Vital', ignoreDeflect: true, }, + [DamageType.Healing]: { + label: 'COSMERE.DamageTypes.Healing', + }, }, }; diff --git a/src/system/data/item/action.ts b/src/system/data/item/action.ts index eedfbc94..e47816c9 100644 --- a/src/system/data/item/action.ts +++ b/src/system/data/item/action.ts @@ -1,5 +1,8 @@ +import { CosmereItem } from '@src/system/documents'; + // Mixins import { DataModelMixin } from '../mixins'; +import { TypedItemMixin, TypedItemData } from './mixins/typed'; import { DescriptionItemMixin, DescriptionItemData, @@ -8,14 +11,22 @@ import { ActivatableItemMixin, ActivatableItemData, } from './mixins/activatable'; +import { DamagingItemMixin, DamagingItemData } from './mixins/damaging'; export interface ActionItemData extends DescriptionItemData, - ActivatableItemData {} + ActivatableItemData, + TypedItemData, + DamagingItemData {} -export class ActionItemDataModel extends DataModelMixin( +export class ActionItemDataModel extends DataModelMixin< + ActionItemData, + CosmereItem +>( + TypedItemMixin(), DescriptionItemMixin(), ActivatableItemMixin(), + DamagingItemMixin(), ) { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), {}); diff --git a/src/system/data/item/armor.ts b/src/system/data/item/armor.ts index 91d7e44c..e3ca436c 100644 --- a/src/system/data/item/armor.ts +++ b/src/system/data/item/armor.ts @@ -16,9 +16,9 @@ export interface ArmorItemData extends TypedItemData, DescriptionItemData, EquippableItemData, + ExpertiseItemData, TraitsItemData, - PhysicalItemData, - ExpertiseItemData { + PhysicalItemData { deflect?: number; } @@ -29,9 +29,9 @@ export class ArmorItemDataModel extends DataModelMixin< TypedItemMixin(), DescriptionItemMixin(), EquippableItemMixin(), + ExpertiseItemMixin(), TraitsItemMixin(), PhysicalItemMixin(), - ExpertiseItemMixin(), ) { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), { diff --git a/src/system/data/item/mixins/activatable.ts b/src/system/data/item/mixins/activatable.ts index c0dcf3bf..08bbf8d6 100644 --- a/src/system/data/item/mixins/activatable.ts +++ b/src/system/data/item/mixins/activatable.ts @@ -3,11 +3,19 @@ import { ActionCostType, ItemConsumeType, Resource, + ItemResource, Skill, Attribute, + ItemRechargeType, } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents'; +interface ItemResourceData { + value: number; + max?: number; + recharge?: ItemRechargeType; +} + export interface ActivatableItemData { activation: { type?: ActivationType; @@ -18,7 +26,7 @@ export interface ActivatableItemData { consume?: { type: ItemConsumeType; value: number; - resource?: Resource; + resource?: Resource | ItemResource; }; flavor?: string; @@ -28,12 +36,7 @@ export interface ActivatableItemData { attribute?: Attribute; }; - resources?: { - charge?: { - value: number; - max?: number; - }; - }; + resources?: Record; } export function ActivatableItemMixin

() { @@ -42,6 +45,8 @@ export function ActivatableItemMixin

() { ) => { return class mixin extends base { static defineSchema() { + const itemResources = CONFIG.COSMERE.items.resources.types; + return foundry.utils.mergeObject(super.defineSchema(), { activation: new foundry.data.fields.SchemaField({ type: new foundry.data.fields.StringField({ @@ -58,7 +63,7 @@ export function ActivatableItemMixin

() { }), type: new foundry.data.fields.StringField({ choices: Object.keys( - CONFIG.COSMERE.actionCosts, + CONFIG.COSMERE.action.costs, ), }), }, @@ -78,7 +83,7 @@ export function ActivatableItemMixin

() { CONFIG.COSMERE.items.activation .consumeTypes, ), - initial: ItemConsumeType.Charge, + initial: ItemConsumeType.ItemResource, }), value: new foundry.data.fields.NumberField({ required: true, @@ -89,9 +94,16 @@ export function ActivatableItemMixin

() { }), resource: new foundry.data.fields.StringField({ blank: false, - choices: Object.keys( - CONFIG.COSMERE.resources, - ), + choices: [ + ...Object.keys( + CONFIG.COSMERE.resources, + ), + ...Object.keys( + CONFIG.COSMERE.items.resources + .types, + ), + ], + initial: ItemResource.Use, }), }, { @@ -106,33 +118,58 @@ export function ActivatableItemMixin

() { choices: Object.keys(CONFIG.COSMERE.skills), }), attribute: new foundry.data.fields.StringField({ + nullable: true, blank: false, choices: Object.keys(CONFIG.COSMERE.attributes), }), }), resources: new foundry.data.fields.SchemaField( - { - charge: new foundry.data.fields.SchemaField( - { - value: new foundry.data.fields.NumberField({ - required: true, - nullable: false, - min: 0, - integer: true, - initial: 0, - }), - max: new foundry.data.fields.NumberField({ - min: 0, - integer: true, - }), - }, - { - required: false, - nullable: true, - initial: null, - }, - ), - }, + (Object.keys(itemResources) as ItemResource[]).reduce( + (schema, resource) => { + schema[resource] = + new foundry.data.fields.SchemaField( + { + value: new foundry.data.fields.NumberField( + { + required: true, + nullable: false, + min: 0, + integer: true, + }, + ), + max: new foundry.data.fields.NumberField( + { + min: 0, + integer: true, + }, + ), + recharge: + new foundry.data.fields.StringField( + { + nullable: true, + blank: false, + choices: Object.keys( + CONFIG.COSMERE.items + .resources + .recharge, + ), + }, + ), + }, + { + required: false, + nullable: true, + initial: null, + }, + ); + + return schema; + }, + {} as Record< + string, + foundry.data.fields.SchemaField + >, + ), { required: false, nullable: true, diff --git a/src/system/data/item/mixins/attacking.ts b/src/system/data/item/mixins/attacking.ts new file mode 100644 index 00000000..2399a4c6 --- /dev/null +++ b/src/system/data/item/mixins/attacking.ts @@ -0,0 +1,46 @@ +import { AttackType } from '@system/types/cosmere'; +import { CosmereItem } from '@system/documents'; + +export interface AttackingItemData { + attack: { + type: AttackType; + range?: { + value?: number; + long?: number; + units?: string; + }; + }; +} + +export function AttackingItemMixin

() { + return ( + base: typeof foundry.abstract.TypeDataModel, + ) => { + return class extends base { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + attack: new foundry.data.fields.SchemaField({ + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + initial: AttackType.Melee, + choices: Object.keys(CONFIG.COSMERE.attack.types), + }), + range: new foundry.data.fields.SchemaField( + { + value: new foundry.data.fields.NumberField({ + min: 0, + }), + long: new foundry.data.fields.NumberField({ + min: 0, + }), + units: new foundry.data.fields.StringField(), + }, + { required: false, nullable: true }, + ), + }), + }); + } + }; + }; +} diff --git a/src/system/data/item/mixins/damaging.ts b/src/system/data/item/mixins/damaging.ts index 90f3c22a..b3dafea5 100644 --- a/src/system/data/item/mixins/damaging.ts +++ b/src/system/data/item/mixins/damaging.ts @@ -18,16 +18,20 @@ export function DamagingItemMixin

() { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), { damage: new foundry.data.fields.SchemaField({ - formula: new foundry.data.fields.StringField(), + formula: new foundry.data.fields.StringField({ + nullable: true, + blank: false, + }), type: new foundry.data.fields.StringField({ + nullable: true, choices: Object.keys(CONFIG.COSMERE.damageTypes), }), skill: new foundry.data.fields.StringField({ - initial: Skill.LightWeapons, + nullable: true, choices: Object.keys(CONFIG.COSMERE.skills), }), attribute: new foundry.data.fields.StringField({ - initial: Attribute.Speed, + nullable: true, choices: Object.keys(CONFIG.COSMERE.attributes), }), }), diff --git a/src/system/data/item/mixins/description.ts b/src/system/data/item/mixins/description.ts index 3c9a575a..f9ea8372 100644 --- a/src/system/data/item/mixins/description.ts +++ b/src/system/data/item/mixins/description.ts @@ -4,6 +4,7 @@ export interface DescriptionItemData { description?: { value?: string; chat?: string; + short?: string; }; } @@ -21,6 +22,7 @@ export function DescriptionItemMixin

() { chat: new foundry.data.fields.HTMLField({ label: 'Chat description', }), + short: new foundry.data.fields.StringField(), }), }); } diff --git a/src/system/data/item/mixins/equippable.ts b/src/system/data/item/mixins/equippable.ts index 9e24a17c..a5fb1cad 100644 --- a/src/system/data/item/mixins/equippable.ts +++ b/src/system/data/item/mixins/equippable.ts @@ -1,7 +1,13 @@ +import { EquipType, HoldType } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents'; export interface EquippableItemData { equipped: boolean; + alwaysEquipped?: boolean; + equip: { + type: EquipType; + hold?: HoldType; + }; } export function EquippableItemMixin

() { @@ -17,6 +23,25 @@ export function EquippableItemMixin

() { initial: false, label: 'Equipped', }), + alwaysEquipped: new foundry.data.fields.BooleanField({ + nullable: true, + }), + equip: new foundry.data.fields.SchemaField({ + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + initial: EquipType.Wear, + choices: Object.keys( + CONFIG.COSMERE.items.equip.types, + ), + }), + hold: new foundry.data.fields.StringField({ + nullable: true, + choices: Object.keys( + CONFIG.COSMERE.items.equip.hold, + ), + }), + }), }); } }; diff --git a/src/system/data/item/mixins/physical.ts b/src/system/data/item/mixins/physical.ts index 6e1c3f00..307b0dd9 100644 --- a/src/system/data/item/mixins/physical.ts +++ b/src/system/data/item/mixins/physical.ts @@ -1,11 +1,12 @@ import { CosmereItem } from '@system/documents'; export interface PhysicalItemData { - weight?: { + quantity: number; + weight: { value?: number; unit?: string; }; - price?: { + price: { value?: number; unit?: string; }; @@ -18,17 +19,20 @@ export function PhysicalItemMixin

() { return class extends base { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), { + quantity: new foundry.data.fields.NumberField({ + min: 0, + initial: 1, + integer: true, + }), weight: new foundry.data.fields.SchemaField({ value: new foundry.data.fields.NumberField({ min: 0, - initial: 0, }), unit: new foundry.data.fields.StringField(), }), price: new foundry.data.fields.SchemaField({ value: new foundry.data.fields.NumberField({ min: 0, - initial: 0, }), unit: new foundry.data.fields.StringField(), }), diff --git a/src/system/data/item/mixins/traits.ts b/src/system/data/item/mixins/traits.ts index 4fabf014..60fdb62d 100644 --- a/src/system/data/item/mixins/traits.ts +++ b/src/system/data/item/mixins/traits.ts @@ -1,8 +1,9 @@ +import { WeaponTraitId, ArmorTraitId } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents'; import { ExpertiseItemData } from './expertise'; interface TraitData { - id: string; + id: WeaponTraitId | ArmorTraitId; /** * The default (not expertise) value of this trait @@ -54,6 +55,14 @@ export function TraitsItemMixin

() { return (base: typeof foundry.abstract.TypeDataModel) => { return class extends base { static defineSchema() { + const superSchema = super.defineSchema(); + + if (!('expertise' in superSchema)) { + throw new Error( + 'TraitsItemMixin must be used in combination with ExpertiseItemMixin and must follow it', + ); + } + return foundry.utils.mergeObject(super.defineSchema(), { traits: new foundry.data.fields.SetField( new foundry.data.fields.SchemaField({ diff --git a/src/system/data/item/weapon.ts b/src/system/data/item/weapon.ts index 3321ec8d..5241741b 100644 --- a/src/system/data/item/weapon.ts +++ b/src/system/data/item/weapon.ts @@ -1,4 +1,5 @@ import { WeaponId } from '@system/types/cosmere'; +import { CosmereItem } from '@src/system/documents'; // Mixins import { DataModelMixin } from '../mixins'; @@ -12,6 +13,7 @@ import { ActivatableItemMixin, ActivatableItemData, } from './mixins/activatable'; +import { AttackingItemMixin, AttackingItemData } from './mixins/attacking'; import { DamagingItemMixin, DamagingItemData } from './mixins/damaging'; import { TraitsItemMixin, TraitsItemData } from './mixins/traits'; import { PhysicalItemMixin, PhysicalItemData } from './mixins/physical'; @@ -22,34 +24,27 @@ export interface WeaponItemData DescriptionItemData, EquippableItemData, ActivatableItemData, + AttackingItemData, DamagingItemData, + ExpertiseItemData, TraitsItemData, - PhysicalItemData, - ExpertiseItemData { - range?: { - value?: number; - long?: number; - units?: string; - }; -} + PhysicalItemData {} -export class WeaponItemDataModel extends DataModelMixin( +export class WeaponItemDataModel extends DataModelMixin< + WeaponItemData, + CosmereItem +>( TypedItemMixin(), DescriptionItemMixin(), EquippableItemMixin(), ActivatableItemMixin(), + AttackingItemMixin(), DamagingItemMixin(), + ExpertiseItemMixin(), TraitsItemMixin(), PhysicalItemMixin(), - ExpertiseItemMixin(), ) { static defineSchema() { - return foundry.utils.mergeObject(super.defineSchema(), { - range: new foundry.data.fields.SchemaField({ - value: new foundry.data.fields.NumberField({ min: 0 }), - long: new foundry.data.fields.NumberField({ min: 0 }), - units: new foundry.data.fields.StringField(), - }), - }); + return foundry.utils.mergeObject(super.defineSchema(), {}); } } diff --git a/src/system/documents/actor.ts b/src/system/documents/actor.ts index ddeb46ce..f39675c2 100644 --- a/src/system/documents/actor.ts +++ b/src/system/documents/actor.ts @@ -36,6 +36,25 @@ interface RollSkillOptions { export class CosmereActor< T extends CommonActorDataModel = CommonActorDataModel, > extends Actor { + /** + * Utility function to get the modifier for a given skill for this actor. + * @param skill The skill to get the modifier for + * @param attributeOverride An optional attribute override, used instead of the default attribute + */ + public getSkillMod(skill: Skill, attributeOverride?: Attribute): number { + // Get attribute + const attribute = + attributeOverride ?? CONFIG.COSMERE.skills[skill].attribute; + + // Get skill rank + const rank = this.system.skills[skill].rank; + + // Get attribute value + const attrValue = this.system.attributes[attribute].value; + + return attrValue + rank; + } + /** * Roll a skill for this actor */ diff --git a/src/system/documents/item.ts b/src/system/documents/item.ts index 06f73d33..62f78d29 100644 --- a/src/system/documents/item.ts +++ b/src/system/documents/item.ts @@ -3,11 +3,24 @@ import { Skill, Attribute, ItemConsumeType, + ActivationType, + Resource, + ItemResource, } from '@system/types/cosmere'; import { CosmereActor } from './actor'; +import { WeaponItemDataModel } from '@system/data/item/weapon'; +import { ArmorItemDataModel } from '@system/data/item/armor'; + import { ActivatableItemData } from '@system/data/item/mixins/activatable'; +import { AttackingItemData } from '@system/data/item/mixins/attacking'; import { DamagingItemData } from '@system/data/item/mixins/damaging'; +import { PhysicalItemData } from '@system/data/item/mixins/physical'; +import { TypedItemData } from '@system/data/item/mixins/typed'; +import { TraitsItemData } from '@system/data/item/mixins/traits'; +import { EquippableItemData } from '@system/data/item/mixins/equippable'; +import { DescriptionItemData } from '@system/data/item/mixins/description'; + import { Derived } from '@system/data/fields'; import { d20Roll, D20Roll, D20RollData } from '@system/dice'; @@ -40,6 +53,18 @@ export class CosmereItem< // This way we avoid casting everytime we want to check its type declare type: ItemType; + /* --- ItemType type guards --- */ + + public isWeapon(): this is CosmereItem { + return this.type === ItemType.Weapon; + } + + public isArmor(): this is CosmereItem { + return this.type === ItemType.Armor; + } + + /* --- Mixin type guards --- */ + /** * Can this item be activated? */ @@ -47,6 +72,13 @@ export class CosmereItem< return 'activation' in this.system; } + /** + * Does this item have an attack? + */ + public hasAttack(): this is CosmereItem { + return 'attack' in this.system; + } + /** * Does this item deal damage? */ @@ -54,6 +86,44 @@ export class CosmereItem< return 'damage' in this.system; } + /** + * Is this item physical? + */ + public isPhysical(): this is CosmereItem { + return 'weight' in this.system && 'price' in this.system; + } + + /** + * Does this item have a sub-type? + */ + public isTyped(): this is CosmereItem { + return 'type' in this.system; + } + + /** + * Does this item have traits? + * Not to be confused adversary traits. (Which are their own item type.) + */ + public hasTraits(): this is CosmereItem { + return 'traits' in this.system; + } + + /** + * Can this item be equipped? + */ + public isEquippable(): this is CosmereItem { + return 'equipped' in this.system; + } + + /** + * Does this item have a description? + */ + public hasDescription(): this is CosmereItem { + return 'description' in this.system; + } + + /* --- Roll & Usage utilities --- */ + /** * Roll utility for activable items. * This function **only** performs the roll, it does not consume resources. @@ -171,10 +241,14 @@ export class CosmereItem< if (consumptionAvailable) { if ( - this.system.activation.consume!.type === ItemConsumeType.Charge + this.system.activation.consume!.type === + ItemConsumeType.ItemResource ) { - // Ensure charges are configured - if (!this.system.resources?.charge) { + const resource = this.system.activation.consume! + .resource as ItemResource; + + // Ensure item resource is configured + if (!this.system.resources?.[resource]) { ui.notifications.warn( game.i18n!.localize( 'GENERIC.Warning.ItemConsumeResourceNotConfigured', @@ -201,11 +275,14 @@ export class CosmereItem< // The the current amount const currentAmount = - consumeType === ItemConsumeType.Charge - ? this.system.resources!.charge!.value - : consumeType === ItemConsumeType.Resource + consumeType === ItemConsumeType.ItemResource + ? this.system.resources![ + this.system.activation.consume! + .resource as ItemResource + ]!.value + : consumeType === ItemConsumeType.ActorResource ? actor.system.resources[ - this.system.activation.consume!.resource! + this.system.activation.consume!.resource as Resource ].value : consumeType === ItemConsumeType.Item ? 0 // TODO: Figure out how to handle item consumption @@ -222,19 +299,20 @@ export class CosmereItem< // Add post roll action to consume the resource postRoll.push(() => { - if (consumeType === ItemConsumeType.Charge) { + if (consumeType === ItemConsumeType.ItemResource) { // Handle charge consumption // Consume the charges void this.update({ system: { resources: { - charge: { + [this.system.activation.consume! + .resource as string]: { value: newAmount, }, }, }, }); - } else if (consumeType === ItemConsumeType.Resource) { + } else if (consumeType === ItemConsumeType.ActorResource) { // Handle actor resource consumption void actor.update({ system: { @@ -259,17 +337,49 @@ export class CosmereItem< }); } - // Perform roll - const result = await this.roll(options); + // Check if a roll is required + const rollRequired = + this.system.activation.type === ActivationType.SkillTest; + + if (rollRequired) { + // Perform roll + const result = await this.roll(options); + + // Ensure roll wasn't cancelled + if (result !== null) { + // Perform post roll actions + postRoll.forEach((action) => action()); + } + + // Return the result + return result; + } else { + // Get the speaker + const speaker = + options.speaker ?? + (ChatMessage.getSpeaker({ actor }) as ChatSpeakerData); + + // NOTE: Use boolean or operator (`||`) here instead of nullish coalescing (`??`), + // as flavor can also be an empty string, which we'd like to replace with the default flavor too + const flavor = + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + this.system.activation.flavor || + game + .i18n!.localize('COSMERE.Item.DefaultFlavor') + .replace('[actor]', actor.name) + .replace('[item]', this.name); + + // Send chat message + void ChatMessage.create({ + content: flavor, + speaker, + }); - // Ensure roll wasn't cancelled - if (result !== null) { // Perform post roll actions postRoll.forEach((action) => action()); - } - // Return the result - return result; + return null; + } } private async showConsumeDialog( @@ -287,14 +397,17 @@ export class CosmereItem< // Determine consumed resource label const consumedResourceLabel = - consumeType === ItemConsumeType.Charge + consumeType === ItemConsumeType.ItemResource ? game.i18n!.localize( - `COSMERE.Item.Activation.Resources.Charge.${amount > 1 ? 'Plural' : 'Singular'}`, + CONFIG.COSMERE.items.resources.types[ + this.system.activation.consume + .resource as ItemResource + ][amount > 1 ? 'labelPlural' : 'label'], ) - : consumeType === ItemConsumeType.Resource + : consumeType === ItemConsumeType.ActorResource ? game.i18n!.localize( CONFIG.COSMERE.resources[ - this.system.activation.consume.resource! + this.system.activation.consume.resource as Resource ].label, ) : consumeType === ItemConsumeType.Item diff --git a/src/system/types/config.ts b/src/system/types/config.ts index 16c011bb..12440b36 100644 --- a/src/system/types/config.ts +++ b/src/system/types/config.ts @@ -17,8 +17,15 @@ import { DeflectSource, ActivationType, ItemConsumeType, + ActionType, ActionCostType, + AttackType, DamageType, + ItemType, + ItemRechargeType, + ItemResource, + EquipType, + HoldType, } from './cosmere'; export interface SizeConfig { @@ -49,6 +56,7 @@ export interface AttributeGroupConfig { export interface AttributeConfig { label: string; + labelShort: string; skills: Skill[]; } @@ -101,21 +109,56 @@ export interface ActivationTypeConfig { label: string; } +export interface ItemResourceConfig { + label: string; + labelPlural: string; +} + export interface ItemConsumeTypeConfig { label: string; } +export interface ItemRechargeConfig { + label: string; +} + +export interface ActionTypeConfig { + label: string; + labelPlural: string; + subtitle?: string; + hasMode?: boolean; +} + export interface ActionCostConfig { label: string; icon?: string; } +export interface AttackTypeConfig { + label: string; +} + export interface DamageTypeConfig { label: string; icon?: string; ignoreDeflect?: boolean; } +export interface ItemTypeConfig { + label: string; + labelPlural: string; +} + +export interface EquipTypeConfig { + label: string; + icon?: string; +} + +export interface HoldTypeConfig { + label: string; + icon?: string; +} + export interface CosmereRPGConfig { sizes: Record; creatureTypes: Record; @@ -128,10 +171,19 @@ export interface CosmereRPGConfig { skills: Record; items: { + types: Record; activation: { types: Record; consumeTypes: Record; }; + resources: { + types: Record; + recharge: Record; + }; + equip: { + types: Record; + hold: Record; + }; }; weaponTypes: Record; @@ -152,6 +204,14 @@ export interface CosmereRPGConfig { sources: Record; }; - actionCosts: Record; + action: { + types: Record; + costs: Record; + }; + + attack: { + types: Record; + }; + damageTypes: Record; } diff --git a/src/system/types/cosmere.ts b/src/system/types/cosmere.ts index dfacd0c8..6086714d 100644 --- a/src/system/types/cosmere.ts +++ b/src/system/types/cosmere.ts @@ -174,19 +174,51 @@ export enum DeflectSource { } export enum ActivationType { + Action = 'action', + Utility = 'utility', SkillTest = 'skill_test', } export enum ItemConsumeType { - Resource = 'resource', - Charge = 'charge', + ActorResource = 'actor_resource', // E.g. health, focus, investiture + ItemResource = 'item_resource', // E.g. uses, charges Item = 'item', } +export enum ItemResource { + Use = 'use', + Charge = 'charge', +} + +export enum ItemRechargeType { + PerScene = 'per_scene', +} + +export enum EquipType { + Hold = 'hold', // Item that you equip by holding it (either in one or two hands) + Wear = 'wear', // Item that you equip by wearing it +} + +export enum HoldType { + MainHand = 'main_hand', + OffHand = 'off_hand', + TwoHanded = 'two_handed', +} + +export enum ActionType { + Basic = 'basic', +} + export enum ActionCostType { Action = 'act', Reaction = 'rea', FreeAction = 'fre', + Special = 'spe', +} + +export enum AttackType { + Melee = 'melee', + Ranged = 'ranged', } export enum DamageType { @@ -195,6 +227,7 @@ export enum DamageType { Keen = 'keen', Spirit = 'spirit', Vital = 'vital', + Healing = 'heal', } /* --- System --- */ diff --git a/src/system/util/handlebars/index.ts b/src/system/util/handlebars/index.ts index 9b4ab5c7..7f554ebd 100644 --- a/src/system/util/handlebars/index.ts +++ b/src/system/util/handlebars/index.ts @@ -1,6 +1,59 @@ -import { CharacterActor } from '@system/documents/actor'; +import { + ArmorTraitId, + WeaponTraitId, + Skill, + Attribute, + ItemConsumeType, + Resource, + ItemResource, + ItemType, + ActionCostType, + DamageType, + HoldType, + AttackType, +} from '@src/system/types/cosmere'; + +import { CharacterActor, CosmereActor } from '@system/documents/actor'; +import { CosmereItem } from '@system/documents/item'; import { Derived } from '@system/data/fields'; +import { ItemContext, ItemContextOptions } from './types'; + +Handlebars.registerHelper('add', (a: number, b: number) => a + b); +Handlebars.registerHelper('sub', (a: number, b: number) => a - b); +Handlebars.registerHelper('multi', (a: number, b: number) => a * b); +Handlebars.registerHelper('divide', (a: number, b: number) => a / b); + +Handlebars.registerHelper('default', (v: unknown, defaultVal: unknown) => { + return v ? v : defaultVal; +}); + +Handlebars.registerHelper( + 'times', + (count: unknown, options: Handlebars.HelperOptions): string => + [...Array(Number(count) || 0).keys()] + .map((i) => + options.fn(i, { + data: options.data as unknown, + blockParams: [i], + }), + ) + .join(''), +); + +Handlebars.registerHelper('cosmereDingbat', (type: ActionCostType) => { + switch (type) { + case ActionCostType.FreeAction: + return '0'; + case ActionCostType.Reaction: + return 'r'; + case ActionCostType.Special: + return '*'; + default: + return ''; + } +}); + Handlebars.registerHelper( 'greaterThan', (a: number, b: number, equal?: boolean) => (equal ? a >= b : a > b), @@ -13,7 +66,9 @@ Handlebars.registerHelper( return actor.system.expertises .map( (expertise) => - `${expertise.label} (${CONFIG.COSMERE.expertiseTypes[expertise.type].label})`, + `${expertise.label} (${game.i18n!.localize( + CONFIG.COSMERE.expertiseTypes[expertise.type].label, + )})`, ) .join(', '); }, @@ -22,3 +77,370 @@ Handlebars.registerHelper( Handlebars.registerHelper('derived', (derived: Derived) => { return Derived.getValue(derived); }); + +Handlebars.registerHelper( + 'skillMod', + (actor: CosmereActor, skill: Skill, attribute?: Attribute) => { + return actor.getSkillMod(skill, attribute); + }, +); + +Handlebars.registerHelper( + 'formulaReplaceData', + (formula: string, data: Record) => { + return Roll.replaceFormulaData(formula, data, { missing: '0' }); + }, +); + +Handlebars.registerHelper('itemHoldSelect', (selected?: HoldType) => { + const holdTypes = Object.keys( + CONFIG.COSMERE.items.equip.hold, + ) as HoldType[]; + + return ` +

+ `; +}); + +Handlebars.registerHelper( + 'itemContext', + (item: CosmereItem, options?: { hash?: ItemContextOptions }) => { + try { + const context = {} as Partial; + const subtitle = [] as string[]; + + const isWeapon = item.isWeapon(); + + if (isWeapon) { + const attack = item.system.attack; + + subtitle.push( + game.i18n!.localize( + CONFIG.COSMERE.attack.types[attack.type].label, + ), + ); + + if (attack.range?.value) { + if (attack.type === AttackType.Melee) { + subtitle[0] += ` + ${attack.range.value}`; + } else { + subtitle[0] += ` (${attack.range.value}${attack.range.units}${ + attack.range.long + ? `/${attack.range.long}${attack.range.units}` + : '' + })`; + } + } + } + + if (item.isPhysical()) { + context.isPhysical = true; + context.quantity = item.system.quantity; + context.weight = { + value: item.system.weight.value, + unit: item.system.weight.unit, + }; + context.price = { + value: item.system.price.value, + unit: item.system.price.unit, + }; + } + + if (item.isEquippable() && !item.system.alwaysEquipped) { + context.isEquippable = true; + context.equipped = item.system.equipped; + + const type = item.system.equip.type; + const hold = item.system.equip.hold; + + context.equip = { + type, + typeLabel: CONFIG.COSMERE.items.equip.types[type].label, + typeIcon: CONFIG.COSMERE.items.equip.types[type].icon, + + hold, + ...(hold + ? { + holdLabel: + CONFIG.COSMERE.items.equip.hold[hold].label, + holdIcon: + CONFIG.COSMERE.items.equip.hold[hold].icon, + } + : {}), + }; + + if (options?.hash?.showEquippedHand !== false) { + if (hold && hold !== HoldType.TwoHanded) { + subtitle.push( + game.i18n!.localize( + CONFIG.COSMERE.items.equip.hold[hold].label, + ), + ); + } + } + } + + if (item.hasTraits()) { + subtitle.push( + ...Array.from(item.system.traits) + .filter((trait) => trait.active) + .map((trait) => trait.id) + .map((traitId) => + isWeapon + ? CONFIG.COSMERE.traits.weaponTraits[ + traitId as WeaponTraitId + ].label + : CONFIG.COSMERE.traits.armorTraits[ + traitId as ArmorTraitId + ].label, + ) + .map((label) => game.i18n!.localize(label)), + ); + } + + if (item.hasActivation()) { + context.hasActivation = true; + context.activation = {}; + + if (item.system.activation.cost?.type) { + context.activation.hasCost = true; + context.activation.cost = { + type: item.system.activation.cost.type, + typeLabel: + CONFIG.COSMERE.action.costs[ + item.system.activation.cost.type + ].label, + value: item.system.activation.cost.value, + }; + } + + // Check if a skill test is configured + if (item.system.activation.skill) { + const skill = item.system.activation.skill; + const attribute = item.system.activation.attribute; + + context.hasSkillTest = true; + context.skillTest = { + skill, + skillLabel: CONFIG.COSMERE.skills[skill].label, + usesDefaultAttribute: + !attribute || + attribute === + CONFIG.COSMERE.skills[skill].attribute, + + ...(attribute + ? { + attribute, + attributeLabel: + CONFIG.COSMERE.attributes[attribute] + .label, + attributeLabelShort: + CONFIG.COSMERE.attributes[attribute] + .labelShort, + } + : {}), + }; + } + + // Check if the activation consumes some resource + if (item.system.activation.consume) { + // Get the actor resource consumed + const resource = item.system.activation.consume.resource; + const consumesActorResource = + item.system.activation.consume.type === + ItemConsumeType.ActorResource; + const consumesItemResource = + item.system.activation.consume.type === + ItemConsumeType.ItemResource; + + context.hasConsume = true; + context.consume = { + type: item.system.activation.consume.type, + value: item.system.activation.consume.value, + consumesActorResource, + consumesItemResource, + consumesItem: + item.system.activation.consume.type === + ItemConsumeType.Item, + + ...(resource + ? { + resource, + resourceLabel: consumesActorResource + ? CONFIG.COSMERE.resources[ + resource as Resource + ].label + : CONFIG.COSMERE.items.resources.types[ + resource as ItemResource + ].label, + } + : {}), + }; + } + + // Check if item has resources + if (item.system.resources) { + context.hasResources = true; + + // Assign resources + context.resources = ( + Object.keys(item.system.resources) as ItemResource[] + ) + .map((resourceType) => { + // Get resource + const resource = + item.system.resources![resourceType]; + if (!resource) return null; + + // Get resource config + const resourceConfig = + CONFIG.COSMERE.items.resources.types[ + resourceType + ]; + + const hasMax = resource.max != null; + const hasRecharge = resource.recharge != null; + + return { + id: resourceType, + label: + resource.value > 1 + ? resourceConfig.labelPlural + : resourceConfig.label, + value: resource.value, + hasMax, + max: hasMax ? resource.max : resource.value, + + hasRecharge, + ...(hasRecharge + ? { + recharge: resource.recharge, + rechargeLabel: + CONFIG.COSMERE.items.resources + .recharge[resource.recharge!] + .label, + } + : {}), + }; + }) + .filter((v) => !!v); + } + } + + if (item.hasDamage() && item.system.damage.formula) { + const skill = item.system.damage.skill; + const attribute = item.system.damage.attribute; + + const hasSkill = !!skill; + const hasAttribute = !!attribute; + + context.hasDamage = true; + context.damage = { + formula: item.system.damage.formula, + formulaData: { + ...item.actor?.getRollData(), + }, + hasSkill, + hasAttribute, + + ...(hasSkill + ? { + skill, + skillLabel: CONFIG.COSMERE.skills[skill].label, + usesDefaultAttribute: + !hasAttribute || + attribute === + CONFIG.COSMERE.skills[skill].attribute, + } + : {}), + + ...(hasAttribute + ? { + attribute, + attributeLabel: + CONFIG.COSMERE.attributes[attribute].label, + attributeLabelShort: + CONFIG.COSMERE.attributes[attribute] + .labelShort, + } + : {}), + + ...(item.system.damage.type + ? { + type: item.system.damage.type, + typeLabel: + CONFIG.COSMERE.damageTypes[ + item.system.damage.type + ].label, + } + : {}), + }; + } + + if (item.hasDescription()) { + if ( + item.system.description?.short && + item.type === ItemType.Action + ) { + subtitle.splice( + 0, + subtitle.length, + item.system.description.short, + ); + } + } + + return { + ...context, + subtitle: subtitle.join(', '), + }; + } catch (err) { + console.error(err); + throw err; + } + }, +); + +Handlebars.registerHelper('damageTypeConfig', (type: DamageType) => { + return CONFIG.COSMERE.damageTypes[type]; +}); + +export async function preloadHandlebarsTemplates() { + const partials = [ + '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', + ]; + + return await loadTemplates( + partials.reduce( + (partials, path) => { + partials[path.split('/').pop()!.replace('.hbs', '')] = path; + return partials; + }, + {} as Record, + ), + ); +} diff --git a/src/system/util/handlebars/types.ts b/src/system/util/handlebars/types.ts new file mode 100644 index 00000000..78f33eb1 --- /dev/null +++ b/src/system/util/handlebars/types.ts @@ -0,0 +1,95 @@ +export interface ItemContextOptions { + showEquippedHand?: boolean; +} + +export interface ItemContext { + subtitle: string; + + isPhysical: boolean; + quantity: number; + weight: Partial<{ + value: number; + unit: string; + }>; + price: Partial<{ + value: number; + unit: string; + }>; + + isEquippable: boolean; + equipped: boolean; + equip: Partial<{ + type: string; + typeLabel: string; + typeIcon: string; + hold: string; + holdLabel: string; + holdIcon: string; + }>; + + hasSkillTest: boolean; + skillTest: Partial<{ + skill: string; + skillLabel: string; + usesDefaultAttribute: boolean; + + attribute: string; + attributeLabel: string; + attributeLabelShort: string; + }>; + + hasActivation: boolean; + activation: Partial<{ + hasCost: boolean; + cost: Partial<{ + type: string; + typeLabel: string; + value: number; + }>; + }>; + + hasConsume: boolean; + consume: Partial<{ + type: string; + value: number; + consumesActorResource: boolean; + consumesItemResource: boolean; + consumesItem: boolean; + + resource: string; + resourceLabel: string; + }>; + + hasResources: boolean; + resources: Partial<{ + id: string; + label: string; + labelPlural: string; + value: number; + hasMax: boolean; + max: number; + + hasRecharge: boolean; + recharge?: string; + rechargeLabel?: string; + }>[]; + + hasDamage: boolean; + damage: Partial<{ + formula: string; + formulaData: object; + hasSkill: boolean; + hasAttribute: boolean; + + skill: string; + skillLabel: string; + usesDefaultAttribute: boolean; + + attribute: string; + attributeLabel: string; + attributeLabelShort: string; + + type: string; + typeLabel: string; + }>; +} diff --git a/src/templates/actors/character-sheet.hbs b/src/templates/actors/character-sheet.hbs index aa9cb30f..1c8ff283 100644 --- a/src/templates/actors/character-sheet.hbs +++ b/src/templates/actors/character-sheet.hbs @@ -1,4 +1,4 @@ -
+
@@ -142,6 +142,32 @@ {{/each}}
+
+
+
+ +
+ {{!-- Navigation --}} + + +
+
+ {{!-- Actions tab --}} +
+ {{> actions sections=items.actions }} +
+ + {{!-- Inventory tab --}} +
+ {{> inventory sections=items.inventory }} +
+
+
+
+
\ No newline at end of file diff --git a/src/templates/actors/parts/actions.hbs b/src/templates/actors/parts/actions.hbs new file mode 100644 index 00000000..e7a473e5 --- /dev/null +++ b/src/templates/actors/parts/actions.hbs @@ -0,0 +1,107 @@ +
    + {{#each sections as |section|}} + +
  1. + {{localize section.label}} + {{#if section.subtitle}} + {{localize section.subtitle}} + {{/if}} +
  2. + + {{#each section.items as |item|}} + {{#with (itemContext item) as |ctx|}} + +
  3. +
    + +
    + + {{item.name}} + {{#if ctx.hasActivation}} + {{#if ctx.activation.hasCost}} + + {{#if (eq ctx.activation.cost.type "act")}} + {{ ctx.activation.cost.value }} + {{else}} + {{cosmereDingbat ctx.activation.cost.type}} + {{/if}} + + {{/if}} + {{/if}} + + {{ctx.subtitle}} +
    +
    + + {{!-- Skill Test --}} + {{#if ctx.hasSkillTest}} +
    + +{{skillMod item.parent ctx.skillTest.skill ctx.skillTest.attribute}} + + + {{localize ctx.skillTest.skillLabel}} + {{#if (not ctx.skillTest.usesDefaultAttribute)}} + + ({{localize ctx.skillTest.attributeLabelShort }}) + + {{/if}} + +
    + {{/if}} + + {{!-- Damage --}} + {{#if ctx.hasDamage}} +
    + {{formulaReplaceData ctx.damage.formula ctx.damage.formulaData}} + {{#if ctx.damage.type}} + {{localize ctx.damage.typeLabel}} + {{/if}} +
    + {{/if}} + + {{!-- Consume --}} + {{#if ctx.hasConsume}} + {{#if ctx.consume.consumesActorResource}} +
    + {{ ctx.consume.value }} {{localize ctx.consume.resourceLabel }} + {{localize "GENERIC.Cost"}} +
    + {{/if}} + {{/if}} + + {{!-- Resources --}} + {{#if ctx.hasResources}} + {{#each ctx.resources as |resource|}} +
    +
    + {{#times resource.value}}{{/times}} + {{#times (sub resource.max resource.value)}}{{/times}} +
    + + {{#if resource.hasRecharge}} + {{localize resource.rechargeLabel}} + {{else}} + {{localize resource.label}} + {{/if}} + +
    + {{/each}} + {{/if}} + + {{!-- Editing --}} + {{!-- Assume editable for now --}} +
    + + + {{!-- Only show the delete button for items that are explicitly an action --}} + {{#if (eq item.type "action")}} + + {{/if}} +
    +
  4. + + {{/with}} + {{/each}} + + {{/each}} +
\ No newline at end of file diff --git a/src/templates/actors/parts/inventory.hbs b/src/templates/actors/parts/inventory.hbs new file mode 100644 index 00000000..048473e5 --- /dev/null +++ b/src/templates/actors/parts/inventory.hbs @@ -0,0 +1,19 @@ +
    + {{#each sections as |section|}} + +
  1. +

    {{localize section.label}}

    +
  2. + + {{#each section.items as |item|}} + +
  3. +
    + +
    +
  4. + + {{/each}} + + {{/each}} +
\ No newline at end of file