From 63da2ef664df7b89d7110911acd1d7ee91c80397 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Sat, 7 Mar 2026 16:09:00 +0100 Subject: [PATCH] ENhance actor sheet with roll messages --- adventures-with-emmy.mjs | 39 ++++ assets/conditions/edge.svg | 4 + assets/conditions/hampered.svg | 4 + assets/conditions/inhibited.svg | 4 + assets/conditions/jumbled.svg | 4 + assets/conditions/mishap.svg | 4 + assets/conditions/prone.svg | 4 + assets/conditions/quickened.svg | 4 + assets/conditions/slowed.svg | 4 + assets/conditions/vulnerable.svg | 4 + lang/en.json | 41 +++- module/applications/hud/action-handler.js | 199 ++++++++++++++++++ module/applications/hud/constants.js | 14 ++ module/applications/hud/defaults.js | 54 +++++ module/applications/hud/roll-handler.js | 92 ++++++++ module/applications/hud/settings.js | 8 + module/applications/hud/system-manager.js | 46 ++++ .../applications/sheets/base-actor-sheet.mjs | 3 + .../applications/sheets/character-sheet.mjs | 66 +++++- module/applications/sheets/creature-sheet.mjs | 2 +- module/documents/actor.mjs | 160 +++++++++++++- module/documents/roll.mjs | 18 +- module/models/ability.mjs | 2 + module/models/creature.mjs | 1 + templates/ability-use.hbs | 47 +++++ templates/ability.hbs | 4 + templates/character-biography.hbs | 16 +- templates/character-equipment.hbs | 2 + templates/character-header.hbs | 3 + templates/character-main.hbs | 32 ++- templates/chat-message.hbs | 21 ++ templates/creature-eureka.hbs | 4 + 32 files changed, 888 insertions(+), 22 deletions(-) create mode 100644 assets/conditions/edge.svg create mode 100644 assets/conditions/hampered.svg create mode 100644 assets/conditions/inhibited.svg create mode 100644 assets/conditions/jumbled.svg create mode 100644 assets/conditions/mishap.svg create mode 100644 assets/conditions/prone.svg create mode 100644 assets/conditions/quickened.svg create mode 100644 assets/conditions/slowed.svg create mode 100644 assets/conditions/vulnerable.svg create mode 100644 module/applications/hud/action-handler.js create mode 100644 module/applications/hud/constants.js create mode 100644 module/applications/hud/defaults.js create mode 100644 module/applications/hud/roll-handler.js create mode 100644 module/applications/hud/settings.js create mode 100644 module/applications/hud/system-manager.js create mode 100644 templates/ability-use.hbs diff --git a/adventures-with-emmy.mjs b/adventures-with-emmy.mjs index ec1ec23..e5f2a44 100644 --- a/adventures-with-emmy.mjs +++ b/adventures-with-emmy.mjs @@ -4,6 +4,8 @@ globalThis.SYSTEM = SYSTEM import * as models from "./module/models/_module.mjs" import * as documents from "./module/documents/_module.mjs" import * as applications from "./module/applications/_module.mjs" +import { SystemManager } from "./module/applications/hud/system-manager.js" +import { MODULE, REQUIRED_CORE_MODULE_VERSION } from "./module/applications/hud/constants.js" Hooks.once("init", function () { console.info("Adventures with Emmy | Initializing System") @@ -72,6 +74,13 @@ Hooks.once("init", function () { CONFIG.Dice.rolls.push(documents.AwERoll) CONFIG.Combatant.documentClass = documents.AwECombatant + // Register conditions as status effects (token HUD overlays) + CONFIG.statusEffects = Object.values(SYSTEM.CONDITIONS).map(c => ({ + id: c.id, + name: c.label, + img: `systems/fvtt-adventures-with-emmy/assets/conditions/${c.id}.svg` + })) + // Handlebars helpers Handlebars.registerHelper("abs", (value) => Math.abs(value ?? 0)) }) @@ -79,3 +88,33 @@ Hooks.once("init", function () { Hooks.once("ready", function () { console.info("Adventures with Emmy | System Ready") }) + +// Token Action HUD Core integration (only fires if the module is active) +Hooks.on('tokenActionHudCoreApiReady', async () => { + const module = { + api: { + requiredCoreModuleVersion: REQUIRED_CORE_MODULE_VERSION, + SystemManager + } + } + Hooks.call('tokenActionHudSystemReady', module) +}) + +// Chat message: "Roll Damage" button on weapon attack messages +Hooks.on('renderChatMessageHTML', (message, html) => { + const btn = html.querySelector('.roll-damage-btn') + if (!btn) return + btn.addEventListener('click', async () => { + const actorId = btn.dataset.actorId + const actor = game.actors.get(actorId) + if (!actor) return ui.notifications.warn('Actor not found') + await actor.rollDamage({ + name: btn.dataset.itemName, + img: btn.dataset.itemImg, + system: { + damageFormula: btn.dataset.damageFormula, + damageType: btn.dataset.damageType + } + }) + }) +}) diff --git a/assets/conditions/edge.svg b/assets/conditions/edge.svg new file mode 100644 index 0000000..365e3a1 --- /dev/null +++ b/assets/conditions/edge.svg @@ -0,0 +1,4 @@ + + + E + diff --git a/assets/conditions/hampered.svg b/assets/conditions/hampered.svg new file mode 100644 index 0000000..8756f18 --- /dev/null +++ b/assets/conditions/hampered.svg @@ -0,0 +1,4 @@ + + + H + diff --git a/assets/conditions/inhibited.svg b/assets/conditions/inhibited.svg new file mode 100644 index 0000000..84f411d --- /dev/null +++ b/assets/conditions/inhibited.svg @@ -0,0 +1,4 @@ + + + I + diff --git a/assets/conditions/jumbled.svg b/assets/conditions/jumbled.svg new file mode 100644 index 0000000..69e7e78 --- /dev/null +++ b/assets/conditions/jumbled.svg @@ -0,0 +1,4 @@ + + + J + diff --git a/assets/conditions/mishap.svg b/assets/conditions/mishap.svg new file mode 100644 index 0000000..b89602c --- /dev/null +++ b/assets/conditions/mishap.svg @@ -0,0 +1,4 @@ + + + ! + diff --git a/assets/conditions/prone.svg b/assets/conditions/prone.svg new file mode 100644 index 0000000..f64df87 --- /dev/null +++ b/assets/conditions/prone.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/assets/conditions/quickened.svg b/assets/conditions/quickened.svg new file mode 100644 index 0000000..95daa56 --- /dev/null +++ b/assets/conditions/quickened.svg @@ -0,0 +1,4 @@ + + + Q + diff --git a/assets/conditions/slowed.svg b/assets/conditions/slowed.svg new file mode 100644 index 0000000..49c33ca --- /dev/null +++ b/assets/conditions/slowed.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/assets/conditions/vulnerable.svg b/assets/conditions/vulnerable.svg new file mode 100644 index 0000000..d7e4bd1 --- /dev/null +++ b/assets/conditions/vulnerable.svg @@ -0,0 +1,4 @@ + + + V + diff --git a/lang/en.json b/lang/en.json index d253834..37ffea8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -28,6 +28,11 @@ "AWEMMY.Condition.Quickened": "Quickened", "AWEMMY.Condition.Slowed": "Slowed", "AWEMMY.Condition.Vulnerable": "Vulnerable", + "AWEMMY.Condition.Panel": "Conditions", + "AWEMMY.Roll.ConditionBonus": "Condition", + "AWEMMY.Kit.Use": "Use Kit", + "AWEMMY.Kit.Used": "{name} used (charges: {value}/{max})", + "AWEMMY.Kit.Depleted": "{name} has no charges remaining!", "AWEMMY.Ability.Cost.Free": "Free", "AWEMMY.Ability.TypeLabel": "Type", "AWEMMY.Ability.Type.Field": "Field", @@ -95,17 +100,27 @@ "AWEMMY.Creature.Hints": "Hints", "AWEMMY.Creature.Threshold1": "Threshold 1", "AWEMMY.Creature.Threshold2": "Threshold 2", + "AWEMMY.Creature.Threshold3": "Threshold 3", "AWEMMY.Ability.CostLabel": "Cost", "AWEMMY.Ability.Frequency": "Frequency", "AWEMMY.Ability.Requirements": "Requirements", "AWEMMY.Ability.Trigger": "Trigger", "AWEMMY.Ability.Traits": "Traits", "AWEMMY.Ability.AddTrait": "Add trait...", + "AWEMMY.Ability.FlowPointCost": "Flow Point Cost", + "AWEMMY.Ability.Use": "Use Ability", + "AWEMMY.Ability.AlreadyUsed": "{name} has already been used today!", + "AWEMMY.Ability.NotEnoughFP": "{name} requires {cost} Flow Point(s), but you only have {current}!", + "AWEMMY.Ability.DailyReset": "Daily Preparations", + "AWEMMY.Ability.DailyResetHint": "Reset all once-per-day abilities (daily preparations)", + "AWEMMY.Ability.DailyResetDone": "Daily preparations complete — abilities reset.", "AWEMMY.Weapon.Range": "Range", "AWEMMY.Weapon.Damage": "Damage", "AWEMMY.Weapon.DamageType": "Damage Type", "AWEMMY.Weapon.AttackAttribute": "Attack Attribute", "AWEMMY.Weapon.AttackRoll": "Attack Roll", + "AWEMMY.Weapon.DamageRoll": "Damage Roll", + "AWEMMY.Weapon.NoDamageFormula": "This weapon has no damage formula.", "AWEMMY.Weapon.Hit": "Hit", "AWEMMY.Weapon.CriticalHit": "Critical Hit!", "AWEMMY.Field.KeyAttribute": "Key Attribute", @@ -117,5 +132,29 @@ "AWEMMY.Kit.Field": "Field", "AWEMMY.Kit.Charges": "Charges", "AWEMMY.Equipment.Quantity": "Quantity", - "AWEMMY.Equipment.Weight": "Weight" + "AWEMMY.Equipment.Weight": "Weight", + "AWEMMY.Sheet.EditItem": "Edit", + "AWEMMY.Sheet.DeleteItem": "Delete", + "AWEMMY.Character.ItemReplaced": "Replaced existing item: {name}", + "AWEMMY.Rest.LongRest": "Rest & Daily Preparations", + "AWEMMY.Rest.LongRestConfirm": "{name} takes an 8-hour rest and performs daily preparations.", + "AWEMMY.Rest.Rest": "Rest", + "AWEMMY.Rest.Cancel": "Cancel", + "AWEMMY.Rest.LongRestTitle": "{name} rests for 8 hours", + "AWEMMY.Rest.HPRestored": "Recovered {amount} HP (now {max}/{max})", + "AWEMMY.Rest.AbilitiesReset": "{count} daily ability(ies) reset", + "AWEMMY.Rest.KitsReplenished": "{count} kit(s) replenished", + "AWEMMY.Rest.AlreadyRested": "Already at full health — no recovery needed.", + + "AWEMMY.TAH.Stats": "Stats", + "AWEMMY.TAH.Combat": "Combat", + "AWEMMY.TAH.Items": "Items", + "AWEMMY.TAH.Utility": "Utility", + "AWEMMY.TAH.Weapons": "Weapons", + "AWEMMY.TAH.Abilities": "Abilities", + "AWEMMY.TAH.Kits": "Kits", + "AWEMMY.TAH.HP": "HP", + "AWEMMY.TAH.Flow": "Flow Points", + "AWEMMY.TAH.LongRest": "Long Rest", + "AWEMMY.TAH.EndTurn": "End Turn" } diff --git a/module/applications/hud/action-handler.js b/module/applications/hud/action-handler.js new file mode 100644 index 0000000..49f8572 --- /dev/null +++ b/module/applications/hud/action-handler.js @@ -0,0 +1,199 @@ +import { SYSTEM } from '../../config/system.mjs' + +export let ActionHandler = null + +Hooks.once('tokenActionHudCoreApiReady', async (coreModule) => { + ActionHandler = class ActionHandler extends coreModule.api.ActionHandler { + + /** @override */ + async buildSystemActions(groupIds) { + this.actors = (!this.actor) ? this._getActors() : [this.actor] + this.actorType = this.actor?.type + + if (this.actorType === 'character') { + await this.#buildCharacterActions() + } else if (this.actorType === 'creature') { + await this.#buildCreatureActions() + } + } + + async #buildCharacterActions() { + await this.#buildAttributes() + await this.#buildHP() + await this.#buildFlow() + await this.#buildWeapons() + await this.#buildConditions() + await this.#buildAbilities() + await this.#buildKits() + await this.#buildUtility() + } + + async #buildCreatureActions() { + await this.#buildAttributes() + await this.#buildHP() + } + + async #buildAttributes() { + const actions = [] + const attrKeys = ['agility', 'fitness', 'awareness', 'influence'] + const labelKeys = { + agility: 'AWEMMY.Attribute.Agility', + fitness: 'AWEMMY.Attribute.Fitness', + awareness: 'AWEMMY.Attribute.Awareness', + influence: 'AWEMMY.Attribute.Influence' + } + for (const key of attrKeys) { + const attr = this.actor.system.attributes?.[key] + if (!attr) continue + const mod = attr.mod ?? 0 + const modText = mod >= 0 ? `+${mod}` : `${mod}` + actions.push({ + name: coreModule.api.Utils.i18n(labelKeys[key]), + id: key, + info1: { text: modText }, + encodedValue: ['attribute', key].join(this.delimiter) + }) + } + await this.addActions(actions, { id: 'attributes', type: 'system' }) + } + + async #buildHP() { + const hp = this.actor.system.hp + if (!hp) return + const tooltip = { content: `${hp.value} / ${hp.max}`, direction: 'LEFT' } + const actions = [ + { + name: `${hp.value} / ${hp.max}`, + id: 'hp_display', + tooltip, + encodedValue: ['hp', 'display'].join(this.delimiter) + }, + { + name: '+', + id: 'hp_add', + tooltip, + encodedValue: ['hp', 'add'].join(this.delimiter) + }, + { + name: '−', + id: 'hp_sub', + tooltip, + encodedValue: ['hp', 'sub'].join(this.delimiter) + } + ] + await this.addActions(actions, { id: 'hp', type: 'system' }) + } + + async #buildFlow() { + const fp = this.actor.system.flowPoints + if (fp === undefined) return + const tooltip = { content: `FP: ${fp.value}`, direction: 'LEFT' } + const actions = [ + { + name: `${fp.value} FP`, + id: 'flow_display', + tooltip, + encodedValue: ['flow', 'display'].join(this.delimiter) + }, + { + name: '+', + id: 'flow_add', + tooltip, + encodedValue: ['flow', 'add'].join(this.delimiter) + }, + { + name: '−', + id: 'flow_sub', + tooltip, + encodedValue: ['flow', 'sub'].join(this.delimiter) + } + ] + await this.addActions(actions, { id: 'flow', type: 'system' }) + } + + async #buildWeapons() { + const weapons = this.actor.itemTypes?.weapon ?? [] + for (const weapon of weapons) { + const attrId = weapon.system.attackAttribute + const attr = this.actor.system.attributes?.[attrId] + const mod = attr?.mod ?? 0 + const modText = mod >= 0 ? `+${mod}` : `${mod}` + + const groupData = { id: `weapon_${weapon.id}`, name: weapon.name, type: 'system' } + this.addGroup(groupData, { id: 'weapons', type: 'system' }, true) + + const actions = [{ + name: weapon.name, + id: `weapon_${weapon.id}`, + info1: { text: modText }, + encodedValue: ['weapon', weapon.id].join(this.delimiter) + }] + await this.addActions(actions, { id: `weapon_${weapon.id}`, type: 'system' }) + } + } + + async #buildConditions() { + const actions = [] + for (const [key, cond] of Object.entries(SYSTEM.CONDITIONS)) { + const isActive = this.actor.statuses?.has(key) ?? false + actions.push({ + name: coreModule.api.Utils.i18n(cond.label), + id: key, + info1: { text: isActive ? '✓' : '' }, + encodedValue: ['condition', key].join(this.delimiter) + }) + } + await this.addActions(actions, { id: 'conditions', type: 'system' }) + } + + async #buildAbilities() { + const abilities = this.actor.itemTypes?.ability ?? [] + const actions = [] + for (const ability of abilities) { + const sys = ability.system + const costLabel = game.i18n.localize(SYSTEM.ABILITY_COST[sys.cost]?.label ?? sys.cost) + const isUsed = sys.usedToday + actions.push({ + name: ability.name, + id: ability.id, + info1: { text: isUsed ? `${costLabel} ✓` : costLabel }, + encodedValue: ['ability', ability.id].join(this.delimiter) + }) + } + await this.addActions(actions, { id: 'abilities', type: 'system' }) + } + + async #buildKits() { + const kits = this.actor.itemTypes?.kit ?? [] + const actions = [] + for (const kit of kits) { + const charges = kit.system.charges + const chargesText = `${charges.value}/${charges.max}` + actions.push({ + name: kit.name, + id: kit.id, + info1: { text: chargesText }, + encodedValue: ['kit', kit.id].join(this.delimiter) + }) + } + await this.addActions(actions, { id: 'kits', type: 'system' }) + } + + async #buildUtility() { + const actions = [] + actions.push({ + name: coreModule.api.Utils.i18n('AWEMMY.TAH.LongRest'), + id: 'longRest', + encodedValue: ['utility', 'longRest'].join(this.delimiter) + }) + if (game.combat?.current?.tokenId === this.token?.id) { + actions.push({ + name: coreModule.api.Utils.i18n('AWEMMY.TAH.EndTurn'), + id: 'endTurn', + encodedValue: ['utility', 'endTurn'].join(this.delimiter) + }) + } + await this.addActions(actions, { id: 'utility', type: 'system' }) + } + } +}) diff --git a/module/applications/hud/constants.js b/module/applications/hud/constants.js new file mode 100644 index 0000000..3c2ef9b --- /dev/null +++ b/module/applications/hud/constants.js @@ -0,0 +1,14 @@ +export const MODULE = { ID: 'token-action-hud-adventures-with-emmy' } +export const CORE_MODULE = { ID: 'token-action-hud-core' } +export const REQUIRED_CORE_MODULE_VERSION = '2.0' + +export const GROUP = { + attributes: { id: 'attributes', name: 'AWEMMY.Character.Attributes', type: 'system' }, + hp: { id: 'hp', name: 'AWEMMY.TAH.HP', type: 'system' }, + flow: { id: 'flow', name: 'AWEMMY.TAH.Flow', type: 'system' }, + weapons: { id: 'weapons', name: 'AWEMMY.TAH.Weapons', type: 'system' }, + conditions: { id: 'conditions', name: 'AWEMMY.Condition.Panel', type: 'system' }, + abilities: { id: 'abilities', name: 'AWEMMY.TAH.Abilities', type: 'system' }, + kits: { id: 'kits', name: 'AWEMMY.TAH.Kits', type: 'system' }, + utility: { id: 'utility', name: 'AWEMMY.TAH.Utility', type: 'system' } +} diff --git a/module/applications/hud/defaults.js b/module/applications/hud/defaults.js new file mode 100644 index 0000000..7ee600a --- /dev/null +++ b/module/applications/hud/defaults.js @@ -0,0 +1,54 @@ +import { GROUP } from './constants.js' + +export let DEFAULTS = null + +Hooks.once('tokenActionHudCoreApiReady', async (coreModule) => { + const groups = foundry.utils.deepClone(GROUP) + Object.values(groups).forEach(group => { + group.name = coreModule.api.Utils.i18n(group.name) + group.listName = `Group: ${coreModule.api.Utils.i18n(group.listName ?? group.name)}` + }) + const groupsArray = Object.values(groups) + + DEFAULTS = { + layout: [ + { + nestId: 'stats', + id: 'stats', + name: game.i18n.localize('AWEMMY.TAH.Stats'), + groups: [ + { ...groups.attributes, nestId: 'stats_attributes' }, + { ...groups.hp, nestId: 'stats_hp' }, + { ...groups.flow, nestId: 'stats_flow' } + ] + }, + { + nestId: 'combat', + id: 'combat', + name: game.i18n.localize('AWEMMY.TAH.Combat'), + groups: [ + { ...groups.weapons, nestId: 'combat_weapons' }, + { ...groups.conditions, nestId: 'combat_conditions' } + ] + }, + { + nestId: 'items', + id: 'items', + name: game.i18n.localize('AWEMMY.TAH.Items'), + groups: [ + { ...groups.abilities, nestId: 'items_abilities' }, + { ...groups.kits, nestId: 'items_kits' } + ] + }, + { + nestId: 'utility', + id: 'utility', + name: game.i18n.localize('AWEMMY.TAH.Utility'), + groups: [ + { ...groups.utility, nestId: 'utility_utility' } + ] + } + ], + groups: groupsArray + } +}) diff --git a/module/applications/hud/roll-handler.js b/module/applications/hud/roll-handler.js new file mode 100644 index 0000000..9207825 --- /dev/null +++ b/module/applications/hud/roll-handler.js @@ -0,0 +1,92 @@ +export let RollHandler = null + +Hooks.once('tokenActionHudCoreApiReady', async (coreModule) => { + RollHandler = class RollHandler extends coreModule.api.RollHandler { + + /** @override */ + async handleActionClick(event, encodedValue) { + const [actionTypeId, actionId] = encodedValue.split(this.delimiter ?? '|') + + if (this.actor) { + await this.#handleAction(event, this.actor, this.token, actionTypeId, actionId) + return + } + + const knownTypes = ['character', 'creature'] + for (const token of canvas.tokens.controlled.filter(t => knownTypes.includes(t.actor?.type))) { + await this.#handleAction(event, token.actor, token, actionTypeId, actionId) + } + } + + /** @override */ + async handleActionHover(event, encodedValue) {} + + /** @override */ + async handleGroupClick(event, group) {} + + async #handleAction(event, actor, token, actionTypeId, actionId) { + switch (actionTypeId) { + case 'attribute': + await actor.rollAttribute(actionId) + break + case 'hp': + await this.#handleHP(actor, actionId) + break + case 'flow': + await this.#handleFlow(actor, actionId) + break + case 'weapon': + await this.#handleWeapon(actor, actionId) + break + case 'condition': + await actor.toggleStatusEffect(actionId) + break + case 'ability': + await actor.useAbility(actionId) + break + case 'kit': + await actor.useKit(actionId) + break + case 'utility': + await this.#handleUtility(actor, token, actionId) + break + } + } + + async #handleHP(actor, actionId) { + if (actionId === 'display') return + const hp = actor.system.hp + if (!hp) return + const newValue = actionId === 'add' ? hp.value + 1 : hp.value - 1 + if (newValue < 0 || newValue > hp.max) return + await actor.update({ 'system.hp.value': newValue }) + } + + async #handleFlow(actor, actionId) { + if (actionId === 'display') return + const fp = actor.system.flowPoints + if (fp === undefined) return + const newValue = actionId === 'add' ? fp.value + 1 : Math.max(0, fp.value - 1) + await actor.update({ 'system.flowPoints.value': newValue }) + } + + async #handleWeapon(actor, actionId) { + const weapon = actor.items.get(actionId) + if (!weapon) return + await actor.rollWeapon(weapon) + } + + async #handleUtility(actor, token, actionId) { + switch (actionId) { + case 'longRest': + await actor.longRest() + break + case 'endTurn': + if (game.combat?.current?.tokenId === token?.id) { + await game.combat.nextTurn() + } + break + } + } + } +}) diff --git a/module/applications/hud/settings.js b/module/applications/hud/settings.js new file mode 100644 index 0000000..0effeb4 --- /dev/null +++ b/module/applications/hud/settings.js @@ -0,0 +1,8 @@ +/** + * Register module settings. + * Called by Token Action HUD Core to register Token Action HUD system module settings. + * @param {function} coreUpdate Token Action HUD Core update function + */ +export function register(coreUpdate) { + // No system-specific settings for now +} diff --git a/module/applications/hud/system-manager.js b/module/applications/hud/system-manager.js new file mode 100644 index 0000000..012e8ef --- /dev/null +++ b/module/applications/hud/system-manager.js @@ -0,0 +1,46 @@ +import { ActionHandler } from './action-handler.js' +import { RollHandler as Core } from './roll-handler.js' +import { MODULE } from './constants.js' +import { DEFAULTS } from './defaults.js' +import * as systemSettings from './settings.js' + +export let SystemManager = null + +Hooks.once('tokenActionHudCoreApiReady', async (coreModule) => { + SystemManager = class SystemManager extends coreModule.api.SystemManager { + + /** @override */ + getActionHandler() { + return new ActionHandler() + } + + /** @override */ + getAvailableRollHandlers() { + return { core: 'Adventures with Emmy' } + } + + /** @override */ + getRollHandler(rollHandlerId) { + switch (rollHandlerId) { + case 'core': + default: + return new Core() + } + } + + /** @override */ + async registerDefaults() { + return DEFAULTS + } + + /** @override */ + registerSettings(coreUpdate) { + systemSettings.register(coreUpdate) + } + + /** @override */ + registerStyles() { + return {} + } + } +}) diff --git a/module/applications/sheets/base-actor-sheet.mjs b/module/applications/sheets/base-actor-sheet.mjs index 1244e4e..65b0e08 100644 --- a/module/applications/sheets/base-actor-sheet.mjs +++ b/module/applications/sheets/base-actor-sheet.mjs @@ -86,6 +86,8 @@ export default class AwEActorSheet extends HandlebarsApplicationMixin(foundry.ap */ async _onRoll(event) { if (this.isEditMode) return + // Skip if the element has a registered data-action (handled by the action system) + if (event.currentTarget.dataset.action) return const attributeId = event.currentTarget.dataset.attributeId if (!attributeId) return await this.document.rollAttribute(attributeId) @@ -233,6 +235,7 @@ export default class AwEActorSheet extends HandlebarsApplicationMixin(foundry.ap static async #onItemDelete(event, target) { const itemUuid = target.getAttribute("data-item-uuid") const item = await fromUuid(itemUuid) + if (!item) return await item.deleteDialog() } diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index 5c393ad..79ed0a6 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -20,7 +20,13 @@ export default class AwECharacterSheet extends AwEActorSheet { flowPointsPlus: AwECharacterSheet.#onFlowPointsPlus, flowPointsMinus: AwECharacterSheet.#onFlowPointsMinus, rollField: AwECharacterSheet.#onRollField, - rollWeapon: AwECharacterSheet.#onRollWeapon + rollWeapon: AwECharacterSheet.#onRollWeapon, + rollDamage: AwECharacterSheet.#onRollDamage, + toggleCondition: AwECharacterSheet.#onToggleCondition, + useKit: AwECharacterSheet.#onUseKit, + useAbility: AwECharacterSheet.#onUseAbility, + dailyReset: AwECharacterSheet.#onDailyReset, + longRest: AwECharacterSheet.#onLongRest } } @@ -84,7 +90,15 @@ export default class AwECharacterSheet extends AwEActorSheet { name: item.name, img: item.img, system: item.system, - costLabel: game.i18n.localize(SYSTEM.ABILITY_COST[item.system.cost]?.label ?? item.system.cost) + costLabel: game.i18n.localize(SYSTEM.ABILITY_COST[item.system.cost]?.label ?? item.system.cost), + usedToday: item.system.usedToday + })) + context.hasUsedAbilities = context.abilities.some(a => a.usedToday) + context.conditions = Object.values(SYSTEM.CONDITIONS).map(c => ({ + ...c, + label: game.i18n.localize(c.label), + img: `systems/fvtt-adventures-with-emmy/assets/conditions/${c.id}.svg`, + active: doc.statuses.has(c.id) })) break case "biography": @@ -147,7 +161,10 @@ export default class AwECharacterSheet extends AwEActorSheet { // field/background/specialization: max 1 (replace existing); archetype: multiple allowed if (item.type === "field" || item.type === "background" || item.type === "specialization") { const existing = this.document.itemTypes[item.type] - if (existing.length > 0) await existing[0].delete() + if (existing.length > 0) { + ui.notifications.info(game.i18n.format("AWEMMY.Character.ItemReplaced", { name: existing[0].name })) + await existing[0].delete() + } return this.document.createEmbeddedDocuments("Item", [item.toObject()]) } if (item.type === "archetype") { @@ -248,8 +265,51 @@ export default class AwECharacterSheet extends AwEActorSheet { await this.document.rollWeapon(item) } + static async #onRollDamage(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId + const item = this.document.items.get(itemId) + if (!item) return + await this.document.rollDamage(item) + } + /** Slugify a string for loose name matching (lowercase, trim, spaces→dash, strip non-alphanum). */ static #slugify(str) { return (str ?? "").toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") } + + static async #onToggleCondition(event, target) { + const conditionId = target.dataset.conditionId + await this.document.toggleStatusEffect(conditionId) + } + + static async #onUseKit(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId + await this.document.useKit(itemId) + } + + static async #onUseAbility(event, target) { + const itemId = target.closest("[data-item-id]")?.dataset.itemId + await this.document.useAbility(itemId) + } + + static async #onDailyReset(event, target) { + const actor = this.document + const dailyAbilities = actor.itemTypes.ability.filter(i => i.system.usedToday) + if (!dailyAbilities.length) return + const updates = dailyAbilities.map(i => ({ _id: i.id, "system.usedToday": false })) + await actor.updateEmbeddedDocuments("Item", updates) + ui.notifications.info(game.i18n.localize("AWEMMY.Ability.DailyResetDone")) + } + + static async #onLongRest(event, target) { + const actor = this.document + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { title: game.i18n.localize("AWEMMY.Rest.LongRest") }, + content: `

${game.i18n.format("AWEMMY.Rest.LongRestConfirm", { name: actor.name })}

`, + yes: { label: game.i18n.localize("AWEMMY.Rest.Rest"), icon: "fa-solid fa-moon" }, + no: { label: game.i18n.localize("AWEMMY.Rest.Cancel") } + }) + if (!confirmed) return + await actor.longRest() + } } diff --git a/module/applications/sheets/creature-sheet.mjs b/module/applications/sheets/creature-sheet.mjs index b2897d9..9b2134d 100644 --- a/module/applications/sheets/creature-sheet.mjs +++ b/module/applications/sheets/creature-sheet.mjs @@ -55,7 +55,7 @@ export default class AwECreatureSheet extends AwEActorSheet { const context = await super._prepareContext() context.tabs = this.#getTabs() context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML( - this.document.system.description, { async: true } + this.document.system.description ?? "", { async: true } ) return context } diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 13a5123..a643b15 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -1,4 +1,5 @@ import AwERoll from "./roll.mjs" +import { SYSTEM } from "../config/system.mjs" export default class AwEActor extends Actor { /** @override */ @@ -48,11 +49,15 @@ export default class AwEActor extends Actor { bonus: f.system.knowledgeBonus ?? "" })).filter(f => f.bonus !== "") ?? [] - return AwERoll.prompt({ + const { conditionBonus, conditionLabels } = this.#buildConditionOptions() + + const roll = await AwERoll.prompt({ attributeKey: attrId, modifier: attribute.mod ?? 0, attributeBonus: attribute.bonus ?? 0, knowledgeBonuses, + conditionBonus, + conditionLabels, actorId: this.id, actorName: this.name, actorImage: this.img, @@ -62,6 +67,28 @@ export default class AwEActor extends Actor { damageType: weaponItem.system.damageType, ...options }) + + // Remove consumed conditions + if (roll && this.statuses.has("edge")) await this.toggleStatusEffect("edge") + return roll + } + + /** + * Roll weapon damage directly (no attack roll). + * @param {Item} weaponItem - The weapon item. + * @returns {Promise} + */ + async rollDamage(weaponItem) { + const formula = weaponItem.system.damageFormula + if (!formula) return ui.notifications.warn(game.i18n.localize("AWEMMY.Weapon.NoDamageFormula")) + const roll = new Roll(formula) + await roll.evaluate() + const typeStr = weaponItem.system.damageType ? ` (${weaponItem.system.damageType})` : "" + await roll.toMessage({ + speaker: ChatMessage.getSpeaker({ actor: this }), + flavor: `${weaponItem.name} — ${game.i18n.localize("AWEMMY.Weapon.DamageRoll")}${typeStr}` + }) + return roll } /** @@ -80,15 +107,144 @@ export default class AwEActor extends Actor { bonus: f.system.knowledgeBonus ?? "" })).filter(f => f.bonus !== "") ?? [] - return AwERoll.prompt({ + const { conditionBonus, conditionLabels } = this.#buildConditionOptions() + + const roll = await AwERoll.prompt({ attributeKey: attributeId, modifier: attribute.mod ?? 0, attributeBonus: attribute.bonus ?? 0, knowledgeBonuses, + conditionBonus, + conditionLabels, actorId: this.id, actorName: this.name, actorImage: this.img, ...options }) + + // Remove consumed conditions + if (roll && this.statuses.has("edge")) await this.toggleStatusEffect("edge") + return roll + } + + #buildConditionOptions() { + let conditionBonus = 0 + const conditionLabels = [] + if (this.statuses.has("edge")) { + conditionBonus += 2 + conditionLabels.push({ label: game.i18n.localize("AWEMMY.Condition.Edge"), bonus: 2 }) + } + if (this.statuses.has("prone")) { + conditionBonus -= 2 + conditionLabels.push({ label: game.i18n.localize("AWEMMY.Condition.Prone"), bonus: -2 }) + } + if (this.statuses.has("jumbled")) { + conditionBonus -= 2 + conditionLabels.push({ label: game.i18n.localize("AWEMMY.Condition.Jumbled"), bonus: -2 }) + } + return { conditionBonus, conditionLabels } + } + + /** + * Use a kit item: decrement charges and post a chat message. + * @param {string} kitId - The kit item ID. + */ + async useKit(kitId) { + const item = this.items.get(kitId) + if (!item) return + const charges = item.system.charges + if (charges.value <= 0) { + ui.notifications.warn(game.i18n.format("AWEMMY.Kit.Depleted", { name: item.name })) + return + } + await item.update({ "system.charges.value": charges.value - 1 }) + await ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this }), + content: `

${game.i18n.format("AWEMMY.Kit.Used", { name: item.name, value: charges.value - 1, max: charges.max })}

` + }) + } + + /** + * Use an ability item: check daily/FP constraints, deduct FP, mark used, post chat card. + * @param {string} abilityId - The ability item ID. + */ + async useAbility(abilityId) { + const item = this.items.get(abilityId) + if (!item) return + const sys = item.system + + if (sys.usedToday) { + ui.notifications.warn(game.i18n.format("AWEMMY.Ability.AlreadyUsed", { name: item.name })) + return + } + + if (sys.flowPointCost > 0) { + const fp = this.system.flowPoints.value + if (fp < sys.flowPointCost) { + ui.notifications.warn(game.i18n.format("AWEMMY.Ability.NotEnoughFP", { name: item.name, cost: sys.flowPointCost, current: fp })) + return + } + await this.update({ "system.flowPoints.value": fp - sys.flowPointCost }) + } + + const isDaily = sys.frequency?.toLowerCase().includes("day") + if (isDaily) await item.update({ "system.usedToday": true }) + + const abilityTypeLabel = game.i18n.localize(SYSTEM.ABILITY_TYPE[sys.abilityType]?.label ?? sys.abilityType) + const costLabel = game.i18n.localize(SYSTEM.ABILITY_COST[sys.cost]?.label ?? sys.cost) + const enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(sys.description ?? "", { async: true, relativeTo: item }) + const content = await foundry.applications.handlebars.renderTemplate( + "systems/fvtt-adventures-with-emmy/templates/ability-use.hbs", + { + name: item.name, + img: item.img, + costLabel, + abilityTypeLabel, + traits: sys.traits ?? [], + frequency: sys.frequency, + trigger: sys.trigger, + requirements: sys.requirements, + flowPointCost: sys.flowPointCost || 0, + description: enrichedDescription + } + ) + await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this }), content }) + } + + /** + * Perform a long rest: restore HP, reset daily abilities, refill kits, post chat card. + * No confirmation dialog — caller is responsible for confirming if needed. + */ + async longRest() { + const sys = this.system + const updates = {} + const summary = [] + + const hpMissing = sys.hp.max - sys.hp.value + if (hpMissing > 0) { + updates["system.hp.value"] = sys.hp.max + summary.push(game.i18n.format("AWEMMY.Rest.HPRestored", { amount: hpMissing, max: sys.hp.max })) + } + if (Object.keys(updates).length > 0) await this.update(updates) + + const dailyAbilities = this.itemTypes.ability.filter(i => i.system.usedToday) + if (dailyAbilities.length) { + await this.updateEmbeddedDocuments("Item", dailyAbilities.map(i => ({ _id: i.id, "system.usedToday": false }))) + summary.push(game.i18n.format("AWEMMY.Rest.AbilitiesReset", { count: dailyAbilities.length })) + } + + const depleted = this.itemTypes.kit.filter(i => i.system.charges.value < i.system.charges.max) + if (depleted.length) { + await this.updateEmbeddedDocuments("Item", depleted.map(i => ({ _id: i.id, "system.charges.value": i.system.charges.max }))) + summary.push(game.i18n.format("AWEMMY.Rest.KitsReplenished", { count: depleted.length })) + } + + const bulletList = summary.map(s => `
  • ${s}
  • `).join("") + const content = ` +
    +

    ${game.i18n.format("AWEMMY.Rest.LongRestTitle", { name: this.name })}

    + ${summary.length ? `
      ${bulletList}
    ` : `

    ${game.i18n.localize("AWEMMY.Rest.AlreadyRested")}

    `} +
    ` + await ChatMessage.create({ speaker: ChatMessage.getSpeaker({ actor: this }), content }) } } diff --git a/module/documents/roll.mjs b/module/documents/roll.mjs index 6d6a320..6cb1bc6 100644 --- a/module/documents/roll.mjs +++ b/module/documents/roll.mjs @@ -23,6 +23,8 @@ export default class AwERoll extends Roll { get damageType() { return this.options.damageType } get damageResult() { return this.options.damageResult } get damageCritical() { return this.options.damageCritical ?? false } + get conditionBonus() { return this.options.conditionBonus ?? 0 } + get conditionLabels() { return this.options.conditionLabels ?? [] } // --- Outcome calculation --- @@ -110,7 +112,7 @@ export default class AwERoll extends Roll { classes: ["awemmy"], content, render: (event, dialog) => { - const baseMod = mod + attrBonus + const baseMod = mod + attrBonus + (options.conditionBonus ?? 0) const el = dialog.element const bonusSelect = el.querySelector('#awe-bonus') const knowledgeSel = el.querySelector('#awe-knowledge') @@ -145,8 +147,8 @@ export default class AwERoll extends Roll { const dc = result.dc !== "" ? parseInt(result.dc) : undefined const rollMode = result.visibility ?? game.settings.get("core", "rollMode") - // Formula: 1d20 + (mod + attrBonus) [± bonus] [± knowledgeBonus] - const totalMod = mod + attrBonus + bonus + knowledgeBonus + // Formula: 1d20 + (mod + attrBonus) [± bonus] [± knowledgeBonus] [± conditionBonus] + const totalMod = mod + attrBonus + bonus + knowledgeBonus + (options.conditionBonus ?? 0) let formula = `1d20` if (totalMod > 0) formula += ` + ${totalMod}` else if (totalMod < 0) formula += ` - ${Math.abs(totalMod)}` @@ -158,12 +160,16 @@ export default class AwERoll extends Roll { attributeBonus: attrBonus, bonus, knowledgeBonus, + conditionBonus: options.conditionBonus ?? 0, + conditionLabels: options.conditionLabels ?? [], dc, actorId: options.actorId, actorName: options.actorName, actorImage: options.actorImage, sourceItemName: options.sourceItemName, - sourceItemImg: options.sourceItemImg + sourceItemImg: options.sourceItemImg, + damageFormula: options.damageFormula, + damageType: options.damageType }) await roll.evaluate() @@ -211,13 +217,17 @@ export default class AwERoll extends Roll { modifier: this.modifier, bonus: this.bonus, knowledgeBonus: this.knowledgeBonus, + conditionBonus: isPrivate ? null : this.conditionBonus, + conditionLabels: this.conditionLabels, dice: this.dice, outcome: isPrivate ? null : this.outcome, dc: this.dc, + actorId: this.actorId, actorName: this.actorName, actorImage: this.actorImage, sourceItemName: this.sourceItemName, sourceItemImg: this.sourceItemImg, + damageFormula: this.damageFormula, damageResult: isPrivate ? null : this.damageResult, damageCritical: this.damageCritical, damageType: this.damageType, diff --git a/module/models/ability.mjs b/module/models/ability.mjs index 939b75d..0d6c08f 100644 --- a/module/models/ability.mjs +++ b/module/models/ability.mjs @@ -22,6 +22,8 @@ export default class AwEAbility extends foundry.abstract.TypeDataModel { schema.requirements = new fields.StringField({ initial: "", required: false, nullable: true }) schema.trigger = new fields.StringField({ initial: "", required: false, nullable: true }) schema.traits = new fields.ArrayField(new fields.StringField()) + schema.flowPointCost = new fields.NumberField({ required: true, nullable: false, initial: 0, min: 0, integer: true }) + schema.usedToday = new fields.BooleanField({ required: true, initial: false }) return schema } diff --git a/module/models/creature.mjs b/module/models/creature.mjs index e8d2ef2..b686418 100644 --- a/module/models/creature.mjs +++ b/module/models/creature.mjs @@ -39,6 +39,7 @@ export default class AwECreature extends foundry.abstract.TypeDataModel { schema.eurekaEvidence = new fields.StringField({ initial: "", required: false, nullable: true }) schema.eurekaThreshold1 = new fields.StringField({ initial: "", required: false, nullable: true }) schema.eurekaThreshold2 = new fields.StringField({ initial: "", required: false, nullable: true }) + schema.eurekaThreshold3 = new fields.StringField({ initial: "", required: false, nullable: true }) schema.eurekaHints = new fields.StringField({ initial: "", required: false, nullable: true }) return schema diff --git a/templates/ability-use.hbs b/templates/ability-use.hbs new file mode 100644 index 0000000..d3b3cb5 --- /dev/null +++ b/templates/ability-use.hbs @@ -0,0 +1,47 @@ +
    +
    + {{name}} +
    + {{name}} + {{costLabel}} +
    + {{abilityTypeLabel}} +
    + + {{#if traits.length}} +
    + {{#each traits}}{{this}}{{/each}} +
    + {{/if}} + +
    + {{#if frequency}} +
    + {{localize "AWEMMY.Ability.Frequency"}} + {{frequency}} +
    + {{/if}} + {{#if trigger}} +
    + {{localize "AWEMMY.Ability.Trigger"}} + {{trigger}} +
    + {{/if}} + {{#if requirements}} +
    + {{localize "AWEMMY.Ability.Requirements"}} + {{requirements}} +
    + {{/if}} + {{#if flowPointCost}} +
    + {{localize "AWEMMY.Ability.FlowPointCost"}} + −{{flowPointCost}} FP +
    + {{/if}} +
    + + {{#if description}} +
    {{{description}}}
    + {{/if}} +
    diff --git a/templates/ability.hbs b/templates/ability.hbs index b31f286..d90148d 100644 --- a/templates/ability.hbs +++ b/templates/ability.hbs @@ -14,6 +14,10 @@ {{formField systemFields.cost value=system.cost localize=true}} +
    + + {{formInput systemFields.flowPointCost value=system.flowPointCost}} +
    diff --git a/templates/character-biography.hbs b/templates/character-biography.hbs index 253be4a..d1046d2 100644 --- a/templates/character-biography.hbs +++ b/templates/character-biography.hbs @@ -30,8 +30,8 @@ {{/if}}
    - - + +
    {{/each}} @@ -57,8 +57,8 @@ {{/if}}
    - - + +
    {{/each}} @@ -73,8 +73,8 @@ {{name}} {{name}}
    - - + +
    {{/each}} @@ -89,8 +89,8 @@ {{name}} {{name}}
    - - + +
    {{/each}} diff --git a/templates/character-equipment.hbs b/templates/character-equipment.hbs index e6001c2..c42d51a 100644 --- a/templates/character-equipment.hbs +++ b/templates/character-equipment.hbs @@ -10,6 +10,7 @@
    {{item.name}}
    {{item.system.charges.value}}/{{item.system.charges.max}}
    + {{#if ../isEditMode}} @@ -37,6 +38,7 @@
    {{localize "AWEMMY.Weapon.Range"}}: {{item.system.range}}
    + {{#if ../isEditMode}} diff --git a/templates/character-header.hbs b/templates/character-header.hbs index a618be3..ec63716 100644 --- a/templates/character-header.hbs +++ b/templates/character-header.hbs @@ -49,6 +49,9 @@
    + diff --git a/templates/character-main.hbs b/templates/character-main.hbs index b79f7a8..d42d98d 100644 --- a/templates/character-main.hbs +++ b/templates/character-main.hbs @@ -51,14 +51,20 @@ {{localize "AWEMMY.Item.Ability"}}
    {{#each abilities as |item|}} -
    +
    {{item.name}}
    {{item.costLabel}}
    + {{#if item.system.frequency}}
    {{item.system.frequency}}
    {{/if}}
    - + + + + {{#if ../isEditMode}} - + {{/if}}
    @@ -68,6 +74,26 @@
    {{/if}} + {{#if hasUsedAbilities}} +
    + +
    + {{/if}} +
    + + + {{!-- Conditions --}} +
    + {{localize "AWEMMY.Condition.Panel"}} +
    + {{#each conditions as |cond|}} + + {{cond.label}} + {{cond.label}} + + {{/each}}
    diff --git a/templates/chat-message.hbs b/templates/chat-message.hbs index 54d5510..b1009ec 100644 --- a/templates/chat-message.hbs +++ b/templates/chat-message.hbs @@ -45,6 +45,11 @@ {{#if (gt knowledgeBonus 0)}}+ {{knowledgeBonus}}{{else if (lt knowledgeBonus 0)}}− {{abs knowledgeBonus}}{{/if}} {{/if}} + {{#if conditionBonus}} + + {{#if (gt conditionBonus 0)}}+ {{conditionBonus}}{{else if (lt conditionBonus 0)}}− {{abs conditionBonus}}{{/if}} + + {{/if}} = {{total}} {{#if dc}}/ DC {{dc}}{{/if}} @@ -80,6 +85,22 @@
    {{/if}} + {{!-- Damage roll button (shown on weapon attacks where damage wasn't auto-rolled) --}} + {{#if damageFormula}} + {{#unless damageResult}} +
    + +
    + {{/unless}} + {{/if}} + {{else}}
    {{localize "AWEMMY.Roll.Private"}} diff --git a/templates/creature-eureka.hbs b/templates/creature-eureka.hbs index 71acbcd..2b96b78 100644 --- a/templates/creature-eureka.hbs +++ b/templates/creature-eureka.hbs @@ -21,6 +21,10 @@
    +
    + + +