Files
hbm-foundry-system/src/sheets/character-sheet.ts
T
octoturgeandClaude Sonnet 5.5 6b8d93f772 feat: flat spell frontmatter, full source-book titles, drop circle
Parser reads human-friendly flat keys (range, targets, castingTime, overcast
list, threshold/successes, ...) and expands them to SpellData; fixes
[object Object] range, empty targets/casting time and blank overcast rows.
SOURCE_BOOKS ids now match vault slugs and show full book titles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 12:35:16 +02:00

491 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Character sheet - Foundry v13 ApplicationV2 + Handlebars.
* Features: tabs, roll-dialogs, drag-and-drop item creation.
*/
import { rollSkill, rollAttribute, rollInitiative } from '../dice/macros';
import { castSpell } from '../logic/spell-cast';
import { askRollParams } from '../dice/roll-dialog';
import { askCastOptions } from '../dice/cast-dialog';
import { askApplyDamage } from '../dice/damage-dialog';
import { applyDamage } from '../logic/damage';
import { rest } from '../logic/rest';
import { ATTRIBUTES, AttributeKey, SKILL_KEYS, getMagicPowerEntry } from '../constants';
import { showMoneyConverter } from './money-converter-dialog';
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
};
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
};
export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
static override DEFAULT_OPTIONS = {
classes: ['hbm', 'sheet', 'character'],
position: { width: 760, height: 720 },
window: { resizable: true, title: 'HBM.actor.character' },
actions: {
rollSkill: CharacterSheet._onRollSkill,
rollAttribute: CharacterSheet._onRollAttribute,
rollInitiative: CharacterSheet._onRollInitiative,
castSpell: CharacterSheet._onCastSpell,
deleteItem: CharacterSheet._onDeleteItem,
editItem: CharacterSheet._onEditItem,
applyDamage: CharacterSheet._onApplyDamage,
takeBreather: CharacterSheet._onTakeBreather,
shortRest: CharacterSheet._onShortRest,
longRest: CharacterSheet._onLongRest,
actorEffectCreate: CharacterSheet._onActorEffectCreate,
actorEffectToggle: CharacterSheet._onActorEffectToggle,
actorEffectEdit: CharacterSheet._onActorEffectEdit,
actorEffectDelete: CharacterSheet._onActorEffectDelete,
addArrayEntry: CharacterSheet._onAddArrayEntry,
removeArrayEntry: CharacterSheet._onRemoveArrayEntry,
editImage: CharacterSheet._onEditImage,
toggleEquipped: CharacterSheet._onToggleEquipped,
recalculateMoney: CharacterSheet._onRecalculateMoney,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static override PARTS = {
main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' },
};
/** Active tab per group - persists across re-renders. */
tabGroups: Record<string, string> = { primary: 'stats' };
override async _prepareContext(options: unknown) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const actor = (this as unknown as { actor: { system: unknown; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell');
const gear = actor.items.filter((it: any) => it.type === 'gear');
const abilities = actor.items.filter((it: any) => it.type === 'ability');
const talents = actor.items.filter((it: any) => it.type === 'talent');
// Group spells by school for sheet display
const spellsBySchool = new Map<string, any[]>();
for (const sp of spells) {
const s = sp.system?.school || 'standard';
if (!spellsBySchool.has(s)) spellsBySchool.set(s, []);
spellsBySchool.get(s)!.push(sp);
}
const spellSchoolGroups = [...spellsBySchool.entries()]
.map(([school, items]) => ({
school,
label: game.i18n.localize(`HBM.spellSchool.${school}`),
items: items.sort((a: any, b: any) => a.name.localeCompare(b.name, 'pl')),
}))
.sort((a, b) => a.label.localeCompare(b.label, 'pl'));
const sys = actor.system as any;
const hasBloodMagic = sys?.details?.discipline === 'Magia Krwi' || sys?.details?.discipline === 'Blood Magic';
const elixirCap = (sys?.attributes?.body?.value ?? 0) + 1;
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
id: ef.id,
name: ef.name,
icon: ef.icon ?? 'icons/svg/aura.svg',
disabled: ef.disabled ?? false,
isTransferred: !!ef.origin,
originName: ef.origin ? (actor.items.find((it: any) => ef.origin?.endsWith(it.id))?.name ?? ef.origin) : '',
changes: ef.changes ?? [],
}));
// Pre-map attributes and skills to prevent lookup context issues in Handlebars
const attributes = ATTRIBUTES.map(key => {
const attrObj = sys.attributes?.[key];
const actualVal = attrObj?.actual !== undefined ? attrObj.actual : (attrObj?.value ?? 0);
const labels = ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
const actualLabel = labels[Math.max(0, Math.min(10, actualVal))] || String(actualVal);
return {
key,
value: attrObj?.value ?? 0,
actual: actualVal,
actualLabel,
label: game.i18n.localize(`HBM.attributes.${key}`)
};
});
const skills = SKILL_KEYS.map(key => ({
key,
value: sys.skills?.[key]?.value ?? 0,
label: game.i18n.localize(`HBM.skills.${key}`)
}));
const races = ['czlowiek', 'elf', 'lamia', 'feles', 'aniol'].map(key => ({
key,
label: game.i18n.localize(`HBM.racesList.${key}`)
}));
const disciplines = [
'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood',
'crimson', 'abyssAspects', 'abyssPrimal', 'eldritch'
].map(key => ({
key,
label: game.i18n.localize(`HBM.spellSchool.${key}`)
}));
// Filter gear categories for tabular sheet display
const weapons = gear.filter((it: any) => it.system?.category === 'weapon');
const armors = gear.filter((it: any) => it.system?.category === 'armor');
const equipment = gear.filter((it: any) => it.system?.category === 'equipment');
// Localized talents list no longer needed (dropdown removed - drag from compendium)
return {
...ctx,
system: actor.system,
attributes,
skills,
races,
disciplines,
tabGroups: this.tabGroups,
attributeKeys: ATTRIBUTES,
skillKeys: SKILL_KEYS,
spells,
spellSchoolGroups,
hasBloodMagic,
elixirCap,
gear,
weapons,
armors,
equipment,
abilities,
talents,
actorEffects,
tabs: this._prepareTabs(),
};
}
/** Build tab metadata for the template. */
private _prepareTabs() {
const active = this.tabGroups['primary'] ?? 'stats';
return ['stats', 'actions', 'skills', 'spells', 'talents', 'inventory', 'effects', 'biography'].map((id) => ({
id,
label: `HBM.ui.tabs.${id}`,
active: active === id,
cssClass: active === id ? 'active' : '',
}));
}
override _onRender(context: unknown, options: unknown) {
super._onRender(context, options);
const html = this.element;
// Tab navigation
html.querySelectorAll<HTMLElement>('.tabs .tab-item').forEach((btn) => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
if (!tab) return;
this.tabGroups['primary'] = tab;
// Update active state without full re-render
html.querySelectorAll('.tabs .tab-item').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
html.querySelectorAll<HTMLElement>('.tab-panel').forEach((p) => {
p.classList.toggle('active', p.dataset.tab === tab);
});
});
});
// Drag-and-drop: accept items dragged from compendium / sidebar
const body = html.querySelector<HTMLElement>('.sheet-body');
if (body) {
body.addEventListener('dragover', (ev) => { ev.preventDefault(); });
body.addEventListener('drop', (ev) => this._handleDrop(ev));
}
}
private async _handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation(); // prevent ActorSheetV2 base from also handling this
const data = (foundry.applications as any).ux.TextEditor.implementation.getDragEventData(event);
if (!data || data['type'] !== 'Item') return;
const item = await fromUuid<any>(data['uuid'] as string);
if (!item) return;
const actor = (this as unknown as { actor: any }).actor;
const uuid = data['uuid'] as string;
// Auto-link Race / Class items by UUID - do NOT create embedded copies.
if (item.type === 'race') {
await actor.update({ 'system.details.raceId': uuid });
return;
} else if (item.type === 'class') {
const year = Math.max(1, Math.min(4, Number(item.system?.year ?? 1)));
const ids = [...((actor.system as any).details?.classIds ?? [])];
ids[year - 1] = uuid;
await actor.update({ 'system.details.classIds': ids });
return;
}
// Talents may always be added multiple times (each rank/copy is a distinct embedded item).
// All other types: deduplicate by source slug or by matching the exact same document id.
if (item.type !== 'talent') {
const sourceSlug: string | undefined = item.flags?.['hbm-rpg-v3']?.slug;
const existing = actor.items.find((it: any) => {
if (it.id === item.id) return true; // same document
if (sourceSlug && it.flags?.['hbm-rpg-v3']?.slug === sourceSlug) return true;
return false;
});
if (existing) {
ui?.notifications?.warn(game.i18n.format('HBM.ui.itemAlreadyOwned', { name: item.name }));
return;
}
}
if (item.type === 'talent' && item.system?.multiSelect) {
const match = item.name.match(/(Dziedzina|B\u00f3stwo|Zmys\u0142|Atrybut|Umiej\u0119tno\u015b\u0107)/i);
if (match) {
const paramType = match[1];
const title = game.i18n.localize('HBM.ui.selectTalentParamTitle') || 'Wybierz parametr talentu';
const labelText = game.i18n.format('HBM.ui.selectTalentParamDesc', { param: paramType }) || `Wprowad\u017a warto\u015b\u0107 dla parametru (${paramType}):`;
let specified: string | null = null;
await (foundry.applications.api as any).DialogV2.wait({
title,
content: `
<div class="hbm-talent-dialog" style="padding:10px;">
<p>${labelText}</p>
<input id="hbm-talent-param-input" type="text" placeholder="${paramType}" style="width:100%; margin-bottom:10px;" />
</div>`,
buttons: [
{
type: 'button', action: 'confirm',
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
default: true,
callback: (_ev: Event, _btn: any, dialog: any) => {
const val = (dialog.element.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
specified = val || null;
},
},
{ type: 'button', action: 'cancel', label: game.i18n.localize('HBM.ui.cancel') || 'Anuluj' },
],
rejectClose: false,
});
if (specified) {
const obj = item.toObject();
obj.name = item.name.replace(/\([^)]+\)$/, `(${specified})`);
await actor.createEmbeddedDocuments('Item', [obj]);
return;
} else {
return; // cancel
}
}
}
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
}
// ─── Actions ─────────────────────────────────────────────────────────────
static async _onRollSkill(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const skillKey = target.dataset.skill;
if (!skillKey) return;
const actor = (this as unknown as { actor: any }).actor;
// Determine default pool so the dialog can show it
const skill = actor.system.skills?.[skillKey];
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
const attr = actor.system.attributes[attrKey];
const attrVal = (attrKey === 'magic' && attr)
? (attr.dicePool ?? getMagicPowerEntry(attr.actual ?? attr.value ?? 0).dicePool)
: (attr?.value ?? 0);
const pool = attrVal + (skill?.value ?? 0);
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) }) });
if (!params) return;
await rollSkill(actor, skillKey, {
threshold: params.threshold,
required: params.required,
modifier: params.modifier,
flavor: params.flavor,
});
}
static async _onRollAttribute(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const attr = target.dataset.attribute as AttributeKey | undefined;
if (!attr) return;
const actor = (this as unknown as { actor: any }).actor;
const a = actor.system.attributes[attr];
const pool = (attr === 'magic' && a)
? (a.dicePool ?? getMagicPowerEntry(a.actual ?? a.value ?? 0).dicePool)
: (a?.value ?? 0);
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }) });
if (!params) return;
await rollAttribute(actor, attr, {
threshold: params.threshold,
required: params.required,
modifier: params.modifier,
});
}
static async _onCastSpell(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const spell = actor.items.get(itemId);
if (!spell || spell.type !== 'spell') return;
const opts = await askCastOptions(spell, actor);
if (!opts) return;
opts.speaker = ChatMessage.getSpeaker({ actor });
await castSpell(actor, spell, opts);
}
static async _onDeleteItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
await item.delete();
}
static async _onEditItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
item.sheet?.render(true);
}
static async _onRollInitiative(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
await rollInitiative(actor);
}
static async _onApplyDamage(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const params = await askApplyDamage(actor);
if (!params) return;
await applyDamage(actor, params);
}
static async _onTakeBreather(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'breather');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> - Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onShortRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'short');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> - Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, −${r.toleranceRecovered} tolerancja` : ''}`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onLongRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'long');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> - Długi Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN · +${r.zealRestored} ZP · +${r.bloodRestored} krew · −${r.toleranceRecovered} tolerancja`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onActorEffectCreate(this: CharacterSheet) {
const actor = (this as unknown as { actor: any }).actor;
const created = await actor.createEmbeddedDocuments('ActiveEffect', [{
name: game.i18n.localize('HBM.activeEffect.newEffect'),
icon: 'icons/svg/aura.svg',
disabled: false,
changes: [],
}]);
created[0]?.sheet?.render(true);
}
static async _onActorEffectToggle(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
const effect = actor.effects.get(id);
if (!effect) return;
await effect.update({ disabled: !effect.disabled });
}
static async _onActorEffectEdit(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
actor.effects.get(id)?.sheet?.render(true);
}
static async _onActorEffectDelete(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
await actor.deleteEmbeddedDocuments('ActiveEffect', [id]);
}
static async _onAddArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
if (!path) return;
const actor = (this as unknown as { actor: any }).actor;
const current = (foundry.utils.getProperty(actor, path) ?? []) as unknown[];
const template = target.dataset.template;
const entry = template ? JSON.parse(template) : '';
await actor.update({ [path]: [...current, entry] });
}
static async _onRemoveArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
const idx = Number(target.dataset.index);
if (!path || Number.isNaN(idx)) return;
const actor = (this as unknown as { actor: any }).actor;
const current = ([...(foundry.utils.getProperty(actor, path) ?? [])] as unknown[]);
current.splice(idx, 1);
await actor.update({ [path]: current });
}
static async _onEditImage(this: CharacterSheet, event: PointerEvent, target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const current = actor.img;
const fp = new FilePicker({
type: "image",
current: current,
callback: (path: string) => {
actor.update({ img: path });
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
static async _onToggleEquipped(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
await item.update({ 'system.equipped': !item.system.equipped });
}
static async _onRecalculateMoney(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
return showMoneyConverter(actor);
}
}