diff --git a/.github/readme/token-action-hud.gif b/.github/readme/token-action-hud.gif new file mode 100644 index 0000000..71dba22 Binary files /dev/null and b/.github/readme/token-action-hud.gif differ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..5c304e6 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,45 @@ +name: Release Creation + +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # get part of the tag after the `v` + - name: Extract tag version number + id: get_version + uses: battila7/get-version-action@v2 + + # Substitute the Manifest and Download URLs in the module.json + - name: Substitute Manifest and Download Links For Versioned Ones + id: sub_manifest_link_version + uses: microsoft/variable-substitution@v1 + with: + files: 'module.json' + env: + version: ${{steps.get_version.outputs.version-without-v}} + url: https://github.com/${{github.repository}} + manifest: https://github.com/${{github.repository}}/releases/latest/download/module.json + download: https://github.com/${{github.repository}}/releases/download/${{github.event.release.tag_name}}/module.zip + + # Create a zip file with all files required by the module to add to the release + - run: zip -r ./module.zip module.json readme.md LICENSE languages/ scripts/ + + # Create a release for this specific version + - name: Update Release with Files + id: create_version_release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true # Set this to false if you want to prevent updating existing releases + name: ${{ github.event.release.name }} + draft: ${{ github.event.release.unpublished }} + prerelease: ${{ github.event.release.prerelease }} + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: './module.json, ./module.zip' + tag: ${{ github.event.release.tag_name }} + body: ${{ github.event.release.body }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ac2754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/* +.eslintrc.js +.eslintrc.json +package-lock.json +package.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6c2ff60 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "master" + ] +} \ No newline at end of file diff --git a/languages/br.json b/languages/br.json new file mode 100644 index 0000000..d3f4de2 --- /dev/null +++ b/languages/br.json @@ -0,0 +1,57 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Atributos", + "bonusActions": "Ações de bônus", + "checks": "Testes", + "conditions": "Condições", + "crewActions": "Ações da tripulação", + "lair": "Covil", + "lairActions": "Ações de covil", + "legendary": "Lendária", + "legendaryActions": "Ações Lendárias", + "otherActions": "Outras ações", + "rests": "Descansos", + "saves": "Salvaguarda", + "skills": "Pericias", + "settings": { + "abbreviateSkills": { + "hint": "Habilidades e atributos usarão uma abreviação de três caracteres.", + "name": "Abrevie nomes de habilidades e atributos" + }, + "displaySpellInfo": { + "hint": "As informações do componente de magia, a concentração e o estatus ritual serão anotados ao lado do nome do feitiço.", + "name": "Exibir informação de magias" + }, + "itemMacroReplace": { + "hint": "Select which of the items will be shown in the inventory.", + "name": "Item-Macro: item macro, original item, or both" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "hint": "Mostrar talentos passivos", + "name": "Mostrar talentos passivos" + }, + "showSlowActions": { + "hint": "Mostrar ações que levam mais de uma rodada", + "name": "Mostrar ações lentas" + }, + "showUnchargedItems": { + "hint": "If enabled, items are shown in the HUD even if they have no remaining charges.", + "name": "Show empty items (includes items and spells)" + }, + "showUnequippedItems": { + "hint": "Todos os itens dos NPCs serão exibidos, não apenas itens equipados.", + "name": "Exibir todos os itens de NPCs" + }, + "showUnpreparedSpells": { + "hint": "Se desativado, magias como cantrips, magias inatas, pactos e à vontade precisam ser 'preparadas' através dos detalhes da magia que serão mostrados no HUD. ", + "name": "Exibe todas as magias não preparadas" + }, + } + } + } +} \ No newline at end of file diff --git a/languages/cn.json b/languages/cn.json new file mode 100644 index 0000000..912662d --- /dev/null +++ b/languages/cn.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "属性", + "bonusActions": "獎勵行動", + "checks": "检定", + "conditions": "状态", + "crewActions": "船員行動", + "lair": "巢穴", + "lairActions": "巢穴行動", + "legendary": "传奇", + "legendaryActions": "傳奇動作", + "otherActions": "其他行為", + "rests": "休息", + "skills": "技能", + "settings": { + "abbreviateSkills": { + "name": "缩写技能和属性名", + "hint": "启用后,技能和属性都会用其三字母缩写。" + }, + "displaySpellInfo": { + "name": "显示法术信息", + "hint": "启用后,法术构材信息、专注、仪式状态会在法术名旁标记出来。" + }, + "itemMacroReplace": { + "name": "物品宏:覆盖原有物品", + "hint": "启用后,有宏的物品的物品宏将会覆盖原本的物品,否则两个都会显示。" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "name": "顯示被動功能", + "hint": "顯示被動功能" + }, + "showSlowActions": { + "name": "顯示緩慢的動作", + "hint": "顯示超過一輪的動作" + }, + "showUnchargedItems": { + "name": "显示空物品(包含物品和法术)", + "hint": "启用后,HUD上会显示没有剩余充能的物品。" + }, + "showUnequippedItems": { + "name": "显示所有NPC物品", + "hint": "启用后,NPC的所有物品都会显示,而不仅仅是已装备的物品。" + }, + "showUnpreparedSpells": { + "name": "显示所有未准备法术", + "hint": "禁用后,戏法、天生、契约和随意使用的法术需要在法术详情页设置为‘准备’状态才能显示在HUD上。" + } + } + } + } +} \ No newline at end of file diff --git a/languages/de.json b/languages/de.json new file mode 100644 index 0000000..fd1b99e --- /dev/null +++ b/languages/de.json @@ -0,0 +1,57 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Abilities", + "bonusActions": "Bonus Actions", + "checks": "Checks", + "conditions": "Conditions", + "crewActions": "Crew Actions", + "lair": "Lair", + "lairActions": "Lair Actions", + "legendary": "Legendär", + "legendaryActions": "Legendary Actions", + "otherActions": "Other Actions", + "rests": "Rests", + "skills": "Skills", + "settings": { + "abbreviateSkills": { + "hint": "If enabled, skills and abilities will use a three-character abbreviation.", + "name": "Abbreviate Skill and Ability Names" + }, + "displaySpellInfo": { + "hint": "If enabled, spell component information, concentration, and ritual status will be noted next to the spell name.", + "name": "Display spell information" + }, + "itemMacroReplace": { + "hint": "Select which of the items will be shown in the inventory.", + "name": "Item-Macro: item macro, original item, or both" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "hint": "Show passive feats", + "name": "Show passive feats" + }, + "showSlowActions": { + "hint": "Aktionen anzeigen, die länger als eine Runde dauern", + "name": "Langsame Aktionen anzeigen" + }, + "showUnchargedItems": { + "hint": "If enabled, items are shown in the HUD even if they have no remaining charges.", + "name": "Show empty items (includes items and spells)" + }, + "showUnequippedItems": { + "hint": "If enabled, all items are shown for NPCs, not just equipped items.", + "name": "Show all NPC items" + }, + "showUnpreparedSpells": { + "hint": "If disabled, spells such as cantrips, innate, pact, and at-will spells need to be 'prepared' via the spell details to be shown on the HUD.", + "name": "Show all non-preparable spells" + } + + } + } + } +} \ No newline at end of file diff --git a/languages/en.json b/languages/en.json new file mode 100644 index 0000000..9cfaee9 --- /dev/null +++ b/languages/en.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Abilities", + "bonusActions": "Bonus Actions", + "checks": "Checks", + "conditions": "Conditions", + "crewActions": "Crew Actions", + "lair": "Lair", + "lairActions": "Lair Actions", + "legendary": "Legendary", + "legendaryActions": "Legendary Actions", + "otherActions": "Other Actions", + "rests": "Rests", + "skills": "Skills", + "settings": { + "abbreviateSkills": { + "hint": "Display skill and ability names as a three-character abbreviation", + "name": "Abbreviate Skill and Ability Names" + }, + "displaySpellInfo": { + "hint": "Display spell component information, concentration, and ritual status next to the spell's name", + "name": "Display Spell Information" + }, + "itemMacroReplace": { + "hint": "Select which of the items will be shown in the inventory", + "name": "Item-Macro: item macro, original item, or both" + }, + "showItemsWithoutActivationCosts": { + "hint": "Show items without an activation cost", + "name": "Show Items without Activation Costs" + }, + "showPassiveFeats": { + "hint": "Show passive features", + "name": "Show Passive Features" + }, + "showSlowActions": { + "hint": "Show actions that take longer than one round", + "name": "Show Slow Actions" + }, + "showUnchargedItems": { + "hint": "Show items with no charges remaining", + "name": "Show Uncharged Items" + }, + "showUnequippedItems": { + "hint": "Show unequipped items", + "name": "Show Unequipped Items" + }, + "showUnpreparedSpells": { + "hint": "Show unprepared spells", + "name": "Show Unprepared Spells" + } + } + } + } +} \ No newline at end of file diff --git a/languages/es.json b/languages/es.json new file mode 100644 index 0000000..7063cb3 --- /dev/null +++ b/languages/es.json @@ -0,0 +1,57 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Características", + "bonusActions": "Acciones de bonificación", + "checks": "Pruebas", + "conditions": "Estados", + "crewActions": "Acciones de la tripulación", + "inventory": "Inventario", + "lair": "Guarida", + "lairActions": "Acciones de guarida", + "legendary": "Legendario", + "legendaryActions": "Acciones legendarias", + "otherActions": "Otras acciones", + "rests": "Descansos", + "skills": "Habilidades", + "settings": { + "abbreviateSkills": { + "hint": "Si se activa, las habilidades y características utilizarán una abreviatura de tres letras.", + "name": "Nombres de habilidades y características abreviados" + }, + "displaySpellInfo": { + "hint": "Si se activa, la información de los componentes del conjuro, la concentración y el estado del ritual se indicarán junto al nombre del conjuro.", + "name": "Mostrar información del conjuro" + }, + "itemMacroReplace": { + "hint": "Selecciona qué elementos se mostrarán en el inventario.", + "name": "Objeto-Macro: macro del objeto, objeto original, o ambos" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "hint": "Mostrar rasgos pasivos", + "name": "Mostrar rasgos pasivos" + }, + "showSlowActions": { + "hint": "Mostrar acciones que toman más de una ronda", + "name": "Mostrar acciones lentas" + }, + "showUnchargedItems": { + "hint": "Si se activa, los elementos se muestran en el HUD incluso si no tienen cargas restantes.", + "name": "Mostrar elementos sin carga (incluye objetos y conjuros)" + }, + "showUnequippedItems": { + "hint": "Si se activa, se muestran todos los objetos de los PNJ, no sólo los equipados.", + "name": "Mostrar todos los objetos de los PNJ" + }, + "showUnpreparedSpells": { + "hint": "Si se desactiva, los conjuros como los trucos, innatos, del pactos y los conjuros a voluntad, se deben 'preparar' a través de los detalles del conjuro para que se muestren en el HUD.", + "name": "Mostrar todos los conjuros no preparables" + } + } + } + } +} \ No newline at end of file diff --git a/languages/fr.json b/languages/fr.json new file mode 100644 index 0000000..1b3a227 --- /dev/null +++ b/languages/fr.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Abilités", + "bonusActions": "Actions bonus", + "checks": "Tests", + "conditions": "Conditions", + "crewActions": "Actions de l'équipage", + "lair": "Antre", + "lairActions": "Actions de antre", + "legendary": "Légendaire", + "legendaryActions": "Actions légendaires", + "otherActions": "D'autres actions", + "rests": "Repos", + "skills": "Compétences", + "settings": { + "abbreviateSkills": { + "hint": "Si activée les caractéristiques et les compétences utiliserons une abréviation de 3 caractères.", + "name": "Abréger les noms des caractéristiques et des compétences" + }, + "displaySpellInfo": { + "hint": "Si activée, les informations sur les composantes de sort, la concentration et les rituels seront affichées à côté du nom du sort.", + "name": "Afficher les informations des sorts" + }, + "itemMacroReplace": { + "hint": "Sélectionne quels objets seront montrés dans l'inventaire.", + "name": "Item-Macro: macro d'objet, objet original, ou les deux" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "hint": "Afficher les dons passifs", + "name": "Afficher les dons passifs" + }, + "showSlowActions": { + "hint": "Afficher les actions qui prennent plus d'un tour", + "name": "Afficher les actions lentes" + }, + "showUnchargedItems": { + "hint": "Si activée, les objets seront affichés dans le HUD même s'ils n'ont plus de charges.", + "name": "Afficher les objets vides (y compris les objets et les sorts)" + }, + "showUnequippedItems": { + "hint": "Si activée, tous les objets seront affichés pour les PNJ, et pas seulement les objets équipés.", + "name": "Afficher tous les objets des PNJs" + }, + "showUnpreparedSpells": { + "hint": "Si désactivée, les sorts tels que les tours de magie, innés, de pacte et à volonté doivent être préparés dans le détail du sort pour être affiché dans le HUD.", + "name": "Afficher tous les sorts non préparés" + } + } + } + } +} \ No newline at end of file diff --git a/languages/it.json b/languages/it.json new file mode 100644 index 0000000..635a60d --- /dev/null +++ b/languages/it.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Capacità", + "bonusActions": "Azioni bonus", + "checks": "Caratteristiche", + "conditions": "Condizioni", + "crewActions": "Azioni dell'equipaggio", + "lair": "Tana", + "lairActions": "Azioni di Tana", + "legendary": "Leggendarie", + "legendaryActions": "Azioni Leggendarie", + "otherActions": "Altre azioni", + "rests": "Riposi", + "skills": "Abilità", + "settings": { + "abbreviateSkills": { + "hint": "If enabled, skills and abilities will use a three-character abbreviation.", + "name": "Abbreviate Skill and Ability Names" + }, + "displaySpellInfo": { + "hint": "If enabled, spell component information, concentration, and ritual status will be noted next to the spell name.", + "name": "Display spell information" + }, + "itemMacroReplace": { + "hint": "Select which of the items will be shown in the inventory.", + "name": "Item-Macro: item macro, original item, or both" + }, + "showItemsWithoutActivationCosts": { + "hint": "If enabled, Items that have no activation information will be added to the Inventory list", + "name": "Show Items without Activation costs" + }, + "showPassiveFeats": { + "hint": "Mostra privilegi passivi", + "name": "Mostra privilegi passivi" + }, + "showSlowActions": { + "hint": "Mostra le azioni che richiedono più di un round", + "name": "Mostra azioni lente" + }, + "showUnchargedItems": { + "hint": "If enabled, items are shown in the HUD even if they have no remaining charges.", + "name": "Show empty items (includes items and spells)" + }, + "showUnequippedItems": { + "hint": "If enabled, all items are shown for NPCs, not just equipped items.", + "name": "Show all NPC items" + }, + "showUnpreparedSpells": { + "hint": "If disabled, spells such as cantrips, innate, pact, and at-will spells need to be 'prepared' via the spell details to be shown on the HUD.", + "name": "Show all non-preparable spells" + } + } + } + } +} diff --git a/languages/ja.json b/languages/ja.json new file mode 100644 index 0000000..a89d003 --- /dev/null +++ b/languages/ja.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "能力値", + "bonusActions": "ボーナス・アクション", + "checks": "判定", + "conditions": "状態", + "crewActions": "乗組員の行動", + "lair": "住処", + "lairActions": "住処アクション", + "legendary": "伝説", + "legendaryActions": "伝説的アクション", + "otherActions": "その他のアクション", + "rests": "休憩", + "skills": "技能", + "settings": { + "abbreviateSkills": { + "hint": "技能や能力名をすべて3文字に省略します", + "name": "特技や能力名を省略する" + }, + "displaySpellInfo": { + "hint": "呪文の構成要素、集中、儀式などの追加の情報が名前の隣に表示されます。", + "name": "呪文の情報を表示する。" + }, + "itemMacroReplace": { + "hint": "アイテムマクロを含むマクロはオリジナルを上書きします。それ以外では両方が表示されます。", + "name": "アイテムマクロ:オリジナルを上書き" + }, + "showItemsWithoutActivationCosts": { + "hint": "有効にすると、有効化の情報がないアイテムを所持品リストに追加します。", + "name": "有効化コストのないアイテムの表示" + }, + "showPassiveFeats": { + "hint": "パッシブ特技を表示", + "name": "パッシブ特技を表示" + }, + "showSlowActions": { + "hint": "1ラウンド以上かかるアクションを表示", + "name": "遅いアクションを表示" + }, + "showUnchargedItems": { + "hint": "ホットバーにチャージや使用回数が空のものも表示します。", + "name": "空のアイテムを表示する(呪文含む)" + }, + "showUnequippedItems": { + "hint": "NPCの装備済みのアイテムだけではなく、他の所持しているだけのアイテムも表示します。", + "name": "NPCのすべてのアイテムを表示する" + }, + "showUnpreparedSpells": { + "hint": "これがオフになっていると初級呪文、生得、契約等の呪文はすべて手動で「準備状態」にしないと表示されなくなります。", + "name": "すべての非準備呪文も表示する" + } + } + } + } +} \ No newline at end of file diff --git a/languages/ko.json b/languages/ko.json new file mode 100644 index 0000000..0decb58 --- /dev/null +++ b/languages/ko.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "능력치", + "bonusActions": "보너스 액션", + "checks": "판정", + "conditions": "상태", + "crewActions": "승무원 조치", + "lair": "소굴", + "lairActions": "소굴 액션", + "legendary": "전설적", + "legendaryActions": "전설적 액션", + "otherActions": "다른 행동", + "rests": "휴식", + "skills": "기술", + "settings": { + "abbreviateSkills": { + "name": "기술 및 능력치 이름을 줄임", + "hint": "활성화할 경우 기술 및 능력치는 3 글자 약어로 표시된다." + }, + "displaySpellInfo": { + "name": "주문 정보 표시", + "hint": "활성화할 경우 주문 구성요소 정보, 집중, 의식 상태가 주문 이름 옆에 표시된다." + }, + "itemMacroReplace": { + "name": "아이템 매크로: 원항목 덮어쓰기", + "hint": "활성화될 경우 아이템 매크로가 있는 아이템은 원래 아이템을 덮어쓰게 되며 그렇지 않을 경우 두 옵션이 모두 표시된다." + }, + "showItemsWithoutActivationCosts": { + "hint": "Show items without an activation cost", + "name": "Show Items without Activation Costs" + }, + "showPassiveFeats": { + "name": "패시브 투수 표시", + "hint": "패시브 투수 표시" + }, + "showSlowActions": { + "name": "느린 동작 표시", + "hint": "한 라운드보다 오래 걸리는 작업 표시" + }, + "showUnchargedItems": { + "name": "빈 항목 표시 (아이템과 주문 포함)", + "hint": "활성화할 경우 남은 충전량이 없더라도 HUD에 항목이 표시된다." + }, + "showUnequippedItems": { + "name": "모든 NPC 아이템 표시", + "hint": "활성화할 경우 NPC가 장비한 아이템 뿐만이 아닌, 모든 아이템이 표시된다." + }, + "showUnpreparedSpells": { + "name": "모든 준비 불가능한 주문들 표시", + "hint": "비활성화할 경우 캔트립(소마법), 선천적, 계약, 의지(At-will) 주문들은 주문 상세 정보에서 '준비'하여야 HUD에 표시된다." + } + } + } + } +} \ No newline at end of file diff --git a/languages/pl.json b/languages/pl.json new file mode 100644 index 0000000..ed2a7ab --- /dev/null +++ b/languages/pl.json @@ -0,0 +1,56 @@ +{ + "tokenActionHud": { + "dnd5e": { + "abilities": "Zdolności", + "bonusActions": "Akcje bonusowe", + "checks": "Sprawdzenie", + "conditions": "Stany", + "crewActions": "Akcje załogi", + "lair": "Matecznik", + "lairActions": "Matecznik akcje", + "legendary": "Legendarny", + "legendaryActions": "Legendarny akcje", + "otherActions": "Inne akcje", + "rests": "Odpoczynki", + "skills": "Umiejętności", + "settings": { + "abbreviateSkills": { + "hint": "If enabled, skills and abilities will use a three-character abbreviation.", + "name": "Abbreviate Skill and Ability Names" + }, + "displaySpellInfo": { + "hint": "If enabled, spell component information, concentration, and ritual status will be noted next to the spell name.", + "name": "Display spell information" + }, + "itemMacroReplace": { + "hint": "If enabled, items with an item macro will overwrite their original, otherwise both options will be shown.", + "name": "Item-Macro: overwrite original item" + }, + "showItemsWithoutActivationCosts": { + "hint": "Show items without an activation cost", + "name": "Show Items without Activation Costs" + }, + "showPassiveFeats": { + "hint": "Pokaż funkcje pasywne", + "name": "Pokaż funkcje pasywne" + }, + "showSlowActions": { + "hint": "Pokaż działania, które trwają dłużej niż jedną rundę", + "name": "Pokaż powolne działania" + }, + "showUnchargedItems": { + "hint": "If enabled, items are shown in the HUD even if they have no remaining charges.", + "name": "Show empty items (includes items and spells)" + }, + "showUnequippedItems": { + "hint": "If enabled, all items are shown for NPCs, not just equipped items.", + "name": "Show all NPC items" + }, + "showUnpreparedSpells": { + "hint": "If disabled, spells such as cantrips, innate, pact, and at-will spells need to be 'prepared' via the spell details to be shown on the HUD.", + "name": "Show all non-preparable spells" + } + } + } + } +} \ No newline at end of file diff --git a/module.json b/module.json new file mode 100644 index 0000000..65d633a --- /dev/null +++ b/module.json @@ -0,0 +1,110 @@ +{ + "id": "token-action-hud-dnd5e", + "title": "Token Action HUD D&D 5e", + "description": "Token Action HUD is a repositionable HUD of actions for a selected token", + "authors": [ + { + "name": "Larkinabout", + "url": "https://github.com/Larkinabout" + }, + { + "name": "Drental" + }, + { + "name": "^ and stick" + } + ], + "url": "https://github.com/Larkinabout/fvtt-token-action-hud-dnd5e", + "flags": {}, + "version": "0.1.0", + "compatibility": { + "minimum": "10", + "verified": "10.291" + }, + "scripts": [ + ], + "styles": [ + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "languages/en.json" + }, + { + "lang": "ko", + "name": "Korean", + "path": "languages/ko.json" + }, + { + "lang": "pt-BR", + "name": "Português (Brasil)", + "path": "languages/br.json" + }, + { + "lang": "es", + "name": "Spanish", + "path": "languages/es.json" + }, + { + "lang": "fr", + "name": "French", + "path": "languages/fr.json" + }, + { + "lang": "pl", + "name": "Polish", + "path": "languages/pl.json" + }, + { + "lang": "ja", + "name": "日本語", + "path": "languages/ja.json" + }, + { + "lang": "cn", + "name": "中文", + "path": "languages/cn.json" + }, + { + "lang": "it", + "name": "italiano", + "path": "languages/it.json" + }, + { + "lang": "de", + "name": "Deutsch", + "path": "languages/de.json" + } + ], + "packs": [], + "relationships": { + "systems": [ + { + "id": "dnd5e", + "type": "system" + } + ], + "requires": [ + { + "id": "token-action-hud-core", + "compatibility": [ + { + "minimum": "0.1.0", + "maximum": "0.1.0", + "verified": "0.1.0" + } + ] + } + ], + "optional": [], + "flags": { + "optional": [] + } + }, + "socket": false, + "manifest": "https://github.com/Larkinabout/fvtt-token-action-hud-dnd5e/releases/download/0.1.0/module.zip", + "protected": false, + "coreTranslation": false, + "library": false +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9190a35 --- /dev/null +++ b/readme.md @@ -0,0 +1,48 @@ +# Token Action HUD D&D 5e + +Token Action HUD is a repositionable HUD of actions for a selected token. + +![Token Action HUD](.github/readme/token-action-hud.gif) + +# Features +- Make rolls directly from the HUD instead of opening your character sheet. +- Use items from the HUD or right-click an item to opens its sheet. +- Move the HUD and choose to expand the menus up or down. +- Unlock the HUD to customise categories and subcategories per user, and actions per actor. +- Add your own macros and Journal Entry and Roll Table compendiums. + +# Installation + +## Method 1 +1. On Foundry VTT's **Configuration and Setup** screen, go to **Add-on Modules** +2. Click **Install Module** +3. Search for **Token Action HUD D&D 5e** +4. Click **Install** next to the module listing + +## Method 2 +1. On Foundry VTT's **Configuration and Setup** screen, go to **Add-on Modules** +2. Click **Install Module** +3. In the Manifest URL field, paste: `https://github.com/Larkinabout/fvtt-token-action-hud-dnd5e/releases/latest/download/module.json` +4. Click **Install** next to the pasted Manifest URL + +## Required Modules + +**IMPORTANT** - Token Action HUD D&D 5e requires the [Token Action HUD D&D Core](https://foundryvtt.com/packages/token-action-hud-core) module to be installed. + +## Recommended Modules +Token Action HUD uses either the [Color Picker](https://foundryvtt.com/packages/color-picker), [libThemer](https://foundryvtt.com/packages/lib-themer) or [VTTColorSettings](https://foundryvtt.com/packages/colorsettings) library modules for its color picker settings. Only one is required. + +# Support + +For questions, feature requests or bug reports, please open an issue [here](https://github.com/Larkinabout/fvtt-token-action-hud-core/issues). + +Pull requests are welcome. Please include a reason for the request or create an issue before starting one. + +# Acknowledgements + +First and foremost, thank you to the Community Helpers on Foundry's Discord who provide tireless support for people seeking help with the HUD. +Enormous thanks also goes to the following people for their help in getting the HUD to its current state: Drental, Kekilla, Rainer, Xacus, Giddy, and anyone who has provided advice to any and all of my problems on Discord, as well as all the other developers who make FoundryVTT a joy to use. + +# License + +This Foundry VTT module is licensed under a [Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/) and this work is licensed under [Foundry Virtual Tabletop EULA - Limited License Agreement for module development](https://foundryvtt.com/article/license/). diff --git a/scripts/action-handler.js b/scripts/action-handler.js new file mode 100644 index 0000000..7c31a7d --- /dev/null +++ b/scripts/action-handler.js @@ -0,0 +1,1295 @@ +// System Module Imports +import { getSetting } from './utils.js' + +// Core Module Imports +import { CoreActionHandler, Logger } from './config.js' + +export default class ActionHandler extends CoreActionHandler { + /** + * Build System Actions + * @override + * @param {object} actionList + * @param {object} character + * @param {array} subcategoryIds + * @returns {object} + */ + async buildSystemActions (actionList, character, subcategoryIds) { + const actor = character?.actor + + if (actor?.type === 'character' || actor?.type === 'npc') { + return this._buildCharacterActions(actionList, character, subcategoryIds) + } + if (actor?.type === 'vehicle') { + return this._buildVehicleActions(actionList, character, subcategoryIds) + } + if (!actor) { + return this._buildMultipleTokenActions(actionList, subcategoryIds) + } + + return actionList + } + + /** + * Build Character Actions + * @private + * @param {object} actionList + * @param {object} character + * @param {array} subcategoryIds + * @returns {object} + */ + async _buildCharacterActions (actionList, character, subcategoryIds) { + const inventorySubcategoryIds = subcategoryIds.filter((subcategoryId) => + subcategoryId === 'equipped' || + subcategoryId === 'consumables' || + subcategoryId === 'containers' || + subcategoryId === 'equipment' || + subcategoryId === 'loot' || + subcategoryId === 'tools' || + subcategoryId === 'weapons' || + subcategoryId === 'unequipped' + ) + + const actionSubcategoryIds = subcategoryIds.filter((subcategoryId) => + subcategoryId === 'actions' || + subcategoryId === 'bonus-actions' || + subcategoryId === 'crew-actions' || + subcategoryId === 'lair-actions' || + subcategoryId === 'legendary-actions' || + subcategoryId === 'reactions' || + subcategoryId === 'other-actions' + ) + + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'abilities')) { + this._buildAbilities(actionList, character, 'ability', 'abilities') + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'checks')) { + this._buildAbilities(actionList, character, 'abilityCheck', 'checks') + } + if (actionSubcategoryIds) { + this._buildActions(actionList, character, actionSubcategoryIds) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'combat')) { + this._buildCombat(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'conditions')) { + this._buildConditions(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'features')) { + this._buildFeatures(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'effects')) { + this._buildEffects(actionList, character) + } + if (inventorySubcategoryIds) { + this._buildInventory(actionList, character, inventorySubcategoryIds) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'rests')) { + this._buildRests(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'saves')) { + this._buildAbilities(actionList, character, 'abilitySave', 'saves') + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'skills')) { + this._buildSkills(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'spells')) { + this._buildSpells(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'utility')) { + this._buildUtility(actionList, character) + } + + return actionList + } + + /** + * Build Vehicle Actions + * @private + * @param {object} actionList + * @param {object} character + * @param {array} subcategoryIds + * @returns {object} + */ + async _buildVehicleActions (actionList, character, subcategoryIds) { + const inventorySubcategoryIds = subcategoryIds.filter((subcategoryId) => + subcategoryId === 'consumables' || + subcategoryId === 'equipment' || + subcategoryId === 'tools' || + subcategoryId === 'weapons' + ) + + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'abilities')) { + this._buildAbilities(actionList, character, 'ability', 'abilities') + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'checks')) { + this._buildAbilities(actionList, character, 'abilityCheck', 'checks') + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'combat')) { + this._buildCombat(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'conditions')) { + this._buildConditions(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'effects')) { + this._buildEffects(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'features')) { + this._buildFeatures(actionList, character) + } + if (inventorySubcategoryIds) { + this._buildInventory(actionList, character, inventorySubcategoryIds) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'saves')) { + this._buildAbilities(actionList, character, 'abilitySave', 'saves') + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'utility')) { + this._buildUtility(actionList, character) + } + + return actionList + } + + /** + * Build Multiple Token Actions + * @private + * @param {object} actionList + * @param {array} subcategoryIds + * @returns {object} + */ + async _buildMultipleTokenActions (actionList, subcategoryIds) { + const character = { actor: { id: 'multi' }, token: { id: 'multi' } } + + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'abilities')) { + this._buildAbilities( + actionList, + character, + 'ability', + 'abilities' + ) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'combat')) { + this._buildCombat(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'conditions')) { + this._buildConditions(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'checks')) { + this._buildAbilities( + actionList, + character, + 'abilityCheck', + 'checks' + ) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'rests')) { + this._buildRests(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'saves')) { + this._buildAbilities( + actionList, + character, + 'abilitySave', + 'saves' + ) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'skills')) { + this._buildSkills(actionList, character) + } + if (subcategoryIds.some((subcategoryId) => subcategoryId === 'utility')) { + this._buildUtility(actionList, character) + } + + return actionList + } + + /** + * Build Abilities + * @private + * @param {object} actionList + * @param {object} character + * @param {string} actionType + * @param {string} subcategoryId + */ + _buildAbilities (actionList, character, actionType, subcategoryId) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character.token?.id + const abbr = getSetting('abbreviateSkills') + + const abilities = (actorId === 'multi') ? game.dnd5e.config.abilities : actor.system.abilities + + if (abilities.length === 0) return + + // Get actions + const actions = Object.entries(abilities) + .filter((ability) => abilities[ability[0]].value !== 0) + .map((ability) => { + const id = ability[0] + const abbreviatedName = id.charAt(0).toUpperCase() + id.slice(1) + const name = abbr ? abbreviatedName : game.dnd5e.config.abilities[id] + const encodedValue = [actionType, actorId, tokenId, id].join( + this.delimiter + ) + let icon + if (subcategoryId === 'checks') { + icon = '' + } else { + icon = this._getProficiencyIcon(abilities[id].proficient) + } + return { + id, + name, + encodedValue, + icon, + selected: true + } + }) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Build Actions + * @param {object} actionList The action list + * @param {object} character The actor and/or token + * @param {array} actionSubcategoryIds The action subcategory IDs + */ + _buildActions (actionList, character, actionSubcategoryIds) { + const actor = character?.actor + if (!actor) return + + let items = actor.items + + if (items.length === 0) return + + // Discard slow items + items = this._discardSlowItems(items) + + actionSubcategoryIds.forEach(actionSubcategoryId => { + let activationType = null + switch (actionSubcategoryId) { + case 'actions': + activationType = 'action' + break + case 'bonus-actions': + activationType = 'bonus' + break + case 'crew-actions': + activationType = 'crew' + break + case 'lair-actions': + activationType = 'lair' + break + case 'legendary-actions': + activationType = 'legendary' + break + case 'reactions': + activationType = 'reaction' + break + case 'other-actions': + activationType = '' + break + } + + const itemsByActivationType = items.filter(item => item.system?.activation?.type === activationType) + + this._buildItems(actionList, character, itemsByActivationType, actionSubcategoryId) + }) + } + + /** + * Build Combat + * @private + * @param {object} actionList + * @param {object} character + */ + _buildCombat (actionList, character) { + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actionType = 'utility' + const subcategoryId = 'combat' + + const combatTypes = { + initiative: { id: 'initiative', name: this.i18n('tokenActionHud.rollInitiative') }, + endTurn: { id: 'endTurn', name: this.i18n('tokenActionHud.endTurn') } + } + + // Delete endTurn for multiple tokens + if (game.combat?.current?.tokenId !== tokenId) delete combatTypes.endTurn + + // Get actions + const actions = Object.entries(combatTypes).map((combatType) => { + const id = combatType[1].id + const name = combatType[1].name + const encodedValue = [actionType, actorId, tokenId, id].join(this.delimiter) + let info1 = '' + let cssClass = '' + if (combatType[0] === 'initiative' && game.combat) { + const tokenIds = canvas.tokens.controlled.map((token) => token.id) + // const combatants = tokenIds.map((id) => + // game.combat.combatants.find((combatant) => combatant.tokenId === id) + // ).filter((combatant) => combatant) + const combatants = game.combat.combatants.filter((combatant) => tokenIds.includes(combatant.tokenId)) + + // Get initiative for single token + if (combatants.length === 1) { + const currentInitiative = combatants[0].initiative + info1 = currentInitiative + } + + const active = combatants.length > 0 && (combatants.every((combatant) => combatant?.initiative)) ? ' active' : '' + cssClass = `toggle${active}` + } + return { + id, + name, + encodedValue, + info1, + cssClass, + selected: true + } + }) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Build Conditions + * @private + * @param {object} actionList + * @param {object} character + */ + _buildConditions (actionList, character) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actors = (actorId === 'multi') ? this._getActors() : [actor] + const actionType = 'condition' + const subcategoryId = 'conditions' + + const conditions = CONFIG.statusEffects.filter((condition) => condition.id !== '') + + if (conditions.length === 0) return + + const actions = conditions.map((condition) => { + const id = condition.id + const name = this.i18n(condition.label) + const encodedValue = [actionType, actorId, tokenId, id].join( + this.delimiter + ) + const active = actors.every((actor) => { + const effects = actor.effects + return effects + .map((effect) => effect.flags?.core?.statusId) + .some((statusId) => statusId === id) + }) + ? ' active' + : '' + const cssClass = `toggle${active}` + const img = condition.icon + return { + id, + name, + encodedValue, + img, + cssClass, + selected: true + } + }) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Build Effects + * @private + * @param {object} actionList + * @param {object} character + */ + _buildEffects (actionList, character) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actionType = 'effect' + const parentSubcategoryId = 'effects' + const subcategoryList = [] + + const effectTypes = { + temporary: { label: this.i18n('tokenActionHud.temporary'), effects: [] }, + passive: { label: this.i18n('tokenActionHud.passive'), effects: [] } + } + + // Group effects by type + const effects = actor.effects + + if (effects.length === 0) return + + effects.forEach((effect) => { + if (effect.isTemporary) { + effectTypes.temporary.effects.push(effect) + } else { + effectTypes.passive.effects.push(effect) + } + }) + + Object.entries(effectTypes).forEach((effectType) => { + const subcategoryId = `effects_${effectType[0]}` + const subcategoryName = effectType[1].label + const subcategory = this.initializeEmptySubcategory(subcategoryId, parentSubcategoryId, subcategoryName, 'system') + const actions = effectType[1].effects.map((effect) => { + const id = effect.id + const name = effect.label + const encodedValue = [actionType, actorId, tokenId, id].join(this.delimiter) + const active = (!effect.disabled) ? ' active' : '' + const cssClass = `toggle${active}` + const img = effect.icon + return { + id, + name, + encodedValue, + cssClass, + img, + selected: true + } + }) + + this.addToSubcategoryList(subcategoryList, subcategoryId, subcategory, actions) + }) + + this.addSubcategoriesToActionList(actionList, subcategoryList, parentSubcategoryId) + } + + /** + * Build Features + * @private + * @param {object} actionList + * @param {object} character + */ + _buildFeatures (actionList, character) { + const actor = character?.actor + const actionType = 'feature' + const parentSubcategoryId = 'features' + const subcategoryList = [] + + const featureTypes = { + active: { label: this.i18n('tokenActionHud.active'), items: [] }, + passive: { label: this.i18n('tokenActionHud.passive'), items: [] }, + lair: { label: this.i18n('tokenActionHud.dnd5e.lair'), items: [] }, + legendary: { label: this.i18n('tokenActionHud.dnd5e.legendary'), items: [] }, + actions: { label: this.i18n('tokenActionHud.actions'), items: [] }, + features: { label: this.i18n('tokenActionHud.features'), items: [] }, + reactions: { label: this.i18n('tokenActionHud.reactions'), items: [] } + } + + // Get feat items + let feats = actor.items.filter((item) => item.type === 'feat') + + if (feats.length === 0) return + + // Discard slow items + feats = this._discardSlowItems(feats) + + // Sort items + feats = this.sortItems(feats) + + // Group feats by type + feats.forEach((item) => { + const activationType = item.system.activation.type + if (actor.type === 'vehicle') { + if ( + activationType && + activationType !== 'none' && + activationType !== 'reaction' + ) { + featureTypes.actions.items.push(item) + } + if (!activationType || activationType === 'none') { + featureTypes.features.items.push(item) + } + if (activationType === 'reaction') { + featureTypes.reactions.items.push(item) + } + } + if (actor.type === 'character' || actor.type === 'npc') { + if ( + activationType && activationType !== '' && + activationType !== 'lair' && + activationType !== 'legendary' + ) { + featureTypes.active.items.push(item) + } + if (!activationType || activationType === '') { + featureTypes.passive.items.push(item) + } + if (activationType === 'lair') { + featureTypes.lair.items.push(item) + } + if (activationType === 'legendary') { + featureTypes.legendary.items.push(item) + } + } + }) + + // Delete unneeded feature types + if (actor.type === 'vehicle') { + delete featureTypes.active + delete featureTypes.passive + delete featureTypes.lair + delete featureTypes.legendary + } + if (actor.type === 'character' || actor.type === 'npc') { + if (!getSetting('showPassiveFeats')) { delete featureTypes.passive } + delete featureTypes.actions + delete featureTypes.features + delete featureTypes.reactions + } + + // Loop feature types + Object.entries(featureTypes).forEach((featureType) => { + const subcategoryId = `features_${featureType[0]}` + const subcategoryName = featureType[1].label + const subcategory = this.initializeEmptySubcategory( + subcategoryId, + parentSubcategoryId, + subcategoryName, + 'system' + ) + + // Get items + const items = featureType[1].items + + // Get actions + const actions = items.map((item) => this._getAction(actionType, character, item)) + + this.addToSubcategoryList(subcategoryList, subcategoryId, subcategory, actions) + }) + + this.addSubcategoriesToActionList(actionList, subcategoryList, parentSubcategoryId) + } + + /** + * Build Inventory + * @private + * @param {object} actionList + * @param {object} character + * @param {array} inventorySubcategoryIds + */ + _buildInventory (actionList, character, inventorySubcategoryIds) { + const actor = character?.actor + + const validItems = this._discardSlowItems( + actor.items.filter((item) => item.system.quantity > 0) + ) + + if (validItems.length === 0) return + + const sortedItems = this.sortItems(validItems) + + // Equipped + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'equipped')) { + const equipped = this._getActiveEquipment( + sortedItems.filter((item) => item.system.equipped) + ) + this._buildItems(actionList, character, equipped, 'equipped') + } + + // Unequipped + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'unequipped')) { + const unequipped = this._getActiveEquipment( + sortedItems.filter((item) => !item.system.equipped) + ) + this._buildItems(actionList, character, unequipped, 'unequipped') + } + + // Consumables + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'consumables')) { + const allConsumables = this._getActiveEquipment( + sortedItems.filter((item) => item.type === 'consumable') + ) + const consumables = this._discardExpendedItems(allConsumables) + + this._buildItems(actionList, character, consumables, 'consumables') + } + + // Equipped Inventory + let equippedItems + if (getSetting('showUnequippedItems')) { + equippedItems = sortedItems.filter((item) => + item.type !== 'consumable' && + item.type !== 'spell' && + item.type !== 'feat' + ) + } else { + equippedItems = sortedItems.filter((item) => item.type !== 'consumable' && item.system.equipped) + } + const activeEquipped = this._getActiveEquipment(equippedItems) + + // Containers + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'containers')) { + const containers = activeEquipped.filter((item) => item.type === 'backpack') + this._buildItems(actionList, character, containers, 'containers') + } + + // Equipment + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'equipment')) { + const equipment = activeEquipped.filter((item) => item.type === 'equipment') + this._buildItems(actionList, character, equipment, 'equipment') + } + + // Loot + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'loot')) { + const loot = activeEquipped.filter((item) => item.type === 'loot') + this._buildItems(actionList, character, loot, 'loot') + } + + // Tools + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'tools')) { + const tools = validItems.filter((item) => item.type === 'tool') + this._buildItems(actionList, character, tools, 'tools') + } + + // Weapons + if (inventorySubcategoryIds.some((subcategoryId) => subcategoryId === 'weapons')) { + const weapons = activeEquipped.filter((item) => item.type === 'weapon') + this._buildItems(actionList, character, weapons, 'weapons') + } + } + + /** + * Build Items + * @private + * @param {object} actionList + * @param {object} character + * @param {object} items + * @param {string} subcategoryId + */ + _buildItems (actionList, character, items, subcategoryId) { + if (items.length === 0) return + + const actionType = 'item' + const actions = items.map((item) => + this._getAction(actionType, character, item) + ) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Get Active Equipment + * @private + * @param {object} equipment + * @returns {object} + */ + _getActiveEquipment (equipment) { + let activeEquipment = [] + if (!getSetting('showItemsWithoutActivationCosts')) { + const activationTypes = Object.keys(game.dnd5e.config.abilityActivationTypes) + .filter((at) => at !== 'none') + + activeEquipment = equipment.filter((e) => { + const activation = e.system.activation + if (!activation) return false + + return activationTypes.includes(e.system.activation.type) + }) + } else { + activeEquipment = equipment + } + + return activeEquipment + } + + /** + * Get Action + * @private + * @param {string} actionType + * @param {object} character + * @param {object} entity + * @returns {object} + */ + _getAction (actionType, character, entity) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const id = entity.id ?? entity._id + let name = entity.name + if ( + entity?.system?.recharge && + !entity?.system?.recharge?.charged && + entity?.system?.recharge?.value + ) { + name += ` (${this.i18n('tokenActionHud.recharge')})` + } + const encodedValue = [actionType, actorId, tokenId, id].join( + this.delimiter + ) + const img = this.getImage(entity) + const icon = this._getIcon(entity?.system?.activation?.type) + const itemInfo = this._getItemInfo(actor, entity) + const info1 = itemInfo.info1 + const info2 = itemInfo.info2 + const info3 = itemInfo.info3 + return { + id, + name, + encodedValue, + img, + icon, + info1, + info2, + info3, + selected: true + } + } + + /** + * Get Item Info + * @private + * @param {object} actor + * @param {object} item + * @returns {object} + */ + _getItemInfo (actor, item) { + return { + info1: this._getQuantityData(item) ?? '', + info2: this._getUsesData(item) ?? '', + info3: this._getConsumeData(item, actor) ?? '' + } + } + + /** + * Build Rests + * @param {object} actionList + * @param {object} character + */ + _buildRests (actionList, character) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actors = (actorId === 'multi') ? this._getActors() : [actor] + if (!actors.every((actor) => actor.type === 'character')) return + const actionType = 'utility' + const subcategoryId = 'rests' + + const restTypes = { + shortRest: { name: this.i18n('tokenActionHud.shortRest') }, + longRest: { name: this.i18n('tokenActionHud.longRest') } + } + + const actions = Object.entries(restTypes) + .map((restType) => { + const id = restType[0] + const name = restType[1].name + const encodedValue = [actionType, actorId, tokenId, id].join(this.delimiter) + return { + id, + name, + encodedValue, + selected: true + } + }) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Build Skills + * @private + * @param {object} actionList + * @param {object} character + */ + _buildSkills (actionList, character) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actionType = 'skill' + const abbr = getSetting('abbreviateSkills') + + // Get skills + const skills = (actorId === 'multi') ? game.dnd5e.config.skills : actor.system.skills + + if (skills.length === 0) return + + // Get actions + const actions = Object.entries(skills) + .map((skill) => { + try { + const id = skill[0] + const abbreviatedName = id.charAt(0).toUpperCase() + id.slice(1) + const name = abbr ? abbreviatedName : game.dnd5e.config.skills[id].label + const encodedValue = [actionType, actorId, tokenId, id].join( + this.delimiter + ) + const icon = this._getProficiencyIcon(skills[id].value) + return { + id, + name, + encodedValue, + icon + } + } catch (error) { + Logger.error(skill) + return null + } + }) + .filter((skill) => !!skill) + + this.addActionsToActionList(actionList, actions, 'skills') + } + + /** + * Build Spells + * @param {object} actionList + * @param {object} character + */ + _buildSpells (actionList, character) { + const actor = character?.actor + const actionType = 'spell' + const parentSubcategoryId = 'spells' + + // Get spell items + let spells = actor.items.filter((item) => item.type === 'spell') + + if (spells.length === 0) return + + // Discard slow spells + spells = this._discardSlowItems(spells) + + // Discard expended spells + spells = this._discardExpendedItems(spells) + + // Discard non-preprared spells + spells = this._discardNonPreparedSpells(actor, spells) + + // Sport spells by level + spells = this._sortSpellsByLevel(spells) + + // Reverse sort spells by level + const spellSlotInfo = Object.entries(actor.system.spells).sort((a, b) => { + return b[0].toUpperCase().localeCompare(a[0].toUpperCase(), undefined, { + sensitivity: 'base' + }) + }) + + // Go through spells and if higher available slots exist, mark spell slots available at lower levels. + const pactInfo = spellSlotInfo.find((spell) => spell[0] === 'pact') + + let slotsAvailable = false + spellSlotInfo + .filter((spell) => spell[0] !== 'maxLevel') + .forEach((spell) => { + if (spell[0].startsWith('spell')) { + if (!slotsAvailable && spell[1].max > 0 && spell[1].value > 0) { + slotsAvailable = true + } + + if (!slotsAvailable && spell[0] === 'spell' + pactInfo[1]?.level) { + if (pactInfo[1].max > 0 && pactInfo[1].value > 0) { slotsAvailable = true } + } + + spell[1].slotsAvailable = slotsAvailable + } else { + if (!spell[1]) spell[1] = {} + + spell[1].slotsAvailable = !spell[1].max || spell[1].value > 0 + } + }) + + const pactIndex = spellSlotInfo.findIndex((spell) => spell[0] === 'pact') + + if (!spellSlotInfo[pactIndex][1].slotsAvailable) { + const pactSpellEquivalent = spellSlotInfo.findIndex( + (spell) => spell[0] === 'spell' + pactInfo[1].level + ) + spellSlotInfo[pactIndex][1].slotsAvailable = + spellSlotInfo[pactSpellEquivalent][1].slotsAvailable + } + + // Get preparation modes/levels + const spellLevelIds = [ + ...new Set( + spells.map((spell) => { + const prepId = spell.system.preparation.mode + const levelId = spell.system.level + const isPrep = !!(prepId === 'pact' || prepId === 'atwill' || prepId === 'innate') + if (isPrep) { + return prepId + } else { + return levelId + } + }) + ) + ] + + // Get spell levels + const spellLevels = spellLevelIds.map((spellLevel) => { + const isPrep = !!( + spellLevel === 'pact' || + spellLevel === 'atwill' || + spellLevel === 'innate' + ) + const id = isPrep ? spellLevel : `spell${spellLevel}` + const name = isPrep + ? game.dnd5e.config.spellPreparationModes[spellLevel] + : spellLevel === 0 + ? this.i18n('tokenActionHud.cantrips') + : `${this.i18n('tokenActionHud.level')} ${spellLevel}` + return [id, name] + }) + + const subcategoryList = [] + + for (const spellLevel of spellLevels) { + const spellLevelId = `spells_${spellLevel[0]}` + const spellLevelName = spellLevel[1] + const isPrep = !!( + spellLevelId === 'pact' || + spellLevelId === 'atwill' || + spellLevelId === 'innate' + ) + const levelInfo = spellSlotInfo.find((level) => level[0] === spellLevel[0])?.[1] + const slots = levelInfo?.value + const max = levelInfo?.max + const slotsAvailable = levelInfo?.slotsAvailable + const ignoreSlotsAvailable = getSetting('showUnchargedItems') + + if (max && !(slotsAvailable || ignoreSlotsAvailable)) continue + + const subcategory = this.initializeEmptySubcategory( + spellLevelId, + parentSubcategoryId, + spellLevelName, + 'system' + ) + if (max > 0) subcategory.info1 = `${slots}/${max}` + + // Get actions + const actions = [] + for (const spell of spells) { + const spellSpellLevelId = isPrep + ? `spells_${spell.system.preparation.mode}` + : `spells_spell${spell.system.level}` + + if (spellSpellLevelId === spellLevelId) { + const action = this._getAction(actionType, character, spell) + if (getSetting('displaySpellInfo')) { + this._addSpellInfo(spell, action) + } + actions.push(action) + } + } + + this.addToSubcategoryList( + subcategoryList, + spellLevelId, + subcategory, + actions + ) + } + + this.addSubcategoriesToActionList(actionList, subcategoryList, 'spells') + } + + /** + * Sort Spells by Level + * @private + * @param {object} spells + * @returns {object} + */ + _sortSpellsByLevel (spells) { + const result = Object.values(spells) + result.sort((a, b) => { + if (a.system.level === b.system.level) { + return a.name + .toUpperCase() + .localeCompare(b.name.toUpperCase(), undefined, { + sensitivity: 'base' + }) + } + return a.system.level - b.system.level + }) + + return result + } + + /** + * Add Spell Info + * @param {object} spell + * @param {object} action + */ + _addSpellInfo (spell, action) { + const components = spell.system.components + + action.info1 = '' + action.info2 = '' + action.info3 = '' + + if (components?.vocal) { + action.info1 += this.i18n('DND5E.ComponentVerbal').charAt(0).toUpperCase() + } + + if (components?.somatic) { + action.info1 += this.i18n('DND5E.ComponentSomatic').charAt(0).toUpperCase() + } + + if (components?.material) { + action.info1 += this.i18n('DND5E.ComponentMaterial').charAt(0).toUpperCase() + } + + if (components?.concentration) { + action.info2 += this.i18n('DND5E.Concentration').charAt(0).toUpperCase() + } + + if (components?.ritual) { + action.info3 += this.i18n('DND5E.Ritual').charAt(0).toUpperCase() + } + } + + /** + * Build Utility + * @private + * @param {object} actionList + * @param {object} character + */ + _buildUtility (actionList, character) { + const actor = character?.actor + const actorId = character?.actor?.id + const tokenId = character?.token?.id + const actors = (actorId === 'multi') ? this._getActors() : [actor] + if (!actors.every((actor) => actor.type === 'character')) return + const actionType = 'utility' + const subcategoryId = 'utility' + + const utilityTypes = { + deathSave: { name: this.i18n('DND5E.DeathSave') }, + inspiration: { name: this.i18n('tokenActionHud.inspiration') } + } + + // Delete 'deathSave' for multiple tokens + if (actorId === 'multi' || actor.system.attributes.hp.value > 0) delete utilityTypes.deathSave + + // Get actions + const actions = Object.entries(utilityTypes) + .map((utilityType) => { + const id = utilityType[0] + const name = utilityType[1].name + const encodedValue = [actionType, actorId, tokenId, id].join(this.delimiter) + let cssClass = '' + if (utilityType[0] === 'inspiration') { + const active = actors.every((actor) => actor.system.attributes?.inspiration) + ? ' active' + : '' + cssClass = `toggle${active}` + } + return { + id, + name, + encodedValue, + cssClass, + selected: true + } + }) + + this.addActionsToActionList(actionList, actions, subcategoryId) + } + + /** + * Get Actors + * @private + * @returns {object} + */ + _getActors () { + const allowedTypes = ['character', 'npc'] + const actors = canvas.tokens.controlled.map((token) => token.actor) + if (actors.every((actor) => allowedTypes.includes(actor.type))) { + return actors + } + } + + /** + * Get Quantity + * @private + * @param {object} item + * @returns {string} + */ + _getQuantityData (item) { + const quantity = item?.system?.quantity + return (quantity > 1) ? quantity : '' + } + + /** + * Get Uses + * @private + * @param {object} item + * @returns {string} + */ + _getUsesData (item) { + let result = '' + + const uses = item?.system?.uses + if (!uses) return result + + result = uses.value === 0 && uses.max ? '0' : uses.value + + if (uses.max > 0) { + result += `/${uses.max}` + } + + return result + } + + /** + * Get Consume + * @private + * @param {object} item + * @param {object} actor + * @returns {string} + */ + _getConsumeData (item, actor) { + let result = '' + + const consumeType = item?.system?.consume?.type + if (consumeType && consumeType !== '') { + const consumeId = item.system.consume.target + const parentId = consumeId.substr(0, consumeId.lastIndexOf('.')) + if (consumeType === 'attribute') { + const target = getProperty(actor, `system.${parentId}`) + + if (target) { + result = target.value ?? 0 + if (target.max) result += `/${target.max}` + } + } + + if (consumeType === 'charges') { + const consumeId = item.system.consume.target + const target = actor.items.get(consumeId) + const uses = target?.system.uses + if (uses?.value) { + result = uses.value + if (uses.max) result += `/${uses.max}` + } + } + + if (!(consumeType === 'attribute' || consumeType === 'charges')) { + const consumeId = item.system.consume.target + const target = actor.items.get(consumeId) + const quantity = target?.system.quantity + if (quantity) { + result = quantity + } + } + } + + return result + } + + /** + * Discard Slow Items + * @private + * @param {object} items + * @returns {object} + */ + _discardSlowItems (items) { + let result + + if (!getSetting('showSlowActions')) { + result = items.filter((item) => { + return ( + !item.system.activation || + !( + item.system.activation.type === 'minute' || + item.system.activation.type === 'hour' || + item.system.activation.type === 'day' + ) + ) + }) + } + + return result || items + } + + /** + * Discard Non-Prepared Spells + * @private + * @param {object} spells + * @returns {object} + */ + _discardNonPreparedSpells (actor, spells) { + if (actor.type !== 'character' && getSetting('showUnequippedItems')) return + + const nonpreparableSpells = Object.keys(game.dnd5e.config.spellPreparationModes) + .filter((p) => p !== 'prepared') + + let result = spells + if (getSetting('showUnpreparedSpells')) { + result = spells.filter((spell) => { + return ( + spell.system.preparation.prepared || + nonpreparableSpells.includes(spell.system.preparation.mode) || + spell.system.level === 0 + ) + }) + } else { + result = spells.filter((spell) => spell.system.preparation.prepared) + } + + return result + } + + /** + * Discard Expended Items + * @private + * @param {object} items + * @returns {object} + */ + _discardExpendedItems (items) { + if (getSetting('showUnchargedItems')) return items + + return items.filter((item) => { + const uses = item.system.uses + // Assume something with no uses is unlimited in its use + if (!uses) return true + + // If it has a max but value is 0, don't return + if (uses.max > 0 && !uses.value) return false + + return true + }) + } + + /** + * Get Proficiency Icon + * @param {string} level + * @returns {string} + */ + _getProficiencyIcon (level) { + const icons = { + 0: '', + 0.5: '', + 1: '', + 2: '' + } + return icons[level] + } + + /** + * Get Icon + * @param {object} action + * @returns {string} + */ + _getIcon (action) { + const img = { + bonus: '', + crew: '', + day: '', + hour: '', + lair: '', + minute: '', + legendary: '', + reaction: '', + special: '' + } + return img[action] + } +} diff --git a/scripts/config.js b/scripts/config.js new file mode 100644 index 0000000..0e0ea1e --- /dev/null +++ b/scripts/config.js @@ -0,0 +1,21 @@ +import { importClass } from './utils.js' + +export const coreModuleVersion = '0.1.0' +const coreModulePath = '../../token-action-hud-core/' +const coreActionHandlerFile = `${coreModulePath}scripts/action-handlers/action-handler.js` +const coreActionListExtenderFile = `${coreModulePath}scripts/action-handlers/action-list-extender.js` +const coreCategoryManagerFile = `${coreModulePath}scripts/category-manager.js` +const corePreRollHandlerFile = `${coreModulePath}scripts/roll-handlers/pre-roll-handler.js` +const coreRollHandlerFile = `${coreModulePath}scripts/roll-handlers/roll-handler.js` +const coreSystemManagerFile = `${coreModulePath}scripts/system-manager.js` +const loggerFile = `${coreModulePath}scripts/logger.js` +const coreUtilsFile = `${coreModulePath}scripts/utilities/utils.js` + +export const CoreActionHandler = await importClass(coreActionHandlerFile) +export const CoreActionListExtender = await importClass(coreActionListExtenderFile) +export const CoreCategoryManager = await importClass(coreCategoryManagerFile) +export const CorePreRollHandler = await importClass(corePreRollHandlerFile) +export const CoreRollHandler = await importClass(coreRollHandlerFile) +export const CoreSystemManager = await importClass(coreSystemManagerFile) +export const Logger = await importClass(loggerFile) +export const CoreUtils = await import(coreUtilsFile) diff --git a/scripts/item-macro-options.js b/scripts/item-macro-options.js new file mode 100644 index 0000000..b985a78 --- /dev/null +++ b/scripts/item-macro-options.js @@ -0,0 +1,5 @@ +export const ItemMacroOptions = { + SHOW_BOTH: 'tokenActionHud.settings.itemMacroReplace.showBoth', + SHOW_ITEM_MACRO: 'tokenActionHud.settings.itemMacroReplace.showItemMacro', + SHOW_ORIGINAL_ITEM: 'tokenActionHud.settings.itemMacroReplace.showOriginal' +} diff --git a/scripts/magic-items-extender.js b/scripts/magic-items-extender.js new file mode 100644 index 0000000..57eb452 --- /dev/null +++ b/scripts/magic-items-extender.js @@ -0,0 +1,92 @@ +import { CoreActionListExtender } from './config.js' + +export default class MagicItemActionListExtender extends CoreActionListExtender { + /** + * Extend the action list + * @param {object} actionList The action list + * @param {object} character The actor and/or token + */ + extendActionList (actionList, character) { + const actorId = character?.actor?.id + const tokenId = character?.token?.id + if (!actorId) return + + const actor = MagicItems.actor(actorId) + + if (!actor) return + + const magicItems = actor.items ?? [] + + if (magicItems.length === 0) return + + const parentSubcategoryId = 'magic-items' + const subcategoryList = [] + + magicItems.forEach((magicItem) => { + if (magicItem.attuned && !this._isItemAttuned(magicItem)) return + if (magicItem.equipped && !this._isItemEquipped(magicItem)) return + + const subcategoryId = `magic-items_${magicItem.id}` + const subcategoryName = magicItem.name + const subcategory = this.initializeEmptySubcategory(subcategoryId, parentSubcategoryId, subcategoryName, 'system') + subcategory.info1 = `${magicItem.uses}/${magicItem.charges}` + + const actions = magicItem.ownedEntries.map((entry) => { + const effect = entry.item + const id = effect.id + const name = effect.name + const encodedValue = [ + 'magicItem', + actorId, + tokenId, + `${magicItem.id}>${id}` + ].join('|') + const img = this.getImage(effect) + const info1 = effect.consumption + let info2 = '' + if (effect.baseLevel) { + info2 = `${this.i18n('tokenActionHud.levelAbbreviation')} ${effect.baseLevel}` + } + return { + id, + name, + encodedValue, + img, + info1, + info2 + } + }) + + this.addToSubcategoryList(subcategoryList, subcategoryId, subcategory, actions) + }) + + this.addSubcategoriesToActionList(actionList, subcategoryList, parentSubcategoryId) + } + + /** + * Whether the magic item is equipped or not + * @param {object} magicItem The item + * @returns {boolean} + */ + _isItemEquipped (magicItem) { + return magicItem.item.system.equipped + } + + /** + * Whether the magic items is attuned or not + * @param {object} magicItem The item + * @returns {boolean} + */ + _isItemAttuned (magicItem) { + const itemData = magicItem.item.system + + if (itemData.attunement) { + const attuned = CONFIG.DND5E.attunementTypes?.ATTUNED ?? 2 + return itemData.attunement === attuned + } + + if (itemData.attuned) return itemData.attuned + + return false + } +} diff --git a/scripts/roll-handler-obsidian.js b/scripts/roll-handler-obsidian.js new file mode 100644 index 0000000..b72b690 --- /dev/null +++ b/scripts/roll-handler-obsidian.js @@ -0,0 +1,55 @@ +import { RollHandler } from './roll-handler.js' + +export class RollHandlerObsidian extends RollHandler { + /** + * Roll Ability Test + * @override + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollAbilityTest (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + OBSIDIAN.Items.roll(actor, { roll: 'abl', abl: actionId }) + } + + /** + * Roll Ability Save + * @override + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollAbilitySave (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + OBSIDIAN.Items.roll(actor, { roll: 'save', save: actionId }) + } + + /** + * Roll Skill + * @override + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollSkill (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + OBSIDIAN.Items.roll(actor, { roll: 'skl', skl: actionId }) + } + + /** + * Use Item + * @override + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _useItem (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + OBSIDIAN.Items.roll(actor, { roll: 'item', id: actionId }) + } +} diff --git a/scripts/roll-handler.js b/scripts/roll-handler.js new file mode 100644 index 0000000..49e04f6 --- /dev/null +++ b/scripts/roll-handler.js @@ -0,0 +1,319 @@ +// Core Module Imports +import { CoreRollHandler } from './config.js' + +export class RollHandler extends CoreRollHandler { + /** + * Handle Action Event + * @override + * @param {object} event + * @param {string} encodedValue + */ + async doHandleActionEvent (event, encodedValue) { + const payload = encodedValue.split('|') + + if (payload.length !== 4) { + super.throwInvalidValueErr() + } + + const actionType = payload[0] + const actorId = payload[1] + const tokenId = payload[2] + const actionId = payload[3] + + if (tokenId === 'multi' && actionId !== 'toggleCombat') { + for (const token of canvas.tokens.controlled) { + const tokenActorId = token.actor?.id + const tokenTokenId = token.id + await this._handleMacros( + event, + actionType, + tokenActorId, + tokenTokenId, + actionId + ) + } + } else { + await this._handleMacros(event, actionType, actorId, tokenId, actionId) + } + } + + /** + * Handle Macros + * @private + * @param {object} event + * @param {string} actionType + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + async _handleMacros (event, actionType, actorId, tokenId, actionId) { + switch (actionType) { + case 'ability': + this._rollAbility(event, actorId, tokenId, actionId) + break + case 'abilityCheck': + this._rollAbilityTest(event, actorId, tokenId, actionId) + break + case 'abilitySave': + this._rollAbilitySave(event, actorId, tokenId, actionId) + break + case 'condition': + if (!tokenId) return + await this._toggleCondition(event, tokenId, actionId) + break + case 'effect': + await this._toggleEffect(event, actorId, tokenId, actionId) + break + case 'feature': + case 'item': + case 'spell': + case 'weapon': + if (this.isRenderItem()) this.doRenderItem(actorId, tokenId, actionId) + else this._useItem(event, actorId, tokenId, actionId) + break + case 'magicItem': + this._rollMagicItem(event, actorId, tokenId, actionId) + break + case 'skill': + this._rollSkill(event, actorId, tokenId, actionId) + break + case 'utility': + await this._performUtilityMacro(event, actorId, tokenId, actionId) + break + default: + break + } + } + + /** + * Roll Ability + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollAbility (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + actor.rollAbility(actionId, { event }) + } + + /** + * Roll Ability Save + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollAbilitySave (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + actor.rollAbilitySave(actionId, { event }) + } + + /** + * Roll Ability Test + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollAbilityTest (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + actor.rollAbilityTest(actionId, { event }) + } + + /** + * Roll Magic Item + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollMagicItem (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + const actionParts = actionId.split('>') + + const itemId = actionParts[0] + const magicEffectId = actionParts[1] + + const magicItemActor = MagicItems.actor(actor.id) + + magicItemActor.roll(itemId, magicEffectId) + + Hooks.callAll('forceUpdateTokenActionHUD') + } + + /** + * Roll Skill + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + _rollSkill (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + actor.rollSkill(actionId, { event }) + } + + /** + * Use Item + * @private + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + * @returns {object} + */ + _useItem (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + const item = super.getItem(actor, actionId) + + if (this._needsRecharge(item)) { + item.rollRecharge() + return + } + + return item.use({ event }) + } + + /** + * Needs Recharge + * @private + * @param {object} item + * @returns {boolean} + */ + _needsRecharge (item) { + return ( + item.system.recharge && + !item.system.recharge.charged && + item.system.recharge.value + ) + } + + /** + * Perform Utility Macro + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + async _performUtilityMacro (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + const token = super.getToken(tokenId) + + switch (actionId) { + case 'deathSave': + actor.rollDeathSave({ event }) + break + case 'endTurn': + if (!token) break + if (game.combat?.current?.tokenId === tokenId) { + await game.combat?.nextTurn() + } + break + case 'initiative': + await this._rollInitiative(actorId) + break + case 'inspiration': { + const update = !actor.system.attributes.inspiration + actor.update({ 'data.attributes.inspiration': update }) + break + } + case 'longRest': + actor.longRest() + break + case 'shortRest': + actor.shortRest() + break + case 'toggleCombat': + if (canvas.tokens.controlled.length === 0) break + await canvas.tokens.controlled[0].toggleCombat() + Hooks.callAll('forceUpdateTokenActionHUD') + break + case 'toggleVisibility': + if (!token) break + token.toggleVisibility() + Hooks.callAll('forceUpdateTokenActionHUD') + break + } + } + + /** + * Roll Initiative + * @private + * @param {string} actorId + * @param {string} tokenId + */ + async _rollInitiative (actorId, tokenId) { + const actor = super.getActor(actorId, tokenId) + + await actor.rollInitiative({ createCombatants: true }) + + Hooks.callAll('forceUpdateTokenActionHUD') + } + + /** + * Toggle Condition + * @private + * @param {object} event + * @param {string} tokenId + * @param {string} actionId + * @param {object} effect + */ + async _toggleCondition (event, tokenId, actionId, effect = null) { + const token = super.getToken(tokenId) + const isRightClick = this.isRightClick(event) + if (game.dfreds && effect?.flags?.isConvenient) { + const effectLabel = effect.label + game.dfreds.effectInterface._toggleEffect(effectLabel) + } else { + const condition = this._getCondition(actionId) + if (!condition) return + + isRightClick + ? await token.toggleEffect(condition, { overlay: true }) + : await token.toggleEffect(condition) + } + + Hooks.callAll('forceUpdateTokenActionHUD') + } + + /** + * Get Condition + * @private + * @param {string} actionId + * @returns {object} + */ + _getCondition (actionId) { + return CONFIG.statusEffects.find((effect) => effect.id === actionId) + } + + /** + * Toggle Effect + * @param {object} event + * @param {string} actorId + * @param {string} tokenId + * @param {string} actionId + */ + async _toggleEffect (event, actorId, tokenId, actionId) { + const actor = super.getActor(actorId, tokenId) + const effects = 'find' in actor.effects.entries ? actor.effects.entries : actor.effects + const effect = effects.find((e) => e.id === actionId) + + if (!effect) return + + const statusId = effect.flags.core?.statusId + if (tokenId && statusId) { + await this._toggleCondition(event, tokenId, statusId, effect) + return + } + + await effect.update({ disabled: !effect.disabled }) + + Hooks.callAll('forceUpdateTokenActionHUD') + } +} diff --git a/scripts/settings.js b/scripts/settings.js new file mode 100644 index 0000000..b3ef300 --- /dev/null +++ b/scripts/settings.js @@ -0,0 +1,156 @@ +import { ItemMacroOptions } from './item-macro-options.js' + +export function register (updateFunc) { + const appName = 'token-action-hud-dnd5e' + + if (game.modules.get('itemacro')?.active) { + game.settings.register(appName, 'itemMacroReplace', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.itemMacroReplace.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.itemMacroReplace.hint' + ), + scope: 'client', + config: true, + type: String, + choices: { + showBoth: game.i18n.localize(ItemMacroOptions.SHOW_BOTH), + showItemMacro: game.i18n.localize(ItemMacroOptions.SHOW_ITEM_MACRO), + showOriginal: game.i18n.localize(ItemMacroOptions.SHOW_ORIGINAL_ITEM) + }, + default: 'showBoth', + onChange: (value) => { + updateFunc(value) + } + }) + } + + game.settings.register(appName, 'abbreviateSkills', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.abbreviateSkills.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.abbreviateSkills.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: false, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showSlowActions', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showSlowActions.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showSlowActions.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: true, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showPassiveFeats', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showPassiveFeats.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showPassiveFeats.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: true, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'displaySpellInfo', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.displaySpellInfo.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.displaySpellInfo.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: true, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showUnchargedItems', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnchargedItems.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnchargedItems.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: false, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showUnequippedItems', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnequippedItems.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnequippedItems.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: true, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showUnpreparedSpells', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnpreparedSpells.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showUnpreparedSpells.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: true, + onChange: (value) => { + updateFunc(value) + } + }) + + game.settings.register(appName, 'showItemsWithoutActivationCosts', { + name: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showItemsWithoutActivationCosts.name' + ), + hint: game.i18n.localize( + 'tokenActionHud.dnd5e.settings.showItemsWithoutActivationCosts.hint' + ), + scope: 'client', + config: true, + type: Boolean, + default: false, + onChange: (value) => { + updateFunc(value) + } + }) +} diff --git a/scripts/system-manager.js b/scripts/system-manager.js new file mode 100644 index 0000000..4288636 --- /dev/null +++ b/scripts/system-manager.js @@ -0,0 +1,226 @@ +// System Module Imports +import ActionHandler from './action-handler.js' +import MagicItemActionListExtender from './magic-items-extender.js' +import { RollHandler as Core } from './roll-handler.js' +import { RollHandlerObsidian as Obsidian5e } from './roll-handler-obsidian.js' +import * as systemSettings from './settings.js' + +// Core Module Imports +import { CoreSystemManager, CoreCategoryManager } from './config.js' + +export default class SystemManager extends CoreSystemManager { + /** @override */ + doGetCategoryManager (user) { + const categoryManager = new CoreCategoryManager(user) + return categoryManager + } + + /** @override */ + doGetActionHandler (character, categoryManager) { + const actionHandler = new ActionHandler(character, categoryManager) + + if (CoreSystemManager.isModuleActive('magicitems')) { actionHandler.addFurtherActionHandler(new MagicItemActionListExtender()) } + + return actionHandler + } + + /** @override */ + getAvailableRollHandlers () { + let coreTitle = 'Core D&D5e' + + if (CoreSystemManager.isModuleActive('midi-qol')) { coreTitle += ` [supports ${CoreSystemManager.getModuleTitle('midi-qol')}]` } + + const choices = { core: coreTitle } + CoreSystemManager.addHandler(choices, 'obsidian') + + return choices + } + + /** @override */ + doGetRollHandler (handlerId) { + let rollHandler + switch (handlerId) { + case 'obsidian': + rollHandler = new Obsidian5e() + break + case 'core': + default: + rollHandler = new Core() + break + } + + return rollHandler + } + + /** @override */ + doRegisterSettings (updateFunc) { + systemSettings.register(updateFunc) + } + + /** @override */ + async doRegisterDefaultFlags () { + const defaults = { + default: { + categories: { + inventory: { + id: 'inventory', + title: this.i18n('DND5E.Inventory'), + subcategories: { + inventory_weapons: { + id: 'weapons', + title: this.i18n('DND5E.ItemTypeWeaponPl'), + type: 'system' + }, + inventory_equipment: { + id: 'equipment', + title: this.i18n('DND5E.ItemTypeEquipmentPl'), + type: 'system' + }, + inventory_consumables: { + id: 'consumables', + title: this.i18n('DND5E.ItemTypeConsumablePl'), + type: 'system' + }, + inventory_tools: { + id: 'tools', + title: this.i18n('DND5E.ItemTypeToolPl'), + type: 'system' + }, + inventory_containers: { + id: 'containers', + title: this.i18n('DND5E.ItemTypeContainerPl'), + type: 'system' + }, + inventory_loot: { + id: 'loot', + title: this.i18n('DND5E.ItemTypeLootPl'), + type: 'system' + } + } + }, + features: { + id: 'features', + title: this.i18n('DND5E.Features'), + subcategories: { + features_features: { + id: 'features', + title: this.i18n('DND5E.Features'), + type: 'system' + } + } + }, + spells: { + id: 'spells', + title: this.i18n('DND5E.ItemTypeSpellPl'), + subcategories: { + spells_spells: { + id: 'spells', + title: this.i18n('DND5E.ItemTypeSpellPl'), + type: 'system' + } + } + }, + attributes: { + id: 'attributes', + title: this.i18n('DND5E.Attributes'), + subcategories: { + attributes_abilities: { + id: 'abilities', + title: this.i18n('tokenActionHud.dnd5e.abilities'), + type: 'system' + }, + attributes_skills: { + id: 'skills', + title: this.i18n('tokenActionHud.dnd5e.skills'), + type: 'system' + } + } + }, + effects: { + id: 'effects', + title: this.i18n('DND5E.Effects'), + subcategories: { + effects_effects: { + id: 'effects', + title: this.i18n('DND5E.Effects'), + type: 'system' + } + } + }, + conditions: { + id: 'conditions', + title: this.i18n('tokenActionHud.dnd5e.conditions'), + subcategories: { + conditions_conditions: { + id: 'conditions', + title: this.i18n('tokenActionHud.dnd5e.conditions'), + type: 'system' + } + } + }, + utility: { + id: 'utility', + title: this.i18n('tokenActionHud.utility'), + subcategories: { + utility_combat: { + id: 'combat', + title: this.i18n('tokenActionHud.combat'), + type: 'system' + }, + utility_token: { + id: 'token', + title: this.i18n('tokenActionHud.token'), + type: 'system' + }, + utility_rests: { + id: 'rests', + title: this.i18n('tokenActionHud.dnd5e.rests'), + type: 'system' + }, + utility_utility: { + id: 'utility', + title: this.i18n('tokenActionHud.utility'), + type: 'system' + } + } + } + }, + subcategories: [ + { id: 'abilities', title: this.i18n('tokenActionHud.dnd5e.abilities'), type: 'system' }, + { id: 'actions', title: this.i18n('DND5E.ActionPl'), type: 'system' }, + { id: 'bonus-actions', title: this.i18n('tokenActionHud.dnd5e.bonusActions'), type: 'system' }, + { id: 'checks', title: this.i18n('tokenActionHud.dnd5e.checks'), type: 'system' }, + { id: 'combat', title: this.i18n('tokenActionHud.combat'), type: 'system' }, + { id: 'conditions', title: this.i18n('tokenActionHud.dnd5e.conditions'), type: 'system' }, + { id: 'consumables', title: this.i18n('DND5E.ItemTypeConsumablePl'), type: 'system' }, + { id: 'containers', title: this.i18n('DND5E.ItemTypeContainerPl'), type: 'system' }, + { id: 'crew-actions', title: this.i18n('tokenActionHud.dnd5e.crewActions'), type: 'system' }, + { id: 'effects', title: this.i18n('DND5E.Effects'), vtype: 'system' }, + { id: 'equipment', title: this.i18n('DND5E.ItemTypeEquipmentPl'), type: 'system' }, + { id: 'equipped', title: this.i18n('DND5E.Equipped'), type: 'system' }, + { id: 'features', title: this.i18n('DND5E.Features'), type: 'system' }, + { id: 'lair-actions', title: this.i18n('tokenActionHud.dnd5e.lairActions'), type: 'system' }, + { id: 'legendary-actions', title: this.i18n('tokenActionHud.dnd5e.legendaryActions'), type: 'system' }, + { id: 'loot', title: this.i18n('DND5E.ItemTypeLootPl'), type: 'system' }, + { id: 'other-actions', title: this.i18n('tokenActionHud.dnd5e.otherActions'), type: 'system' }, + { id: 'reactions', title: this.i18n('DND5E.ReactionPl'), type: 'system' }, + { id: 'rests', title: this.i18n('tokenActionHud.dnd5e.rests'), type: 'system' }, + { id: 'saves', title: this.i18n('DND5E.ClassSaves'), type: 'system' }, + { id: 'skills', title: this.i18n('tokenActionHud.dnd5e.skills'), type: 'system' }, + { id: 'spells', title: this.i18n('DND5E.ItemTypeSpellPl'), type: 'system' }, + { id: 'token', title: this.i18n('tokenActionHud.token'), type: 'system' }, + { id: 'tools', title: this.i18n('DND5E.ItemTypeToolPl'), type: 'system' }, + { id: 'weapons', title: this.i18n('DND5E.ItemTypeWeaponPl'), type: 'system' }, + { id: 'unequipped', title: this.i18n('DND5E.Unequipped'), type: 'system' }, + { id: 'utility', title: this.i18n('tokenActionHud.utility'), type: 'system' } + ] + } + } + // If the 'Magic Items' module is active, then add a subcategory for it + if (game.modules.get('magicitems').active) { + defaults.default.subcategories.push({ id: 'magic-items', title: this.i18n('tokenActionHud.magicItems'), type: 'system' }) + defaults.default.subcategories.sort((a, b) => a.id.localeCompare(b.id)) + } + await game.user.update({ flags: { 'token-action-hud-core': defaults } }) + } +} diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 0000000..b56d92e --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,43 @@ +import { Logger } from './config.js' + +const namespace = 'token-action-hud-dnd5e' + +/** + * Get setting value + * @param {string} key The key + * @param {string=null} defaultValue The default value + * @returns The setting value + */ +export function getSetting (key, defaultValue = null) { + let value = defaultValue ?? null + if (game.settings.settings.get(`${namespace}.${key}`)) { + value = game.settings.get(namespace, key) + } else { + Logger.debug(`Setting '${key}' not found`) + } + return value +} + +/** + * Set setting value + * @param {string} key The key + * @param {string} value The value + */ +export function setSetting (key, value) { + if (game.settings.settings.get(`${namespace}.${key}`)) { + value = game.settings.set(namespace, key, value) + Logger.debug(`Setting '${key}' set to '${value}'`) + } else { + Logger.debug(`Setting '${key}' not found`) + } +} + +/** + * Import a default class + * @param {string} path The path of the file + */ +export async function importClass (path) { + return await import(path).then(module => { + return module.default + }) +}