Various fixes and add rune support

This commit is contained in:
2026-03-30 23:38:45 +02:00
parent 2bf737a3ef
commit fb04448ab0
18 changed files with 506 additions and 9 deletions

View File

@@ -570,6 +570,26 @@
.oathhammer .character-main .character-stats-band .character-resources .character-resource.character-resource--luck .luck-btn:hover {
color: #2a1a0a;
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-stepper {
display: flex;
align-items: center;
gap: 1px;
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.4rem;
font-size: calc(0.86rem * 0.85);
font-weight: bold;
color: #535128;
cursor: pointer;
line-height: 1;
}
.oathhammer .character-main .character-stats-band .character-resources .character-resource .grit-btn:hover {
color: #2a1a0a;
}
.oathhammer .character-main .character-stats-band .character-resources .resource-label {
min-width: 4.2rem;
font-family: "BlueDragon", "Palatino Linotype", serif;
@@ -1419,6 +1439,110 @@
.oathhammer .item-sheet-common .enchantment-fieldset .enchant-cursed-label input[type="checkbox"] {
margin: 0;
}
.oathhammer .item-sheet-common .rune-zone {
margin-top: 6px;
border-color: #084a74;
}
.oathhammer .item-sheet-common .rune-zone legend {
color: #084a74;
}
.oathhammer .item-sheet-common .rune-zone legend i {
margin-right: 4px;
}
.oathhammer .item-sheet-common .rune-zone .rune-list {
list-style: none;
margin: 0 0 6px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.06);
border: 1px solid #535128;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry.rune-exalted {
border-color: #084a74;
background: rgba(8, 74, 116, 0.08);
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-img {
width: 24px;
height: 24px;
border-radius: 3px;
border: 1px solid #535128;
-o-object-fit: cover;
object-fit: cover;
flex-shrink: 0;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-name {
flex: 1;
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: 0.86rem;
font-weight: bold;
color: #2a1a0a;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-badge-exalted {
color: #084a74;
font-size: calc(0.86rem * 0.85);
font-weight: bold;
flex-shrink: 0;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-dv {
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: calc(0.86rem * 0.9);
color: #535128;
flex-shrink: 0;
white-space: nowrap;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-duration {
font-family: "Calibri", "Segoe UI", sans-serif;
font-size: calc(0.86rem * 0.9);
color: #2a1a0a;
opacity: 0.7;
flex-shrink: 0;
white-space: nowrap;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-effect-toggle {
color: #535128;
flex-shrink: 0;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-effect-toggle:hover {
color: #084a74;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-remove {
color: #2a1a0a;
opacity: 0.4;
flex-shrink: 0;
margin-left: auto;
}
.oathhammer .item-sheet-common .rune-zone .rune-entry .rune-remove:hover {
color: #c0392b;
opacity: 1;
}
.oathhammer .item-sheet-common .rune-zone .rune-drop-zone {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border: 1px dashed #535128;
border-radius: 3px;
color: #535128;
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: calc(0.86rem * 0.85);
font-style: italic;
opacity: 0.7;
pointer-events: none;
}
.oathhammer .item-sheet-common .proficiency-section {
display: flex;
gap: 8px;
@@ -2624,6 +2748,21 @@
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: calc(0.86rem * 0.85);
}
.oathhammer .party-main .party-slots .party-renown-label {
font-weight: bold;
color: #535128;
margin-left: 0.8rem;
margin-right: 0.3rem;
text-transform: uppercase;
font-size: calc(0.86rem * 0.9);
letter-spacing: 0.04em;
}
.oathhammer .party-main .party-slots .party-renown-value {
width: 3.5rem;
text-align: center;
font-family: "BlueDragon", "Palatino Linotype", serif;
font-size: calc(0.86rem * 0.85);
}
.oathhammer .item-list--party-member .item-list-header,
.oathhammer .item-list--party-member .item-entry {
grid-template-columns: 1.8rem 24px 1fr 7rem 5rem 3rem 5.5rem;

View File

@@ -373,7 +373,13 @@
"Mercenary": "Mercenary",
"CurrentXP": "XP",
"CarriesLight": "Carries Light",
"Slots": "Slots"
"Slots": "Slots",
"Runes": "Runes",
"DropRuneHint": "Drop a runic spell here to attach it…",
"RemoveRune": "Remove rune",
"OpenRune": "Open spell sheet",
"DifficultyValue": "Difficulty Value",
"Exalted": "Exalted"
},
"ColorDice": {
"White": "White (4+)",
@@ -540,6 +546,15 @@
"MagicSpells": "spells"
}
},
"Rune": {
"Attached": "Rune \"{name}\" attached.",
"NotRunic": "Only runic tradition spells can be attached as runes.",
"WrongType": "This item only accepts {expected} runes.",
"MaxRunes": "This item already has 2 runes (maximum).",
"Duplicate": "This rune is already attached to this item.",
"MaxExalted": "This item already bears an exalted rune (maximum 1).",
"SourceNotFound": "Source spell not found — it may have been deleted."
},
"FreeRoll": {
"Label": "Free Roll",
"PoolTitle": "Number of dice (120)",

View File

@@ -189,6 +189,25 @@
&:hover { color: @color-dark; }
}
}
.grit-stepper {
display: flex;
align-items: center;
gap: 1px;
}
.grit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.4rem;
font-size: @font-size-sm;
font-weight: bold;
color: @color-olive;
cursor: pointer;
line-height: 1;
&:hover { color: @color-dark; }
}
}
.resource-label {

View File

@@ -134,7 +134,115 @@
}
}
// ── Class proficiency checkboxes ────────────────────────────
// ── Rune zone ──────────────────────────────────────────────────
.rune-zone {
margin-top: 6px;
border-color: @color-blue;
legend {
color: @color-blue;
i { margin-right: 4px; }
}
.rune-list {
list-style: none;
margin: 0 0 6px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rune-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-radius: 3px;
background: rgba(0,0,0,0.06);
border: 1px solid @color-olive;
&.rune-exalted {
border-color: @color-blue;
background: fade(@color-blue, 8%);
}
.rune-img {
width: 24px;
height: 24px;
border-radius: 3px;
border: 1px solid @color-olive;
object-fit: cover;
flex-shrink: 0;
}
.rune-name {
flex: 1;
font-family: @font-secondary;
font-size: @font-size-base;
font-weight: bold;
color: @color-dark;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rune-badge-exalted {
color: @color-blue;
font-size: @font-size-sm;
font-weight: bold;
flex-shrink: 0;
}
.rune-dv {
font-family: @font-secondary;
font-size: @font-size-xs;
color: @color-olive;
flex-shrink: 0;
white-space: nowrap;
}
.rune-duration {
font-family: @font-body;
font-size: @font-size-xs;
color: @color-dark;
opacity: 0.7;
flex-shrink: 0;
white-space: nowrap;
}
.rune-effect-toggle {
color: @color-olive;
flex-shrink: 0;
&:hover { color: @color-blue; }
}
.rune-remove {
color: @color-dark;
opacity: 0.4;
flex-shrink: 0;
margin-left: auto;
&:hover { color: #c0392b; opacity: 1; }
}
}
.rune-drop-zone {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border: 1px dashed @color-olive;
border-radius: 3px;
color: @color-olive;
font-family: @font-secondary;
font-size: @font-size-sm;
font-style: italic;
opacity: 0.7;
pointer-events: none;
}
}
.proficiency-section {
display: flex;
gap: 8px;

View File

@@ -161,6 +161,21 @@
font-family: @font-secondary;
font-size: @font-size-sm;
}
.party-renown-label {
font-weight: bold;
color: @color-olive;
margin-left: 0.8rem;
margin-right: 0.3rem;
text-transform: uppercase;
font-size: @font-size-xs;
letter-spacing: 0.04em;
}
.party-renown-value {
width: 3.5rem;
text-align: center;
font-family: @font-secondary;
font-size: @font-size-sm;
}
}
}

View File

@@ -30,10 +30,19 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
toggleSheet: OathHammerItemSheet.#onToggleSheet,
editImage: OathHammerItemSheet.#onEditImage,
rollRarity: OathHammerItemSheet.#onRollRarity,
removeRune: OathHammerItemSheet.#onRemoveRune,
openRune: OathHammerItemSheet.#onOpenRune,
},
}
_sheetMode = this.constructor.SHEET_MODES.PLAY
_lockedReadOnly = false
/** @override — prevent form submissions when this sheet is opened in read-only mode */
get isEditable() {
if (this._lockedReadOnly) return false
return super.isEditable
}
get isPlayMode() {
return this._sheetMode === this.constructor.SHEET_MODES.PLAY
@@ -85,13 +94,49 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
// Class proficiency choices (for class-sheet checkboxes)
context.armorTypeChoices = ARMOR_TYPE_CHOICES
context.weaponGroupChoices = WEAPON_PROFICIENCY_GROUPS
// Rune context — enrich effect HTML for each attached rune
if (Array.isArray(this.document.system.runes)) {
context.enrichedRunes = await Promise.all(
this.document.system.runes.map(async (rune, idx) => ({
...rune,
idx,
enrichedEffect: await foundry.applications.ux.TextEditor.implementation.enrichHTML(rune.effect ?? "", { async: true }),
}))
)
}
context.acceptsRunes = this.#acceptsRunes()
return context
}
/** Returns true if this item type can have runic spells attached. */
#acceptsRunes() {
const type = this.document.type
if (type === "armor" || type === "weapon") return true
if (type === "magic-item" && this.document.system.itemType === "talisman") return true
return false
}
/** Map runeType expected for each item type. */
static #EXPECTED_RUNE_TYPE = {
armor: "armor",
weapon: "weapon",
"magic-item": "talisman",
}
/** @override */
_onRender(context, options) {
super._onRender(context, options)
this.#dragDrop.forEach((d) => d.bind(this.element))
if (this._lockedReadOnly) {
this.element.querySelector('[data-action="toggleSheet"]')?.remove()
for (const el of this.element.querySelectorAll(
'.window-content input, .window-content select, .window-content textarea, .window-content button'
)) {
el.disabled = true
}
}
for (const pm of this.element.querySelectorAll("prose-mirror[name]")) {
pm.addEventListener("change", async (event) => {
event.stopPropagation()
@@ -129,9 +174,93 @@ export default class OathHammerItemSheet extends HandlebarsApplicationMixin(foun
_onDragOver(event) {}
async _onDrop(event) {}
async _onDrop(event) {
if (!this.#acceptsRunes()) return
let dragData
try { dragData = JSON.parse(event.dataTransfer.getData("text/plain")) } catch { return }
if (dragData?.type !== "Item") return
let spell
try { spell = await fromUuid(dragData.uuid) } catch { return }
if (!spell || spell.type !== "spell") return
const sys = spell.system
if (sys.tradition !== "runic") {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.NotRunic"))
return
}
const expectedType = OathHammerItemSheet.#EXPECTED_RUNE_TYPE[this.document.type]
if (sys.runeType !== expectedType) {
ui.notifications.warn(game.i18n.format("OATHHAMMER.Rune.WrongType", {
expected: game.i18n.localize(`OATHHAMMER.RuneType.${expectedType.charAt(0).toUpperCase() + expectedType.slice(1)}`),
}))
return
}
const current = this.document.system.runes ?? []
if (current.length >= 2) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxRunes"))
return
}
if (current.some(r => r.name === spell.name)) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.Duplicate"))
return
}
if (sys.isExalted && current.some(r => r.isExalted)) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.MaxExalted"))
return
}
const snapshot = {
sourceUuid: spell.uuid,
name: spell.name,
img: spell.img,
runeType: sys.runeType,
isExalted: sys.isExalted,
difficultyValue: sys.difficultyValue,
effect: sys.effect ?? "",
duration: sys.duration ?? "",
range: sys.range ?? "",
spellSave: sys.spellSave ?? "",
}
await this.document.update({ "system.runes": [...current, snapshot] })
ui.notifications.info(game.i18n.format("OATHHAMMER.Rune.Attached", { name: spell.name }))
}
static async #onRemoveRune(event, target) {
const idx = parseInt(target.dataset.runeIndex, 10)
if (isNaN(idx)) return
const runes = [...(this.document.system.runes ?? [])]
runes.splice(idx, 1)
await this.document.update({ "system.runes": runes })
}
static async #onOpenRune(event, target) {
const uuid = target.dataset.sourceUuid
if (!uuid) return
try {
const spell = await fromUuid(uuid)
if (!spell) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound"))
return
}
// Use a unique ID so this read-only instance never conflicts with the
// document's normal sheet (which uses {ClassName}-{documentId} as its ID).
const SheetClass = spell.sheet.constructor
const sheet = new SheetClass({
document: spell,
id: `${SheetClass.name}-rune-view-${spell.id}`,
})
sheet._lockedReadOnly = true
sheet.render(true)
} catch {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Rune.SourceNotFound"))
}
}
static #onToggleSheet(event, target) {
if (this._lockedReadOnly) return
const modes = this.constructor.SHEET_MODES
this._sheetMode = this.isEditMode ? modes.PLAY : modes.EDIT
this.render()

View File

@@ -38,6 +38,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
adjustQty: OathHammerCharacterSheet.#onAdjustQty,
adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency,
adjustLuck: OathHammerCharacterSheet.#onAdjustLuck,
adjustGrit: OathHammerCharacterSheet.#onAdjustGrit,
clearStress: OathHammerCharacterSheet.#onClearStress,
},
}
@@ -417,6 +418,13 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet {
await this.document.update({ "system.luck.value": Math.max(0, current + delta) })
}
static async #onAdjustGrit(event, target) {
const delta = parseInt(target.dataset.delta, 10)
const current = this.document.system.grit.value ?? 0
const max = this.document.system.grit.max ?? 0
await this.document.update({ "system.grit.value": Math.max(0, Math.min(max, current + delta)) })
}
static async #onAdjustStress(event, target) {
const delta = parseInt(target.dataset.delta, 10)
const current = this.document.system.arcaneStress.value ?? 0

View File

@@ -1,4 +1,4 @@
import { SYSTEM } from "../config/system.mjs"
import { CLASS_RESTRICTION_CHOICES, SYSTEM } from "../config/system.mjs"
export default class OathHammerArmor extends foundry.abstract.TypeDataModel {
static defineSchema() {
@@ -39,7 +39,10 @@ export default class OathHammerArmor extends foundry.abstract.TypeDataModel {
schema.isCursed = new fields.BooleanField({ initial: false })
schema.magicEffect = new fields.HTMLField({ required: false, textSearch: true })
// Class/lineage restriction, e.g. "Dwarves only" (empty = no restriction)
schema.classRestriction = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.classRestriction = new fields.StringField({ required: false, nullable: true, initial: null, choices: CLASS_RESTRICTION_CHOICES })
// Attached runic spells (max 2; each is a snapshot of the spell item)
schema.runes = new fields.ArrayField(new fields.ObjectField(), { required: true, initial: [] })
return schema
}

View File

@@ -1,4 +1,4 @@
import { SYSTEM } from "../config/system.mjs"
import { CLASS_RESTRICTION_CHOICES, SYSTEM } from "../config/system.mjs"
export default class OathHammerMagicItem extends foundry.abstract.TypeDataModel {
static defineSchema() {
@@ -27,7 +27,7 @@ export default class OathHammerMagicItem extends foundry.abstract.TypeDataModel
schema.isBonded = new fields.BooleanField({ initial: false })
// Class/lineage restriction printed in the item's type line, e.g. "Troubadour Only"
schema.classRestriction = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.classRestriction = new fields.StringField({ required: false, nullable: true, initial: null, choices: CLASS_RESTRICTION_CHOICES })
// Limited-use items (e.g. "once per day"); none = always active
schema.usagePeriod = new fields.StringField({
@@ -39,6 +39,9 @@ export default class OathHammerMagicItem extends foundry.abstract.TypeDataModel
schema.equipped = new fields.BooleanField({ initial: false })
// Attached runic spells — only for talisman subtype (max 2; snapshot of spell item)
schema.runes = new fields.ArrayField(new fields.ObjectField(), { required: true, initial: [] })
return schema
}

View File

@@ -21,6 +21,7 @@ export default class OathHammerParty extends foundry.abstract.TypeDataModel {
})
schema.maxSlots = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
schema.renown = new fields.NumberField({ ...requiredInteger, initial: 0, min: 0 })
return schema
}

View File

@@ -64,6 +64,9 @@ export default class OathHammerWeapon extends foundry.abstract.TypeDataModel {
// Use this for abilities like Magic Bolt that roll Magic+Willpower instead.
schema.skillOverride = new fields.StringField({ required: false, nullable: true, initial: null })
// Attached runic spells (max 2; each is a snapshot of the spell item)
schema.runes = new fields.ArrayField(new fields.ObjectField(), { required: true, initial: [] })
return schema
}

View File

@@ -98,6 +98,11 @@ Hooks.once("init", function () {
OathHammerUtils.registerHandlebarsHelpers()
// Pre-register Handlebars partials so {{> "path"}} works in item templates
foundry.applications.handlebars.loadTemplates([
"systems/fvtt-oath-hammer/templates/item/rune-zone.hbs",
])
console.info("Oath Hammer | System Initialized")
})

View File

@@ -50,7 +50,11 @@
<fieldset class="character-resources">
<div class="character-resource">
<span class="resource-label">{{localize "OATHHAMMER.Label.Grit"}}</span>
{{formInput systemFields.grit.fields.value value=system.grit.value name="system.grit.value" disabled=isPlayMode}}
<div class="grit-stepper">
<a data-action="adjustGrit" data-delta="-1" class="grit-btn"></a>
{{formInput systemFields.grit.fields.value value=system.grit.value name="system.grit.value"}}
<a data-action="adjustGrit" data-delta="1" class="grit-btn">+</a>
</div>
<span class="res-sep">/</span>
{{formInput systemFields.grit.fields.max value=system.grit.max name="system.grit.max" disabled=isPlayMode}}
</div>

View File

@@ -49,12 +49,14 @@
</div><!-- /party-treasury -->
<!-- Slots -->
<!-- Slots + Renown -->
<div class="party-slots">
<span class="party-slots-label">{{localize "OATHHAMMER.Label.Slots"}}</span>
<span class="party-slots-current">{{currentSlots}}</span>
<span class="party-slots-sep">/</span>
<input class="party-slots-max" type="number" name="system.maxSlots" value="{{system.maxSlots}}" min="0" {{#if isPlayMode}}disabled{{/if}} />
<span class="party-renown-label">{{localize "OATHHAMMER.Label.Renown"}}</span>
<input class="party-renown-value" type="number" name="system.renown" value="{{system.renown}}" min="0" />
</div>
</div><!-- /party-header-body -->
</div>

View File

@@ -108,4 +108,5 @@
}}
</fieldset>
{{/if}}
{{> "systems/fvtt-oath-hammer/templates/item/rune-zone.hbs"}}
</section>

View File

@@ -24,4 +24,7 @@
<legend>{{localize "OATHHAMMER.Label.Effect"}}</legend>
{{formInput systemFields.effect enriched=enrichedEffect value=system.effect name="system.effect" toggled=true}}
</fieldset>
{{#if (eq system.itemType "talisman")}}
{{> "systems/fvtt-oath-hammer/templates/item/rune-zone.hbs"}}
{{/if}}
</section>

View File

@@ -0,0 +1,38 @@
{{!-- Runic attachment zone — included in armor, weapon, magic-item (talisman) sheets --}}
<fieldset class="rune-zone">
<legend><i class="fa-solid fa-star-of-david"></i> {{localize "OATHHAMMER.Label.Runes"}}</legend>
{{#if enrichedRunes.length}}
<ul class="rune-list">
{{#each enrichedRunes as |rune|}}
<li class="rune-entry {{#if rune.isExalted}}rune-exalted{{/if}}">
<img class="rune-img" src="{{rune.img}}" alt="{{rune.name}}" />
<span class="rune-name">{{rune.name}}</span>
{{#if rune.isExalted}}
<span class="rune-badge-exalted" data-tooltip="{{localize 'OATHHAMMER.Label.Exalted'}}">✦</span>
{{/if}}
<span class="rune-dv" data-tooltip="{{localize 'OATHHAMMER.Label.DifficultyValue'}}">DV{{rune.difficultyValue}}</span>
{{#if rune.duration}}
<span class="rune-duration">{{rune.duration}}</span>
{{/if}}
<a class="rune-effect-toggle"
data-action="openRune"
data-source-uuid="{{rune.sourceUuid}}"
data-tooltip="{{localize 'OATHHAMMER.Label.OpenRune'}}"
data-tooltip-direction="UP">
<i class="fa-solid fa-circle-info"></i>
</a>
<a class="rune-remove" data-action="removeRune" data-rune-index="{{rune.idx}}"
data-tooltip="{{localize 'OATHHAMMER.Label.RemoveRune'}}">
<i class="fa-solid fa-xmark"></i>
</a>
</li>
{{/each}}
</ul>
{{/if}}
<div class="rune-drop-zone">
<i class="fa-solid fa-wand-sparkles"></i>
<span>{{localize "OATHHAMMER.Label.DropRuneHint"}}</span>
</div>
</fieldset>

View File

@@ -78,4 +78,5 @@
{{formInput systemFields.magicEffect enriched=enrichedMagicEffect value=system.magicEffect name="system.magicEffect" toggled=true}}
</fieldset>
{{/if}}
{{> "systems/fvtt-oath-hammer/templates/item/rune-zone.hbs"}}
</section>