From 000bf348a627f89e375f0e829ff410f802bcb91f Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Tue, 17 Mar 2026 13:50:32 +0100 Subject: [PATCH] Various fixes and changes based on tester feedback --- css/fvtt-oath-hammer.css | 285 ++++++++++++++---- lang/en.json | 54 +++- less/actor-sheet.less | 35 ++- less/base.less | 7 +- less/item-list.less | 76 ++++- less/roll-dialog.less | 204 +++++++++---- less/rolls.less | 5 + less/variables.less | 2 +- module/applications/_module.mjs | 2 + module/applications/armor-dialog.mjs | 70 +++++ module/applications/defense-dialog.mjs | 112 +++++++ module/applications/roll-dialog.mjs | 13 +- .../applications/sheets/base-actor-sheet.mjs | 14 + .../applications/sheets/base-item-sheet.mjs | 6 + .../applications/sheets/character-sheet.mjs | 53 +++- module/applications/sheets/class-sheet.mjs | 31 +- module/applications/weapon-dialog.mjs | 137 +++++++++ module/models/character.mjs | 2 + module/rolls.mjs | 272 ++++++++++++++++- system.json | 2 + templates/actor/character-combat.hbs | 27 +- templates/actor/character-equipment.hbs | 4 + templates/actor/character-identity.hbs | 12 +- templates/actor/character-magic.hbs | 9 +- templates/armor-roll-dialog.hbs | 50 +++ templates/defense-roll-dialog.hbs | 63 ++++ templates/item/class-sheet.hbs | 4 +- templates/roll-dialog.hbs | 6 + templates/weapon-defense-dialog.hbs | 85 ++++++ 29 files changed, 1450 insertions(+), 192 deletions(-) create mode 100644 module/applications/armor-dialog.mjs create mode 100644 module/applications/defense-dialog.mjs create mode 100644 templates/armor-roll-dialog.hbs create mode 100644 templates/defense-roll-dialog.hbs create mode 100644 templates/weapon-defense-dialog.hbs diff --git a/css/fvtt-oath-hammer.css b/css/fvtt-oath-hammer.css index aadef32..ad42e63 100644 --- a/css/fvtt-oath-hammer.css +++ b/css/fvtt-oath-hammer.css @@ -30,7 +30,7 @@ .oathhammer .npc-content { font-family: "Calibri", "Segoe UI", sans-serif; font-size: 0.86rem; - color: var(--color-dark-1); + color: #2a1a0a; background-image: var(--oh-background-image); background-repeat: no-repeat; background-size: 100% 100%; @@ -51,7 +51,7 @@ .oathhammer .npc-content select:disabled { background-color: rgba(0, 0, 0, 0.08); border-color: transparent; - color: var(--color-dark-3); + color: #2a1a0a; } .oathhammer .character-content input, .oathhammer .npc-content input, @@ -226,7 +226,8 @@ margin-bottom: 2px; } .oathhammer .skills-container .skill-row label.skill-name-col { - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); + font-weight: bold; } .oathhammer .skills-container .skill-row .skill-rank-col select, .oathhammer .skills-container .skill-row .skill-modifier-col input { @@ -409,7 +410,7 @@ border: 1px solid #535128; border-radius: 3px; background: rgba(0, 0, 0, 0.15); - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); min-width: 6rem; min-height: 1.6rem; } @@ -429,18 +430,18 @@ .oathhammer .character-main .character-identity-bar .identity-slot .identity-name { flex: 1; font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); } .oathhammer .character-main .character-identity-bar .identity-slot .slot-icon { - font-size: calc(0.86rem * 0.85); - opacity: 0.6; + font-size: calc(0.86rem * 0.9); + opacity: 0.8; } .oathhammer .character-main .character-identity-bar .identity-slot .slot-placeholder { - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); } .oathhammer .character-main .character-identity-bar .identity-slot a { - font-size: calc(0.86rem * 0.85); - opacity: 0.7; + font-size: calc(0.86rem * 0.9); + opacity: 0.85; } .oathhammer .character-main .character-identity-bar .identity-slot a:hover { opacity: 1; @@ -450,21 +451,22 @@ align-items: center; gap: 3px; margin-left: auto; - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); } .oathhammer .character-main .character-identity-bar .identity-xp .xp-label { font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.85); - color: #535128; + font-size: calc(0.86rem * 0.9); + font-weight: bold; + color: #2a1a0a; margin-left: 4px; } .oathhammer .character-main .character-identity-bar .identity-xp .xp-sep { - opacity: 0.6; + opacity: 0.8; } .oathhammer .character-main .character-identity-bar .identity-xp input { width: 3rem; text-align: center; - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); padding: 1px 2px; } .oathhammer .character-main .character-stats-band { @@ -496,17 +498,18 @@ .oathhammer .character-main .character-stats-band .character-resources .character-resource input { width: 2.4rem; text-align: center; - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); padding: 1px 2px; } .oathhammer .character-main .character-stats-band .character-resources .character-resource .res-sep { - opacity: 0.5; + opacity: 0.7; font-size: calc(0.86rem * 0.9); } .oathhammer .character-main .character-stats-band .character-resources .resource-label { - min-width: 3.8rem; + min-width: 4.2rem; font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.9); + font-size: 0.86rem; + font-weight: bold; color: #2a1a0a; } .oathhammer .character-main .character-stats-band .character-attributes { @@ -529,7 +532,8 @@ } .oathhammer .attribute-box label { font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.85); + font-size: calc(0.86rem * 0.9); + font-weight: bold; color: #2a1a0a; text-align: center; } @@ -622,7 +626,7 @@ text-align: center; } .oathhammer .character-arcane-stress span { - opacity: 0.6; + opacity: 0.8; } .oathhammer .defense-display { min-width: 3rem; @@ -697,7 +701,7 @@ } .oathhammer .item-entry .item-detail { font-size: calc(0.86rem * 0.9); - color: #535128; + color: #2a1a0a; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -705,7 +709,7 @@ } .oathhammer .item-entry .item-group { font-size: calc(0.86rem * 0.9); - color: #535128; + color: #2a1a0a; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -742,7 +746,7 @@ gap: 4px; } .oathhammer .item-entry .item-actions a { - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; font-size: calc(0.86rem * 0.85); } @@ -754,11 +758,11 @@ } .oathhammer .item-list--weapon .item-list-header, .oathhammer .item-list--weapon .item-entry { - grid-template-columns: 24px 1fr 4.5rem 3.5rem 2rem 1.5rem 1.8rem 5.5rem; + grid-template-columns: 24px 1fr 4.5rem 3.5rem 2rem 1.5rem 1.8rem 9rem; } .oathhammer .item-list--armor .item-list-header, .oathhammer .item-list--armor .item-entry { - grid-template-columns: 24px 1fr 3.5rem 2.5rem 3.5rem 1.5rem 1.8rem 3.5rem; + grid-template-columns: 24px 1fr 3.5rem 2.5rem 3.5rem 1.5rem 1.8rem 5.5rem; } .oathhammer .item-list--ammo .item-list-header, .oathhammer .item-list--ammo .item-entry { @@ -772,6 +776,10 @@ .oathhammer .item-list--miracle .item-entry { grid-template-columns: 24px 1fr 4.5rem 5.5rem; } +.oathhammer .miracles-blocked { + opacity: 0.45; + pointer-events: none; +} .oathhammer .item-list--equipment .item-list-header, .oathhammer .item-list--equipment .item-entry { grid-template-columns: 24px 1fr 5rem 3rem 3.5rem; @@ -794,7 +802,7 @@ } .oathhammer .item-usage { font-size: calc(0.86rem * 0.9); - color: #535128; + color: #2a1a0a; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -817,7 +825,7 @@ color: #c0392b; } .oathhammer .no-items { - color: var(--color-dark-5); + color: #535128; font-style: italic; font-size: calc(0.86rem * 0.9); padding: 4px; @@ -825,12 +833,70 @@ .oathhammer .create-btn { margin-left: 6px; color: #084a74; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } .oathhammer .create-btn:hover { opacity: 1; } +.miracle-blocked-banner { + display: flex; + align-items: center; + gap: 6px; + background: rgba(192, 57, 43, 0.1); + border: 1px solid rgba(192, 57, 43, 0.45); + border-radius: 4px; + padding: 4px 10px; + margin-bottom: 4px; + color: #c0392b; + font-size: calc(0.86rem * 0.9); + font-weight: bold; +} +.miracle-blocked-banner span { + flex: 1; +} +.miracle-blocked-banner a { + color: #2a1a0a; + font-weight: normal; + font-size: calc(0.86rem * 0.85); + opacity: 0.85; + transition: opacity 0.2s; + white-space: nowrap; +} +.miracle-blocked-banner a:hover { + opacity: 1; +} +.miracle-blocked-banner a i { + color: #c8a84b; +} +.slots-counter { + display: flex; + gap: 6px; + padding: 2px 6px 4px; +} +.slots-counter .slots-label { + font-size: calc(0.86rem * 0.9); + font-weight: bold; + color: #2a1a0a; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.slots-counter .slots-value { + font-size: calc(0.86rem * 0.9); + font-weight: bold; + color: #2a1a0a; + background: rgba(200, 168, 75, 0.15); + border: 1px solid rgba(200, 168, 75, 0.4); + border-radius: 4px; + padding: 1px 8px; + min-width: 3.5rem; + text-align: center; +} +.slots-counter .slots-value.slots-over { + color: #c0392b; + background: rgba(192, 57, 43, 0.1); + border-color: rgba(192, 57, 43, 0.4); +} .oathhammer .item-sheet-common { overflow: auto; padding: 10px 20px; @@ -1025,6 +1091,10 @@ background: rgba(231, 76, 60, 0.15); color: #c0392b; } +.oh-roll-card .roll-opposed { + background: rgba(52, 152, 219, 0.15); + color: #1a6fa8; +} .oathhammer .rarity-roll-btn { display: inline-flex; align-items: center; @@ -1032,7 +1102,7 @@ cursor: pointer; font-size: calc(0.86rem * 0.9); color: #084a74; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } .oathhammer .rarity-roll-btn:hover { @@ -1155,25 +1225,27 @@ background: rgba(255, 255, 255, 0.85); color: #2a1a0a; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-row { +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 4px 8px; padding: 5px 0; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-row:not(:last-child) { +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row:not(:last-child) { border-bottom: 1px solid rgba(83, 81, 40, 0.1); } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-row label { - flex: 1 1 120px; +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row label { + flex: 0 0 140px; font-size: calc(0.86rem * 0.9); color: #2a1a0a; white-space: nowrap; font-family: "Calibri", "Segoe UI", sans-serif; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-row select { - flex: 0 0 90px; +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row select, +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row input[type="text"], +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row input[type="number"] { + flex: 0 0 110px; padding: 3px 6px; border: 1px solid rgba(49, 47, 23, 0.2); border-radius: 3px; @@ -1182,27 +1254,38 @@ font-family: "Calibri", "Segoe UI", sans-serif; font-size: calc(0.86rem * 0.9); } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-row .roll-option-hint { - flex: 1 1 100%; +.fvtt-oath-hammer .oh-roll-dialog .roll-option-row .roll-option-hint { + flex: 1 1 auto; font-size: calc(calc(0.86rem * 0.9) * 0.85); color: #535128; font-style: italic; - padding-left: 4px; - white-space: normal; + padding-left: 6px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-luck label { +.fvtt-oath-hammer .oh-roll-dialog .roll-option-luck label { color: #c8a84b; font-weight: bold; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-luck select { +.fvtt-oath-hammer .oh-roll-dialog .roll-option-luck select { border-color: #c8a84b; } -.fvtt-oath-hammer .oh-roll-dialog .roll-options-block .roll-option-luck .luck-icon { +.fvtt-oath-hammer .oh-roll-dialog .roll-option-luck .luck-icon { color: #c8a84b; font-size: 0.8em; } +.fvtt-oath-hammer .oh-roll-dialog .roll-option-check input[type="checkbox"] { + flex: 0 0 auto; + width: 16px; + height: 16px; + accent-color: #084a74; + cursor: pointer; +} +.fvtt-oath-hammer .oh-roll-dialog .roll-option-check label { + cursor: pointer; + flex: 1 1 auto; +} .fvtt-oath-hammer .oh-roll-dialog .roll-visibility-block select { width: 100%; padding: 4px 6px; @@ -1220,7 +1303,7 @@ display: inline-flex; align-items: center; gap: 5px; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } .fvtt-oath-hammer .skills-list a.skill-name-col:hover { @@ -1373,7 +1456,7 @@ font-family: "Calibri", "Segoe UI", sans-serif; font-size: calc(0.86rem * 0.9); cursor: pointer; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } .oh-weapon-card .oh-roll-damage-btn:hover, @@ -1395,29 +1478,38 @@ font-weight: bold; margin-left: 8px; } -.item-list--weapon .item-actions { - gap: 8px; +.item-list--weapon .item-entry .item-actions { + gap: 14px; } -.item-list--weapon .item-actions a[data-action="attackWeapon"] { +.item-list--weapon .item-entry .item-actions a[data-action="attackWeapon"] { color: #084a74; font-size: 1.1em; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } -.item-list--weapon .item-actions a[data-action="attackWeapon"]:hover { +.item-list--weapon .item-entry .item-actions a[data-action="attackWeapon"]:hover { opacity: 1; } -.item-list--weapon .item-actions a[data-action="damageWeapon"] { +.item-list--weapon .item-entry .item-actions a[data-action="defendWeapon"] { + color: #535128; + font-size: 1.1em; + opacity: 0.85; + transition: opacity 0.2s; +} +.item-list--weapon .item-entry .item-actions a[data-action="defendWeapon"]:hover { + opacity: 1; +} +.item-list--weapon .item-entry .item-actions a[data-action="damageWeapon"] { color: #8b0000; font-size: 1.1em; - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; } -.item-list--weapon .item-actions a[data-action="damageWeapon"]:hover { +.item-list--weapon .item-entry .item-actions a[data-action="damageWeapon"]:hover { opacity: 1; } -.item-list--weapon .item-actions a[data-action="edit"] { - margin-left: 6px; +.item-list--weapon .item-entry .item-actions a[data-action="edit"] { + margin-left: 4px; } .oh-spell-dialog .dv-badge, .oh-miracle-dialog .dv-badge { @@ -1565,3 +1657,84 @@ .item-list--miracle .item-actions a[data-action="edit"] { margin-left: 6px; } +.oh-defense-dialog .oh-trait-info, +.oh-armor-dialog .oh-trait-info { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + margin: 4px 0; + background: rgba(8, 74, 116, 0.08); + border: 1px solid rgba(8, 74, 116, 0.25); + border-radius: 4px; + font-size: 0.84em; + color: #052c44; +} +.oh-defense-dialog .oh-trait-info i, +.oh-armor-dialog .oh-trait-info i { + flex-shrink: 0; +} +.oh-defense-card .oh-roll-header, +.oh-armor-card .oh-roll-header { + display: flex; + align-items: center; + gap: 8px; +} +.oh-defense-card .oh-defense-icon, +.oh-armor-card .oh-defense-icon { + font-size: 1.1em; + color: #084a74; + flex-shrink: 0; +} +.oh-defense-card .oh-card-weapon-img, +.oh-armor-card .oh-card-weapon-img { + width: 28px; + height: 28px; + -o-object-fit: contain; + object-fit: contain; + border-radius: 3px; + border: 1px solid rgba(83, 81, 40, 0.2); + flex-shrink: 0; +} +.defense-row { + align-items: flex-end; +} +.defense-row .defense-roll-group { + flex: 0 0 auto; +} +.defense-roll-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: #084a74; + color: #fff; + border-radius: 4px; + font-family: "BlueDragon", "Palatino Linotype", serif; + font-size: calc(0.86rem * 0.85); + cursor: pointer; + text-decoration: none; + opacity: 0.85; + transition: opacity 0.2s; +} +.defense-roll-btn:hover { + opacity: 1; +} +.defense-roll-btn i { + font-size: 0.9em; +} +.defense-roll-btn:hover { + opacity: 0.85; + color: #fff; + text-decoration: none; +} +.item-list--armor .item-actions { + gap: 8px; +} +.item-list--armor .item-actions a[data-action="rollArmorSave"] .armor-roll-icon { + color: #084a74; + font-size: 1.05em; +} +.item-list--armor .item-actions a[data-action="edit"] { + margin-left: 6px; +} diff --git a/lang/en.json b/lang/en.json index 8476064..3661b5d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -60,6 +60,14 @@ "Survival": "Survival", "Tracking": "Tracking" }, + "Lineage": { + "Dwarf": "Dwarf", + "Firbolg": "Firbolg", + "Halfling": "Halfling", + "HighElf": "High Elf", + "Human": "Human", + "WoodElf": "Wood Elf" + }, "Class": { "Berserker": "Berserker", "Champion": "Champion", @@ -211,6 +219,7 @@ "Weapons": "Weapons", "Armor": "Armor & Shields", "Ammunition": "Ammunition", + "ItemSlots": "Item Slots", "Spells": "Spells", "Miracles": "Miracles", "Equipment": "Equipment", @@ -231,7 +240,9 @@ "NoArmor": "No armor or shields.", "NoSpells": "No spells known.", "NoMiracles": "No miracles known.", + "MiracleBlocked": "Divine favour lost — no more miracles today.", "NoEquipment": "No equipment.", + "NoTraits": "Drop traits here.", "Enchantment": "Enchantment", "Tenet": "Tenet", "Boon": "Boon", @@ -276,12 +287,15 @@ "Spell": "New Spell", "Miracle": "New Miracle", "Equipment": "New Equipment", - "Building": "New Building" + "Building": "New Building", + "Trait": "New Trait" }, "ToggleSheet": "Toggle Edit/Play Mode", "Action": { "CastSpell": "Cast Spell", - "InvokeMiracle": "Invoke Miracle" + "InvokeMiracle": "Invoke Miracle", + "ResetMiracleBlocked": "Restore divine favour (new day)", + "NewDay": "New day" }, "Dialog": { "SkillCheckTitle": "Skill Check: {skill}", @@ -300,6 +314,8 @@ "Visibility": "Visibility", "Attribute": "Attribute", "RollSkill": "Click to roll skill check", + "ExplodeOn5": "Explode on 5+", + "ExplodeOn5Hint": "trait bonus — 5s & 6s explode", "AttackTitle": "Attack: {weapon}", "DamageTitle": "Damage: {weapon}", "Attack": "Attack", @@ -335,7 +351,30 @@ "MiracleDVNote": "miracle # today", "MiracleCount": "Miracle # Today", "MiracleCountHint": "1st = DV 1, 2nd = DV 2...", - "MiracleFailWarning": "Failure blocks ALL miracles for the rest of the day." + "MiracleFailWarning": "Failure blocks ALL miracles for the rest of the day.", + "DefenseTitle": "Defense Roll: {actor}", + "RollDefense": "Roll Defense", + "DefenseOptions": "Defense Options", + "AttackType": "Attack Type", + "DefenseMelee": "Melee Attack", + "DefenseRanged": "Ranged Attack", + "GoverningAttr": "Attribute", + "MightMeleeHint": "melee only: may use Might", + "ParryActive": "Parry — red defense dice vs melee", + "BlockActive": "Block — red defense dice vs ranged (+1)", + "ArmorPenalty": "armor penalty", + "WeaponDefenseTitle": "Weapon Defense: {weapon}", + "DiminishingDefense": "Diminishing Defense", + "DiminishingDefenseHint": "-2 penalty per additional defense this turn", + "DiminishFirst": "1st defense (no penalty)", + "DiminishSecond": "2nd defense (−2)", + "DiminishThird": "3rd+ defense (−4)", + "ArmorRollTitle": "Armor Roll: {armor}", + "RollArmor": "Roll Armor", + "ArmorRollOptions": "Armor Roll Options", + "APPenalty": "AP (Attacker)", + "APHint": "attacker's Armor Piercing value", + "ReinforcedHint": "Reinforced — rolling red dice" }, "Enhancement": { "None": "None", @@ -352,6 +391,7 @@ "Check": "Check", "Success": "Success!", "Failure": "Failure", + "Exploded": "exploded dice", "AutoSuccess": "Automatically Available", "RarityCheck": "Rarity Check", "NoActor": "No character selected — assign a character to your user first.", @@ -362,6 +402,11 @@ "MiracleCast": "Miracle Invocation", "StressGained": "Arcane Stress Gained", "MiracleBlocked": "You are now blocked from casting miracles for the rest of the day!", + "Defense": "Defense Roll", + "DefenseResult": "defense successes", + "ArmorRoll": "Armor Roll", + "ArmorBypassed": "Armor bypassed (0 dice — AP ≥ AV)", + "Successes": "successes", "DualAttr": { "DefenseMelee": "melee defense", "FightingNimble": "nimble weapon", @@ -893,6 +938,9 @@ "Actor": { "character": "Character", "npc": "NPC" + }, + "Warning": { + "MiracleBlocked": "Divine favour has been lost — you cannot invoke miracles until a new day." } } } \ No newline at end of file diff --git a/less/actor-sheet.less b/less/actor-sheet.less index a37eae1..c478e4e 100644 --- a/less/actor-sheet.less +++ b/less/actor-sheet.less @@ -76,7 +76,7 @@ border: 1px solid @color-olive; border-radius: 3px; background: rgba(0,0,0,0.15); - font-size: @font-size-sm; + font-size: @font-size-xs; min-width: 6rem; min-height: 1.6rem; @@ -97,13 +97,13 @@ .identity-name { flex: 1; font-family: @font-secondary; - font-size: @font-size-sm; + font-size: @font-size-xs; } - .slot-icon { font-size: @font-size-sm; opacity: 0.6; } - .slot-placeholder { font-size: @font-size-sm; } + .slot-icon { font-size: @font-size-xs; opacity: 0.8; } + .slot-placeholder { font-size: @font-size-xs; } - a { font-size: @font-size-sm; opacity: 0.7; &:hover { opacity: 1; } } + a { font-size: @font-size-xs; opacity: 0.85; &:hover { opacity: 1; } } } .identity-xp { @@ -111,21 +111,22 @@ align-items: center; gap: 3px; margin-left: auto; - font-size: @font-size-sm; + font-size: @font-size-xs; .xp-label { font-family: @font-secondary; - font-size: @font-size-sm; - color: @color-olive; + font-size: @font-size-xs; + font-weight: bold; + color: @color-dark; margin-left: 4px; } - .xp-sep { opacity: 0.6; } + .xp-sep { opacity: 0.8; } input { width: 3rem; text-align: center; - font-size: @font-size-sm; + font-size: @font-size-xs; padding: 1px 2px; } } @@ -161,17 +162,18 @@ input { width: 2.4rem; text-align: center; - font-size: @font-size-sm; + font-size: @font-size-xs; padding: 1px 2px; } - .res-sep { opacity: 0.5; font-size: @font-size-xs; } + .res-sep { opacity: 0.7; font-size: @font-size-xs; } } .resource-label { - min-width: 3.8rem; + min-width: 4.2rem; font-family: @font-secondary; - font-size: @font-size-xs; + font-size: @font-size-base; + font-weight: bold; color: @color-dark; } } @@ -204,7 +206,8 @@ label { font-family: @font-secondary; - font-size: @font-size-sm; + font-size: @font-size-xs; + font-weight: bold; color: @color-dark; text-align: center; } @@ -311,7 +314,7 @@ width: 2.8rem; text-align: center; } - span { opacity: 0.6; } + span { opacity: 0.8; } } // Defense display diff --git a/less/base.less b/less/base.less index 934f4a2..829a0a0 100644 --- a/less/base.less +++ b/less/base.less @@ -39,7 +39,7 @@ .oathhammer .npc-content { font-family: @font-body; // Calibri — standard text per design_rules.md font-size: @font-size-base; - color: var(--color-dark-1); + color: @color-dark; .sheet-background(); overflow: auto; padding: 10px 20px; // Inner margin so content clears the parchment border @@ -53,7 +53,7 @@ select:disabled { background-color: @color-disabled-bg; border-color: transparent; - color: var(--color-dark-3); + color: @color-dark; } input, @@ -208,7 +208,8 @@ margin-bottom: 2px; label.skill-name-col { - font-size: @font-size-sm; + font-size: @font-size-xs; + font-weight: bold; } .skill-rank-col select, diff --git a/less/item-list.less b/less/item-list.less index 6ddf986..0369e3b 100644 --- a/less/item-list.less +++ b/less/item-list.less @@ -56,7 +56,7 @@ .item-detail { font-size: @font-size-xs; - color: @color-olive; + color: @color-dark; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -65,7 +65,7 @@ .item-group { font-size: @font-size-xs; - color: @color-olive; + color: @color-dark; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -117,13 +117,13 @@ .item-list--weapon { .item-list-header, .item-entry { - grid-template-columns: @item-img-size 1fr 4.5rem 3.5rem 2rem 1.5rem 1.8rem 5.5rem; + grid-template-columns: @item-img-size 1fr 4.5rem 3.5rem 2rem 1.5rem 1.8rem 9rem; } } .item-list--armor { .item-list-header, .item-entry { - grid-template-columns: @item-img-size 1fr 3.5rem 2.5rem 3.5rem 1.5rem 1.8rem 3.5rem; + grid-template-columns: @item-img-size 1fr 3.5rem 2.5rem 3.5rem 1.5rem 1.8rem 5.5rem; } } @@ -145,6 +145,11 @@ } } + .miracles-blocked { + opacity: 0.45; + pointer-events: none; + } + .item-list--equipment { .item-list-header, .item-entry { grid-template-columns: @item-img-size 1fr 5rem 3rem 3.5rem; @@ -177,7 +182,7 @@ .item-usage { font-size: @font-size-xs; - color: @color-olive; + color: @color-dark; text-align: center; overflow: hidden; text-overflow: ellipsis; @@ -197,7 +202,7 @@ } .no-items { - color: var(--color-dark-5); + color: @color-olive; font-style: italic; font-size: @font-size-xs; padding: 4px; @@ -209,3 +214,62 @@ .transition-opacity(); } } + +// Miracle blocked banner on the Magic tab +.miracle-blocked-banner { + display: flex; + align-items: center; + gap: 6px; + background: fade(#c0392b, 10%); + border: 1px solid fade(#c0392b, 45%); + border-radius: 4px; + padding: 4px 10px; + margin-bottom: 4px; + color: #c0392b; + font-size: @font-size-xs; + font-weight: bold; + + span { flex: 1; } + + a { + color: @color-dark; + font-weight: normal; + font-size: @font-size-sm; + .transition-opacity(); + white-space: nowrap; + i { color: @color-gold; } + } +} + +// Slots counter on the Combat tab +.slots-counter { + display: flex; + gap: 6px; + padding: 2px 6px 4px; + + .slots-label { + font-size: @font-size-xs; + font-weight: bold; + color: @color-dark; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .slots-value { + font-size: @font-size-xs; + font-weight: bold; + color: @color-dark; + background: fade(@color-gold, 15%); + border: 1px solid fade(@color-gold, 40%); + border-radius: 4px; + padding: 1px 8px; + min-width: 3.5rem; + text-align: center; + + &.slots-over { + color: #c0392b; + background: fade(#c0392b, 10%); + border-color: fade(#c0392b, 40%); + } + } +} diff --git a/less/roll-dialog.less b/less/roll-dialog.less index 2560635..7851fbf 100644 --- a/less/roll-dialog.less +++ b/less/roll-dialog.less @@ -133,55 +133,72 @@ } } - // ——— Options block ——— + // ——— Option rows — applies to all fieldsets in any roll dialog ——— + .roll-option-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 4px 8px; + padding: 5px 0; + + &:not(:last-child) { + border-bottom: 1px solid fade(@color-olive, 10%); + } + + label { + flex: 0 0 140px; + font-size: @font-size-xs; + color: @color-dark; + white-space: nowrap; + font-family: @font-body; + } + + select, input[type="text"], input[type="number"] { + flex: 0 0 110px; + padding: 3px 6px; + border: 1px solid darken(@color-olive-faint, 10%); + border-radius: 3px; + background: rgba(255, 255, 255, 0.85); + color: @color-dark; + font-family: @font-body; + font-size: @font-size-xs; + } + + .roll-option-hint { + flex: 1 1 auto; + font-size: calc(@font-size-xs * 0.85); + color: @color-olive; + font-style: italic; + padding-left: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .roll-option-luck { + label { color: @color-gold; font-weight: bold; } + select { border-color: @color-gold; } + .luck-icon { color: @color-gold; font-size: 0.8em; } + } + + .roll-option-check { + input[type="checkbox"] { + flex: 0 0 auto; + width: 16px; + height: 16px; + accent-color: @color-blue; + cursor: pointer; + } + label { + cursor: pointer; + flex: 1 1 auto; + } + } + + // ——— Options block (legacy scope kept for skill roll dialog) ——— .roll-options-block { - .roll-option-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - padding: 5px 0; - - &:not(:last-child) { - border-bottom: 1px solid fade(@color-olive, 10%); - } - - label { - flex: 1 1 120px; - font-size: @font-size-xs; - color: @color-dark; - white-space: nowrap; - font-family: @font-body; - } - - select { - flex: 0 0 90px; - padding: 3px 6px; - border: 1px solid darken(@color-olive-faint, 10%); - border-radius: 3px; - background: rgba(255, 255, 255, 0.85); - color: @color-dark; - font-family: @font-body; - font-size: @font-size-xs; - } - - .roll-option-hint { - flex: 1 1 100%; - font-size: calc(@font-size-xs * 0.85); - color: @color-olive; - font-style: italic; - padding-left: 4px; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .roll-option-luck { - label { color: @color-gold; font-weight: bold; } - select { border-color: @color-gold; } - .luck-icon { color: @color-gold; font-size: 0.8em; } - } + // inherits .roll-option-row styles from parent scope } // ——— Visibility block ——— @@ -390,19 +407,24 @@ } // Attack/damage buttons in weapon list -.item-list--weapon .item-actions { - gap: 8px; +.item-list--weapon .item-entry .item-actions { + gap: 14px; a[data-action="attackWeapon"] { color: @color-blue; font-size: 1.1em; .transition-opacity(); } + a[data-action="defendWeapon"] { + color: @color-olive; + font-size: 1.1em; + .transition-opacity(); + } a[data-action="damageWeapon"] { color: #8b0000; font-size: 1.1em; .transition-opacity(); } - a[data-action="edit"] { margin-left: 6px; } + a[data-action="edit"] { margin-left: 4px; } } // ============================================================ @@ -550,3 +572,83 @@ a[data-action="castMiracle"] .miracle-cast-icon { color: #5a3000; font-size: 1.05em; } a[data-action="edit"] { margin-left: 6px; } } + +// ============================================================ +// DEFENSE / ARMOR DIALOG STYLES +// ============================================================ + +.oh-defense-dialog, +.oh-armor-dialog { + .oh-trait-info { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + margin: 4px 0; + background: fade(@color-blue, 8%); + border: 1px solid fade(@color-blue, 25%); + border-radius: 4px; + font-size: 0.84em; + color: darken(@color-blue, 10%); + i { flex-shrink: 0; } + } +} + +// Defense card in chat +.oh-defense-card, +.oh-armor-card { + .oh-roll-header { + display: flex; + align-items: center; + gap: 8px; + } + .oh-defense-icon { + font-size: 1.1em; + color: @color-blue; + flex-shrink: 0; + } + .oh-card-weapon-img { + width: 28px; + height: 28px; + object-fit: contain; + border-radius: 3px; + border: 1px solid @color-olive-faint; + flex-shrink: 0; + } +} + +// Defense roll button in combat tab +.defense-row { + align-items: flex-end; + .defense-roll-group { + flex: 0 0 auto; + } +} + +.defense-roll-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: @color-blue; + color: #fff; + border-radius: 4px; + font-family: @font-secondary; + font-size: @font-size-sm; + cursor: pointer; + text-decoration: none; + .transition-opacity(); + + i { font-size: 0.9em; } + &:hover { opacity: 0.85; color: #fff; text-decoration: none; } +} + +// Armor roll icon in armor list +.item-list--armor .item-actions { + gap: 8px; + a[data-action="rollArmorSave"] .armor-roll-icon { + color: @color-blue; + font-size: 1.05em; + } + a[data-action="edit"] { margin-left: 6px; } +} diff --git a/less/rolls.less b/less/rolls.less index 8e37d29..95386a5 100644 --- a/less/rolls.less +++ b/less/rolls.less @@ -77,6 +77,11 @@ background: fade(#e74c3c, 15%); color: #c0392b; } + + .roll-opposed { + background: fade(#3498db, 15%); + color: #1a6fa8; + } } // Rollable rarity button on item sheets diff --git a/less/variables.less b/less/variables.less index 4e8eb84..635fa45 100644 --- a/less/variables.less +++ b/less/variables.less @@ -47,7 +47,7 @@ } .transition-opacity() { - opacity: 0.6; + opacity: 0.85; transition: opacity 0.2s; &:hover { opacity: 1; } } diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index c644d01..eebf362 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -15,3 +15,5 @@ export { default as OathHammerRollDialog } from "./roll-dialog.mjs" export { default as OathHammerWeaponDialog } from "./weapon-dialog.mjs" export { default as OathHammerSpellDialog } from "./spell-dialog.mjs" export { default as OathHammerMiracleDialog } from "./miracle-dialog.mjs" +export { default as OathHammerDefenseDialog } from "./defense-dialog.mjs" +export { default as OathHammerArmorDialog } from "./armor-dialog.mjs" diff --git a/module/applications/armor-dialog.mjs b/module/applications/armor-dialog.mjs new file mode 100644 index 0000000..725b217 --- /dev/null +++ b/module/applications/armor-dialog.mjs @@ -0,0 +1,70 @@ +/** + * Armor roll dialog. + * + * Pool = Armor Value (AV) − AP penalty + manual bonus (can go to 0, unlike other pools) + * Reinforced trait on the armor → red dice (3+) + * Each success on the roll reduces incoming damage by 1. + */ +export default class OathHammerArmorDialog { + + static async prompt(actor, armor) { + const sys = armor.system + const av = sys.armorValue ?? 0 + + const isReinforced = [...(sys.traits ?? [])].includes("reinforced") + + // AP options — entered by the user based on the attacker's weapon + const apOptions = Array.from({ length: 9 }, (_, i) => ({ + value: -i, + label: i === 0 ? "0" : `−${i}`, + selected: i === 0, + })) + + const bonusOptions = Array.from({ length: 7 }, (_, i) => { + const v = i - 3 + return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } + }) + + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) + + const context = { + actorName: actor.name, + armorName: armor.name, + armorImg: armor.img, + av, + isReinforced, + apOptions, + bonusOptions, + rollModes, + visibility: game.settings.get("core", "rollMode"), + } + + const content = await foundry.applications.handlebars.renderTemplate( + "systems/fvtt-oath-hammer/templates/armor-roll-dialog.hbs", + context + ) + + const result = await foundry.applications.api.DialogV2.wait({ + window: { title: game.i18n.format("OATHHAMMER.Dialog.ArmorRollTitle", { armor: armor.name }) }, + classes: ["fvtt-oath-hammer"], + content, + rejectClose: false, + buttons: [{ + label: game.i18n.localize("OATHHAMMER.Dialog.RollArmor"), + callback: (_ev, btn) => Object.fromEntries( + [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) + ), + }], + }) + + if (!result) return null + + return { + av, + isReinforced, + apPenalty: parseInt(result.ap) || 0, + bonus: parseInt(result.bonus) || 0, + visibility: result.visibility ?? game.settings.get("core", "rollMode"), + } + } +} diff --git a/module/applications/defense-dialog.mjs b/module/applications/defense-dialog.mjs new file mode 100644 index 0000000..3aa5207 --- /dev/null +++ b/module/applications/defense-dialog.mjs @@ -0,0 +1,112 @@ +/** + * Defense roll dialog. + * + * Pool = governing attribute (Agility default; Might option for melee) + Defense skill + * + armorPenalty (auto from equipped armor, always ≤ 0) + * + parryBonus / blockBonus (from equipped weapon traits) + * + manual bonus + * + * Parry trait on equipped weapon → red dice (3+) vs melee; +1 if two Parry weapons + * Block trait on equipped weapon → red dice (3+) vs ranged; +1 to ranged defense + */ +export default class OathHammerDefenseDialog { + + static async prompt(actor) { + const actorSys = actor.system + + // ── Attributes & skill ────────────────────────────────────────────── + const agiRank = actorSys.attributes.agility.rank + const mightRank = actorSys.attributes.might.rank + const defRank = actorSys.skills.defense.rank + + // ── Equipped weapons ──────────────────────────────────────────────── + const equipped = actor.items.filter(i => i.type === "weapon" && i.system.equipped) + const parryCount = equipped.filter(w => w.system.traits?.has?.("parry") || [...(w.system.traits ?? [])].includes("parry")).length + const blockCount = equipped.filter(w => w.system.traits?.has?.("block") || [...(w.system.traits ?? [])].includes("block")).length + + // ── Equipped armor penalty (sum) ──────────────────────────────────── + const armorPenalty = actor.items + .filter(i => i.type === "armor" && i.system.equipped) + .reduce((sum, a) => sum + (a.system.penalty ?? 0), 0) + + // ── Build option lists ─────────────────────────────────────────────── + const attackTypeOptions = [ + { value: "melee", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseMelee"), selected: true }, + { value: "ranged", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseRanged"), selected: false }, + ] + + const attrOptions = [ + { value: "agility", label: `${game.i18n.localize("OATHHAMMER.Attribute.Agility")} (${agiRank})`, selected: true }, + { value: "might", label: `${game.i18n.localize("OATHHAMMER.Attribute.Might")} (${mightRank})`, selected: false }, + ] + + const bonusOptions = Array.from({ length: 13 }, (_, i) => { + const v = i - 6 + return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } + }) + + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) + + const context = { + actorName: actor.name, + agiRank, + mightRank, + defRank, + parryCount, + blockCount, + armorPenalty, + attackTypeOptions, + attrOptions, + bonusOptions, + rollModes, + visibility: game.settings.get("core", "rollMode"), + } + + const content = await foundry.applications.handlebars.renderTemplate( + "systems/fvtt-oath-hammer/templates/defense-roll-dialog.hbs", + context + ) + + const result = await foundry.applications.api.DialogV2.wait({ + window: { title: game.i18n.format("OATHHAMMER.Dialog.DefenseTitle", { actor: actor.name }) }, + classes: ["fvtt-oath-hammer"], + content, + rejectClose: false, + buttons: [{ + label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"), + callback: (_ev, btn) => Object.fromEntries( + [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) + ), + }], + }) + + if (!result) return null + + const attackType = result.attackType ?? "melee" + const attrChoice = result.attribute ?? "agility" + const attrRank = attrChoice === "might" ? mightRank : agiRank + const bonus = parseInt(result.bonus) || 0 + + // Determine red dice and trait bonus from equipped weapons + let redDice = false + let traitBonus = 0 + if (attackType === "melee" && parryCount > 0) { + redDice = true + if (parryCount >= 2) traitBonus = 1 + } else if (attackType === "ranged" && blockCount > 0) { + redDice = true + traitBonus = 1 + } + + return { + attackType, + attrRank, + attrChoice, + redDice, + traitBonus, + armorPenalty, + bonus, + visibility: result.visibility ?? game.settings.get("core", "rollMode"), + } + } +} diff --git a/module/applications/roll-dialog.mjs b/module/applications/roll-dialog.mjs index 0d86640..f74ef71 100644 --- a/module/applications/roll-dialog.mjs +++ b/module/applications/roll-dialog.mjs @@ -68,9 +68,10 @@ export default class OathHammerRollDialog { const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) // Build select option arrays - const dvOptions = Array.from({ length: 10 }, (_, i) => { - const v = i + 1 - return { value: v, label: String(v), selected: v === 2 } + const dvOptions = Array.from({ length: 11 }, (_, i) => { + const v = i // 0..10 + const label = v === 0 ? "0 (opposed)" : String(v) + return { value: v, label, selected: v === 2 } }) const bonusOptions = Array.from({ length: 13 }, (_, i) => { @@ -130,7 +131,8 @@ export default class OathHammerRollDialog { callback: (_event, button) => { const out = {} for (const el of button.form.elements) { - if (el.name) out[el.name] = el.value + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value } return out }, @@ -143,10 +145,11 @@ export default class OathHammerRollDialog { const attrOverride = result.attrOverride || defaultAttrKey return { - dv: Math.max(1, parseInt(result.dv) || 2), + dv: Math.max(0, parseInt(result.dv) ?? 2), bonus: parseInt(result.bonus) || 0, luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), supporters: Math.max(0, parseInt(result.supporters) || 0), + explodeOn5: result.explodeOn5 === "true", attrOverride, visibility: result.visibility ?? game.settings.get("core", "rollMode"), } diff --git a/module/applications/sheets/base-actor-sheet.mjs b/module/applications/sheets/base-actor-sheet.mjs index 2ffe989..c3869d8 100644 --- a/module/applications/sheets/base-actor-sheet.mjs +++ b/module/applications/sheets/base-actor-sheet.mjs @@ -60,6 +60,16 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou /** @override */ _onRender(context, options) { this.#dragDrop.forEach((d) => d.bind(this.element)) + // ProseMirror "Save" dispatches a change event before committing its .value + // to the element, so FormDataExtended may read stale HTML. Instead we + // intercept the event here, stop it from bubbling to the submitOnChange + // handler, and update the document directly with the current editor value. + for (const pm of this.element.querySelectorAll("prose-mirror[name]")) { + pm.addEventListener("change", async (event) => { + event.stopPropagation() + await this.document.update({ [pm.name]: pm.value ?? "" }) + }) + } } #createDragDropHandlers() { @@ -89,6 +99,10 @@ export default class OathHammerActorSheet extends HandlebarsApplicationMixin(fou _onDragStart(event) { if ("link" in event.target.dataset) return + const li = event.target.closest("[data-item-uuid]") + if (!li) return + const dragData = { type: "Item", uuid: li.dataset.itemUuid } + event.dataTransfer.setData("text/plain", JSON.stringify(dragData)) } _onDragOver(event) {} diff --git a/module/applications/sheets/base-item-sheet.mjs b/module/applications/sheets/base-item-sheet.mjs index 0418567..b785dc0 100644 --- a/module/applications/sheets/base-item-sheet.mjs +++ b/module/applications/sheets/base-item-sheet.mjs @@ -81,6 +81,12 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun _onRender(context, options) { super._onRender(context, options) this.#dragDrop.forEach((d) => d.bind(this.element)) + for (const pm of this.element.querySelectorAll("prose-mirror[name]")) { + pm.addEventListener("change", async (event) => { + event.stopPropagation() + await this.document.update({ [pm.name]: pm.value ?? "" }) + }) + } } #createDragDropHandlers() { diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index 38c07f5..a6a4afd 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -4,7 +4,9 @@ import OathHammerRollDialog from "../roll-dialog.mjs" import OathHammerWeaponDialog from "../weapon-dialog.mjs" import OathHammerSpellDialog from "../spell-dialog.mjs" import OathHammerMiracleDialog from "../miracle-dialog.mjs" -import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast } from "../../rolls.mjs" +import OathHammerDefenseDialog from "../defense-dialog.mjs" +import OathHammerArmorDialog from "../armor-dialog.mjs" +import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense } from "../../rolls.mjs" export default class OathHammerCharacterSheet extends OathHammerActorSheet { /** @override */ @@ -22,11 +24,15 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { createSpell: OathHammerCharacterSheet.#onCreateSpell, createMiracle: OathHammerCharacterSheet.#onCreateMiracle, createEquipment: OathHammerCharacterSheet.#onCreateEquipment, + createTrait: OathHammerCharacterSheet.#onCreateTrait, rollSkill: OathHammerCharacterSheet.#onRollSkill, attackWeapon: OathHammerCharacterSheet.#onAttackWeapon, + defendWeapon: OathHammerCharacterSheet.#onDefendWeapon, damageWeapon: OathHammerCharacterSheet.#onDamageWeapon, castSpell: OathHammerCharacterSheet.#onCastSpell, castMiracle: OathHammerCharacterSheet.#onCastMiracle, + rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave, + resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked, }, } @@ -101,6 +107,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { _violated: o.system.violated } }) + context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true }) break case "skills": { context.tab = context.tabs.skills @@ -170,6 +177,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { } }) context.ammunition = doc.itemTypes.ammunition + // Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots + context.slotsMax = 10 + (doc.system.attributes.might.rank * 2) + context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0) + context.slotsOver = context.slotsUsed > context.slotsMax break case "magic": context.tab = context.tabs.magic @@ -180,12 +191,14 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { context.tab = context.tabs.equipment context.equipment = doc.itemTypes.equipment context.magicItems = doc.itemTypes["magic-item"] + context.slotsMax = 10 + (doc.system.attributes.might.rank * 2) + context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0) + context.slotsOver = context.slotsUsed > context.slotsMax break case "notes": context.tab = context.tabs.notes - context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background, { async: true }) - context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.description, { async: true }) - context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.notes, { async: true }) + context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.description ?? "", { async: true }) + context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.notes ?? "", { async: true }) break } return context @@ -246,6 +259,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Equipment"), type: "equipment" }]) } + static #onCreateTrait(event, target) { + this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }]) + } + static async #onRollSkill(event, target) { const skillKey = target.dataset.skill if (!skillKey) return @@ -264,6 +281,16 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { await rollWeaponAttack(this.document, weapon, opts) } + static async #onDefendWeapon(event, target) { + const weaponId = target.dataset.itemId + if (!weaponId) return + const weapon = this.document.items.get(weaponId) + if (!weapon) return + const opts = await OathHammerWeaponDialog.promptDefense(this.document, weapon) + if (!opts) return + await rollWeaponDefense(this.document, weapon, opts) + } + static async #onDamageWeapon(event, target) { const weaponId = target.dataset.itemId if (!weaponId) return @@ -289,8 +316,26 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { if (!miracleId) return const miracle = this.document.items.get(miracleId) if (!miracle) return + if (this.document.system.miracleBlocked) { + ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.MiracleBlocked")) + return + } const opts = await OathHammerMiracleDialog.prompt(this.document, miracle) if (!opts) return await rollMiracleCast(this.document, miracle, opts) } + + static async #onResetMiracleBlocked() { + await this.document.update({ "system.miracleBlocked": false }) + } + + static async #onRollArmorSave(event, target) { + const armorId = target.dataset.itemId + if (!armorId) return + const armor = this.document.items.get(armorId) + if (!armor) return + const opts = await OathHammerArmorDialog.prompt(this.document, armor) + if (!opts) return + await rollArmorSave(this.document, armor, opts) + } } diff --git a/module/applications/sheets/class-sheet.mjs b/module/applications/sheets/class-sheet.mjs index df506e0..bab4a41 100644 --- a/module/applications/sheets/class-sheet.mjs +++ b/module/applications/sheets/class-sheet.mjs @@ -28,15 +28,26 @@ export default class OathHammerClassSheet extends OathHammerItemSheet { return context } - /** @override — collect checkbox sets explicitly so unchecking all works */ - _prepareSubmitData(event, form, formData) { - const data = super._prepareSubmitData(event, form, formData) - data["system.armorProficiency"] = Array.from( - form.querySelectorAll('input[name="system.armorProficiency"]:checked') - ).map(el => el.value) - data["system.weaponProficiency"] = Array.from( - form.querySelectorAll('input[name="system.weaponProficiency"]:checked') - ).map(el => el.value) - return data + /** @override */ + _onRender(context, options) { + super._onRender(context, options) + // Handle proficiency checkboxes directly — FormDataExtended mishandles + // multiple same-named checkboxes, so we intercept the change event, + // collect all checked values ourselves, and stop propagation to prevent + // the generic submitOnChange handler from clobbering the data. + for (const cb of this.element.querySelectorAll('.proficiency-checkboxes input[type="checkbox"]')) { + cb.addEventListener("change", this.#onProficiencyChange.bind(this)) + } + } + + async #onProficiencyChange(event) { + event.stopPropagation() + const root = this.element + const armorProficiency = [...root.querySelectorAll('input[name="system.armorProficiency"]:checked')].map(e => e.value) + const weaponProficiency = [...root.querySelectorAll('input[name="system.weaponProficiency"]:checked')].map(e => e.value) + await this.document.update({ + "system.armorProficiency": armorProficiency, + "system.weaponProficiency": weaponProficiency, + }) } } diff --git a/module/applications/weapon-dialog.mjs b/module/applications/weapon-dialog.mjs index cbac4e3..2b38184 100644 --- a/module/applications/weapon-dialog.mjs +++ b/module/applications/weapon-dialog.mjs @@ -129,6 +129,143 @@ export default class OathHammerWeaponDialog { } } + // ------------------------------------------------------------------ // + // DEFENSE DIALOG + // ------------------------------------------------------------------ // + + /** + * Show the weapon defense dialog and return resolved options. + * + * Defense pool = Agility (or Might) + Defense skill + trait bonuses + armor penalty + diminish penalty + bonus + * + * Parry trait → red dice vs melee; +1 if two Parry weapons equipped + * Block trait → red dice vs ranged; +1 bonus always + * Diminishing defense: -2 per additional defense after the first in a turn + */ + static async promptDefense(actor, weapon) { + const sys = weapon.system + const actorSys = actor.system + + const agiRank = actorSys.attributes.agility.rank + const mightRank = actorSys.attributes.might.rank + const defRank = actorSys.skills.defense.rank + + // Detect this weapon's defense-relevant traits + const hasParry = sys.traits.has("parry") + const hasBlock = sys.traits.has("block") + + // Count all equipped parry/block weapons (for +1 with two Parry weapons) + const equipped = actor.items.filter(i => i.type === "weapon" && i.system.equipped) + const parryCount = equipped.filter(w => w.system.traits?.has?.("parry") || [...(w.system.traits ?? [])].includes("parry")).length + const blockCount = equipped.filter(w => w.system.traits?.has?.("block") || [...(w.system.traits ?? [])].includes("block")).length + + // Armor penalty from all equipped armors + const armorPenalty = actor.items + .filter(i => i.type === "armor" && i.system.equipped) + .reduce((sum, a) => sum + (a.system.penalty ?? 0), 0) + + // Pre-select attack type: block weapons default to ranged, parry to melee + const defaultAttackType = hasBlock && !hasParry ? "ranged" : "melee" + + const traitLabels = [...sys.traits].map(t => { + const key = SYSTEM.WEAPON_TRAITS[t] + return key ? game.i18n.localize(key) : t + }) + + const attackTypeOptions = [ + { value: "melee", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseMelee"), selected: defaultAttackType === "melee" }, + { value: "ranged", label: game.i18n.localize("OATHHAMMER.Dialog.DefenseRanged"), selected: defaultAttackType === "ranged" }, + ] + + const attrOptions = [ + { value: "agility", label: `${game.i18n.localize("OATHHAMMER.Attribute.Agility")} (${agiRank})`, selected: true }, + { value: "might", label: `${game.i18n.localize("OATHHAMMER.Attribute.Might")} (${mightRank})`, selected: false }, + ] + + const diminishOptions = [ + { value: 0, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishFirst"), selected: true }, + { value: -2, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishSecond"), selected: false }, + { value: -4, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishThird"), selected: false }, + ] + + const bonusOptions = Array.from({ length: 13 }, (_, i) => { + const v = i - 6 + return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } + }) + + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) + + const context = { + actorName: actor.name, + weaponName: weapon.name, + weaponImg: weapon.img, + agiRank, + mightRank, + defRank, + hasParry, + hasBlock, + parryCount, + blockCount, + armorPenalty, + traits: traitLabels, + attackTypeOptions, + attrOptions, + diminishOptions, + bonusOptions, + rollModes, + visibility: game.settings.get("core", "rollMode"), + } + + const content = await foundry.applications.handlebars.renderTemplate( + "systems/fvtt-oath-hammer/templates/weapon-defense-dialog.hbs", + context + ) + + const result = await foundry.applications.api.DialogV2.wait({ + window: { title: game.i18n.format("OATHHAMMER.Dialog.WeaponDefenseTitle", { weapon: weapon.name }) }, + classes: ["fvtt-oath-hammer"], + content, + rejectClose: false, + buttons: [{ + label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"), + callback: (_ev, btn) => Object.fromEntries( + [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) + ), + }], + }) + + if (!result) return null + + const attackType = result.attackType ?? defaultAttackType + const attrChoice = result.attribute ?? "agility" + const attrRank = attrChoice === "might" ? mightRank : agiRank + const diminishPenalty = parseInt(result.diminish) || 0 + const bonus = parseInt(result.bonus) || 0 + + // Resolve red dice and trait bonus based on selected attack type + let redDice = false + let traitBonus = 0 + if (attackType === "melee" && hasParry) { + redDice = true + traitBonus = parryCount >= 2 ? 1 : 0 + } else if (attackType === "ranged" && hasBlock) { + redDice = true + traitBonus = 1 + } + + return { + attackType, + attrRank, + attrChoice, + redDice, + traitBonus, + armorPenalty, + diminishPenalty, + bonus, + visibility: result.visibility ?? game.settings.get("core", "rollMode"), + } + } + // ------------------------------------------------------------------ // // DAMAGE DIALOG // ------------------------------------------------------------------ // diff --git a/module/models/character.mjs b/module/models/character.mjs index 323c08f..5aa325a 100644 --- a/module/models/character.mjs +++ b/module/models/character.mjs @@ -82,6 +82,8 @@ export default class OathHammerCharacter extends foundry.abstract.TypeDataModel threshold: new fields.NumberField({ ...requiredInteger, initial: 10, min: 0 }) }) + schema.miracleBlocked = new fields.BooleanField({ required: true, initial: false }) + schema.movement = new fields.SchemaField({ base: new fields.NumberField({ ...requiredInteger, initial: 30, min: 0 }), adjusted: new fields.NumberField({ ...requiredInteger, initial: 30, min: 0 }) diff --git a/module/rolls.mjs b/module/rolls.mjs index 37a9916..fd09398 100644 --- a/module/rolls.mjs +++ b/module/rolls.mjs @@ -24,7 +24,7 @@ import { SYSTEM } from "./config/system.mjs" * @returns {Promise<{successes: number, dv: number, isSuccess: boolean}>} */ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { - const { bonus = 0, luckSpend = 0, supporters = 0, attrOverride, visibility, flavor } = options + const { bonus = 0, luckSpend = 0, supporters = 0, attrOverride, visibility, flavor, explodeOn5 = false } = options const sys = actor.system const skillDef = SYSTEM.SKILLS[skillKey] @@ -52,7 +52,8 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { // Roll the dice pool const roll = await new Roll(`${totalDice}d6`).evaluate() - // Count successes — exploding 6s produce additional dice + // Count successes — exploding dice produce additional dice + const explodeThreshold = explodeOn5 ? 5 : 6 let successes = 0 const diceResults = [] let extraDice = 0 @@ -60,7 +61,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { for (const r of roll.dice[0].results) { const val = r.result if (val >= threshold) successes++ - if (val === 6) extraDice++ + if (val >= explodeThreshold) extraDice++ diceResults.push({ val, exploded: false }) } @@ -70,12 +71,13 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { for (const r of xRoll.dice[0].results) { const val = r.result if (val >= threshold) successes++ - if (val === 6) extraDice++ + if (val >= explodeThreshold) extraDice++ diceResults.push({ val, exploded: true }) } } - const isSuccess = successes >= dv + const isOpposed = dv === 0 + const isSuccess = isOpposed ? null : successes >= dv const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜" const skillLabel = game.i18n.localize(skillDef.label) const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrKey.charAt(0).toUpperCase()}${attrKey.slice(1)}`) @@ -89,18 +91,26 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { }).join(" ") // Build modifier summary + const explodedCount = diceResults.filter(d => d.exploded).length const modParts = [] if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) if (luckSpend > 0) modParts.push(`+${luckSpend * 2} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP)`) if (supporters > 0) modParts.push(`+${supporters} ${game.i18n.localize("OATHHAMMER.Dialog.Supporters")}`) + if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" - const resultClass = isSuccess ? "roll-success" : "roll-failure" - const resultLabel = isSuccess - ? game.i18n.localize("OATHHAMMER.Roll.Success") - : game.i18n.localize("OATHHAMMER.Roll.Failure") + const resultClass = isOpposed ? "roll-opposed" : isSuccess ? "roll-success" : "roll-failure" + const resultLabel = isOpposed + ? game.i18n.localize("OATHHAMMER.Roll.Opposed") + : isSuccess + ? game.i18n.localize("OATHHAMMER.Roll.Success") + : game.i18n.localize("OATHHAMMER.Roll.Failure") - const cardFlavor = flavor ?? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})` + const cardFlavor = flavor ?? (isOpposed + ? `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (${game.i18n.localize("OATHHAMMER.Roll.Opposed")})` + : `${skillLabel} ${game.i18n.localize("OATHHAMMER.Roll.Check")} (DV ${dv})`) + + const successDisplay = isOpposed ? String(successes) : `${successes} / ${dv}` const content = `
@@ -112,7 +122,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { ${modLine}
${diceHtml}
- ${successes} / ${dv} + ${successDisplay} ${resultLabel}
@@ -549,7 +559,247 @@ export async function rollMiracleCast(actor, miracle, options = {}) { ChatMessage.applyRollMode(msgData, rollMode) await ChatMessage.create(msgData) + if (!isSuccess) { + await actor.update({ "system.miracleBlocked": true }) + } + return { successes, dv, isSuccess } } function _cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : "" } + +// ============================================================ +// DEFENSE ROLL +// ============================================================ + +/** + * Roll a defense check (Agility/Might + Defense skill) and post to chat. + * + * @param {Actor} actor + * @param {object} options From OathHammerDefenseDialog.prompt() + */ +export async function rollDefense(actor, options = {}) { + const { + attackType = "melee", + attrRank = 0, + attrChoice = "agility", + redDice = false, + traitBonus = 0, + armorPenalty = 0, + bonus = 0, + visibility, + } = options + + const defRank = actor.system.skills.defense.rank + const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + bonus, 1) + const threshold = redDice ? 3 : 4 + const colorEmoji = redDice ? "🔴" : "⬜" + + const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const diceHtml = _diceHtml(diceResults, threshold) + + const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrChoice.charAt(0).toUpperCase() + attrChoice.slice(1)}`) + const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Defense") + const typeLabel = game.i18n.localize(attackType === "ranged" ? "OATHHAMMER.Dialog.DefenseRanged" : "OATHHAMMER.Dialog.DefenseMelee") + + const modParts = [] + if (traitBonus > 0) modParts.push(`+${traitBonus} ${game.i18n.localize(attackType === "melee" ? "OATHHAMMER.WeaponTrait.Parry" : "OATHHAMMER.WeaponTrait.Block")}`) + if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`) + if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" + + const content = ` +
+
+ + ${game.i18n.localize("OATHHAMMER.Roll.Defense")} — ${typeLabel} +
+
+ ${attrLabel} ${attrRank} + ${skillLabel} ${defRank} + ${colorEmoji} ${totalDice}d6 (${threshold}+) +
+ ${modLine} +
${diceHtml}
+
+ ${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")} + ${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")} +
+
+ ` + + const rollMode = visibility ?? game.settings.get("core", "rollMode") + const msgData = { + speaker: ChatMessage.getSpeaker({ actor }), + content, + rolls: [roll], + sound: CONFIG.sounds.dice, + } + ChatMessage.applyRollMode(msgData, rollMode) + await ChatMessage.create(msgData) + + return { successes } +} + +// ============================================================ +// WEAPON DEFENSE ROLL +// ============================================================ + +/** + * Roll a defense check triggered from a specific weapon, applying the + * weapon's Parry / Block traits, and post to chat. + * + * Pool = (Agility or Might) + Defense skill + * + traitBonus (Parry: +1 if two Parry weapons; Block: +1 vs ranged) + * + armorPenalty (≤ 0) + * + diminishPenalty (0 / −2 / −4 for 1st / 2nd / 3rd+ defense) + * + bonus + * + * Parry trait → red dice (3+) when defending vs melee attacks + * Block trait → red dice (3+) + +1 bonus when defending vs ranged attacks + * + * @param {Actor} actor + * @param {Item} weapon The weapon used to defend + * @param {object} options From OathHammerWeaponDialog.promptDefense() + */ +export async function rollWeaponDefense(actor, weapon, options = {}) { + const { + attackType = "melee", + attrRank = 0, + attrChoice = "agility", + redDice = false, + traitBonus = 0, + armorPenalty = 0, + diminishPenalty = 0, + bonus = 0, + visibility, + } = options + + const defRank = actor.system.skills.defense.rank + const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus, 1) + const threshold = redDice ? 3 : 4 + const colorEmoji = redDice ? "🔴" : "⬜" + + const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const diceHtml = _diceHtml(diceResults, threshold) + + const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${_cap(attrChoice)}`) + const skillLabel = game.i18n.localize("OATHHAMMER.Skill.Defense") + const typeLabel = game.i18n.localize(attackType === "ranged" ? "OATHHAMMER.Dialog.DefenseRanged" : "OATHHAMMER.Dialog.DefenseMelee") + + const modParts = [] + if (traitBonus > 0) modParts.push(`+${traitBonus} ${game.i18n.localize(attackType === "melee" ? "OATHHAMMER.WeaponTrait.Parry" : "OATHHAMMER.WeaponTrait.Block")}`) + if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`) + if (diminishPenalty < 0) modParts.push(`${diminishPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.DiminishingDefense")}`) + if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" + + const content = ` +
+
+ ${weapon.name} + ${weapon.name} — ${game.i18n.localize("OATHHAMMER.Roll.Defense")} (${typeLabel}) +
+
+ ${attrLabel} ${attrRank} + ${skillLabel} ${defRank} + ${colorEmoji} ${totalDice}d6 (${threshold}+) +
+ ${modLine} +
${diceHtml}
+
+ ${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")} + ${game.i18n.localize("OATHHAMMER.Roll.DefenseResult")} +
+
+ ` + + const rollMode = visibility ?? game.settings.get("core", "rollMode") + const msgData = { + speaker: ChatMessage.getSpeaker({ actor }), + content, + rolls: [roll], + sound: CONFIG.sounds.dice, + } + ChatMessage.applyRollMode(msgData, rollMode) + await ChatMessage.create(msgData) + + return { successes } +} + +// ============================================================ +// ARMOR ROLL +// ============================================================ + +/** + * Roll an armor saving roll (AV dice − AP) and post to chat. + * Unlike other rolls, AP can reduce the pool to 0 (armor bypassed). + * Each success reduces incoming damage by 1. + * + * @param {Actor} actor + * @param {Item} armor + * @param {object} options From OathHammerArmorDialog.prompt() + */ +export async function rollArmorSave(actor, armor, options = {}) { + const { + av = armor.system.armorValue ?? 0, + isReinforced = false, + apPenalty = 0, + bonus = 0, + visibility, + } = options + + // Armor CAN be reduced to 0 dice (fully bypassed by AP) + const totalDice = Math.max(av + apPenalty + bonus, 0) + const threshold = isReinforced ? 3 : 4 + const colorEmoji = isReinforced ? "🔴" : "⬜" + + let successes = 0 + let diceHtml = `${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")}` + let roll + + if (totalDice > 0) { + const result = await _rollPool(totalDice, threshold) + roll = result.roll + successes = result.successes + diceHtml = _diceHtml(result.diceResults, threshold) + } else { + // Zero dice — create a dummy roll with no results so Foundry can still attach it + roll = new Roll("0d6") + await roll.evaluate() + } + + const modParts = [] + if (apPenalty < 0) modParts.push(`AP ${apPenalty}`) + if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" + + const content = ` +
+
+ ${armor.name} + ${armor.name} (AV ${av}) +
+
+ ${game.i18n.localize("OATHHAMMER.Roll.ArmorRoll")} + ${colorEmoji} ${totalDice}d6 (${threshold}+) +
+ ${modLine} +
${diceHtml}
+
+ ${successes} ${game.i18n.localize("OATHHAMMER.Roll.Successes")} + −${successes} ${game.i18n.localize("OATHHAMMER.Roll.Damage")} +
+
+ ` + + const rollMode = visibility ?? game.settings.get("core", "rollMode") + const msgData = { + speaker: ChatMessage.getSpeaker({ actor }), + content, + rolls: [roll], + sound: CONFIG.sounds.dice, + } + ChatMessage.applyRollMode(msgData, rollMode) + await ChatMessage.create(msgData) + + return { successes, totalDice } +} diff --git a/system.json b/system.json index 12c9dfd..4924759 100644 --- a/system.json +++ b/system.json @@ -25,6 +25,7 @@ "character": { "htmlFields": [ "description", + "background", "notes", "lineage.traits" ] @@ -32,6 +33,7 @@ "npc": { "htmlFields": [ "description", + "background", "notes" ] } diff --git a/templates/actor/character-combat.hbs b/templates/actor/character-combat.hbs index f146e6d..72a667b 100644 --- a/templates/actor/character-combat.hbs +++ b/templates/actor/character-combat.hbs @@ -1,21 +1,8 @@
-
- {{localize "OATHHAMMER.Label.Defense"}} -
-
- - -
-
- - {{formInput systemFields.defense.fields.armorRating value=system.defense.armorRating name="system.defense.armorRating" disabled=isPlayMode}} -
-
- - {{formInput systemFields.defense.fields.bonus value=system.defense.bonus name="system.defense.bonus" disabled=isPlayMode}} -
-
-
+
+ {{localize "OATHHAMMER.Label.ItemSlots"}} + {{slotsUsed}} / {{slotsMax}} +
{{localize "OATHHAMMER.Label.Weapons"}} {{#unless isPlayMode}}{{/unless}} @@ -41,10 +28,11 @@ {{#if weapon.system.ap}}{{weapon.system.ap}}{{else}}—{{/if}} {{#if weapon._isMagic}}{{/if}}
- +
+ @@ -80,9 +68,10 @@ {{#if armor.system.penalty}}{{armor.system.penalty}}{{else}}—{{/if}} {{#if armor._isMagic}}{{/if}}
- +
+
diff --git a/templates/actor/character-equipment.hbs b/templates/actor/character-equipment.hbs index 145c54f..3994ecb 100644 --- a/templates/actor/character-equipment.hbs +++ b/templates/actor/character-equipment.hbs @@ -1,4 +1,8 @@
+
+ {{localize "OATHHAMMER.Label.ItemSlots"}} + {{slotsUsed}} / {{slotsMax}} +
{{localize "OATHHAMMER.Label.Currency"}}
diff --git a/templates/actor/character-identity.hbs b/templates/actor/character-identity.hbs index c17ba9b..2de8fa4 100644 --- a/templates/actor/character-identity.hbs +++ b/templates/actor/character-identity.hbs @@ -3,9 +3,11 @@ {{localize "OATHHAMMER.Label.Background"}} {{formInput systemFields.background enriched=enrichedBackground value=system.background name="system.background" toggled=true}}
- {{#if traits.length}}
- {{localize "OATHHAMMER.Label.Traits"}} + {{localize "OATHHAMMER.Label.Traits"}} + {{#unless isPlayMode}}{{/unless}} + + {{#if traits.length}}
  • @@ -15,7 +17,7 @@
  • {{#each traits as |trait|}} -
  • +
  • {{trait.name}} {{trait._typeLabel}} @@ -27,8 +29,10 @@
  • {{/each}}
+ {{else}} +

{{localize "OATHHAMMER.Label.NoTraits"}}

+ {{/if}}
- {{/if}} {{#if oaths.length}}
{{localize "OATHHAMMER.Label.Oaths"}} diff --git a/templates/actor/character-magic.hbs b/templates/actor/character-magic.hbs index fcb2cb4..7865f57 100644 --- a/templates/actor/character-magic.hbs +++ b/templates/actor/character-magic.hbs @@ -43,8 +43,15 @@ {{localize "OATHHAMMER.Label.Miracles"}} {{#unless isPlayMode}}{{/unless}} + {{#if system.miracleBlocked}} +
+ + {{localize "OATHHAMMER.Label.MiracleBlocked"}} + {{localize "OATHHAMMER.Action.NewDay"}} +
+ {{/if}} {{#if miracles.length}} -
    +
    • {{localize "OATHHAMMER.Label.Name"}} diff --git a/templates/armor-roll-dialog.hbs b/templates/armor-roll-dialog.hbs new file mode 100644 index 0000000..7033390 --- /dev/null +++ b/templates/armor-roll-dialog.hbs @@ -0,0 +1,50 @@ +
      + + {{!-- Armor header --------------------------------------------------------}} +
      + {{armorName}} +
      + {{armorName}} +
      + AV {{av}} + {{#if isReinforced}}{{localize "OATHHAMMER.ArmorTrait.Reinforced"}} 🔴{{/if}} +
      +
      +
      + +
      + {{localize "OATHHAMMER.Dialog.ArmorRollOptions"}} + +
      + AV {{av}} + {{#if isReinforced}} · {{localize "OATHHAMMER.Dialog.ReinforcedHint"}}{{/if}} +
      + + {{!-- AP penalty from attacker's weapon ----------------------------------}} +
      + + + {{localize "OATHHAMMER.Dialog.APHint"}} +
      + + {{!-- Manual bonus -------------------------------------------------------}} +
      + + +
      + +
      + + {{!-- Visibility -----------------------------------------------------------}} +
      + {{localize "OATHHAMMER.Dialog.Visibility"}} + +
      + +
      diff --git a/templates/defense-roll-dialog.hbs b/templates/defense-roll-dialog.hbs new file mode 100644 index 0000000..0790389 --- /dev/null +++ b/templates/defense-roll-dialog.hbs @@ -0,0 +1,63 @@ +
      + + {{!-- Pool preview -------------------------------------------------------}} +
      + {{localize "OATHHAMMER.Skill.Defense"}} ({{localize "OATHHAMMER.Attribute.Agility"}} {{agiRank}}) + + {{localize "OATHHAMMER.Label.SkillRank"}} {{defRank}} + {{#if armorPenalty}} {{armorPenalty}} {{localize "OATHHAMMER.Dialog.ArmorPenalty"}}{{/if}} +
      + + {{!-- Trait summary -------------------------------------------------------}} + {{#if parryCount}} +
      + + {{localize "OATHHAMMER.Dialog.ParryActive"}}{{#if (gte parryCount 2)}} (+1){{/if}} +
      + {{/if}} + {{#if blockCount}} +
      + + {{localize "OATHHAMMER.Dialog.BlockActive"}} +
      + {{/if}} + +
      + {{localize "OATHHAMMER.Dialog.DefenseOptions"}} + + {{!-- Attack type --------------------------------------------------------}} +
      + + +
      + + {{!-- Governing attribute (melee can use Might) --------------------------}} +
      + + + {{localize "OATHHAMMER.Dialog.MightMeleeHint"}} +
      + + {{!-- Manual bonus -------------------------------------------------------}} +
      + + + {{localize "OATHHAMMER.Dialog.AttackModifierHint"}} +
      + +
      + + {{!-- Visibility -----------------------------------------------------------}} +
      + {{localize "OATHHAMMER.Dialog.Visibility"}} + +
      + +
      diff --git a/templates/item/class-sheet.hbs b/templates/item/class-sheet.hbs index 2ec6f7a..76ab600 100644 --- a/templates/item/class-sheet.hbs +++ b/templates/item/class-sheet.hbs @@ -12,7 +12,7 @@ {{/each}} @@ -26,7 +26,7 @@ {{/each}} diff --git a/templates/roll-dialog.hbs b/templates/roll-dialog.hbs index b626e7a..875103c 100644 --- a/templates/roll-dialog.hbs +++ b/templates/roll-dialog.hbs @@ -61,6 +61,12 @@ {{localize "OATHHAMMER.Dialog.SupportersHint"}}
+
+ + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
+ {{#if availableLuck}}
diff --git a/templates/weapon-defense-dialog.hbs b/templates/weapon-defense-dialog.hbs new file mode 100644 index 0000000..e91989c --- /dev/null +++ b/templates/weapon-defense-dialog.hbs @@ -0,0 +1,85 @@ +
+ + {{!-- Weapon header --}} +
+ {{weaponName}} +
+ {{weaponName}} + {{#if traits}} +
+ {{#each traits}}{{this}}{{/each}} +
+ {{/if}} +
+
+ + {{!-- Pool preview --}} +
+ {{localize "OATHHAMMER.Skill.Defense"}} ({{localize "OATHHAMMER.Attribute.Agility"}} {{agiRank}}) + + {{localize "OATHHAMMER.Label.SkillRank"}} {{defRank}} + {{#if armorPenalty}} {{armorPenalty}} {{localize "OATHHAMMER.Dialog.ArmorPenalty"}}{{/if}} +
+ + {{!-- Active trait indicators --}} + {{#if hasParry}} +
+ + {{localize "OATHHAMMER.Dialog.ParryActive"}}{{#if (gte parryCount 2)}} (+1){{/if}} +
+ {{/if}} + {{#if hasBlock}} +
+ + {{localize "OATHHAMMER.Dialog.BlockActive"}} +
+ {{/if}} + +
+ {{localize "OATHHAMMER.Dialog.DefenseOptions"}} + + {{!-- Attack type --}} +
+ + +
+ + {{!-- Governing attribute (melee can use Might) --}} +
+ + + {{localize "OATHHAMMER.Dialog.MightMeleeHint"}} +
+ + {{!-- Diminishing defense penalty --}} +
+ + + {{localize "OATHHAMMER.Dialog.DiminishingDefenseHint"}} +
+ + {{!-- Manual bonus/penalty --}} +
+ + + {{localize "OATHHAMMER.Dialog.AttackModifierHint"}} +
+ +
+ + {{!-- Visibility --}} +
+ {{localize "OATHHAMMER.Dialog.Visibility"}} + +
+ +