vagabond-rpg-foundryvtt/module/sheets/base-actor-sheet.mjs
Cal Corum 06e0dc01c0 Complete P2-P5: Perks, Feature Choices, Ancestry Traits, Caster Progression
- P2: Perks with changes[] arrays create Active Effects on drop/delete
- P3: Feature choice UI for Fighting Style (auto-grants Situational
  Awareness + selected training perk, ignoring prerequisites)
- P4: Ancestry traits apply Active Effects (Dwarf Darksight/Tough working)
- P5: Caster progression accumulates mana from class progression table

Key patterns:
- Manual UUID construction: Compendium.${pack.collection}.Item.${entry._id}
- ignorePrereqs flag for specific choices bypassing all prerequisites
- Mode 5 (OVERRIDE) for boolean senses like darkvision
- Form data merging with direct DOM reading for reliable selection capture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:23:44 -06:00

825 lines
22 KiB
JavaScript

/**
* Base Actor Sheet for Vagabond RPG
*
* Provides common functionality for all actor sheets:
* - Tab navigation
* - Drag-and-drop handling
* - Item management (create, edit, delete)
* - Context menus
* - Roll integration
*
* Uses Foundry VTT v13 ActorSheetV2 API with HandlebarsApplicationMixin.
*
* @extends ActorSheetV2
* @mixes HandlebarsApplicationMixin
*/
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
export default class VagabondActorSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
/**
* @param {Object} options - Application options
*/
constructor(options = {}) {
super(options);
// Active tab tracking
this._activeTab = "main";
// Scroll position preservation
this._scrollPositions = {};
}
/* -------------------------------------------- */
/* Static Properties */
/* -------------------------------------------- */
/** @override */
static DEFAULT_OPTIONS = {
id: "vagabond-actor-sheet-{id}",
classes: ["vagabond", "sheet", "actor", "themed"],
tag: "form",
window: {
title: "VAGABOND.ActorSheet",
icon: "fa-solid fa-user",
resizable: true,
},
position: {
width: 720,
height: 800,
},
form: {
handler: VagabondActorSheet.#onFormSubmit,
submitOnChange: true,
closeOnSubmit: false,
},
actions: {
rollSkill: VagabondActorSheet.#onRollSkill,
rollSave: VagabondActorSheet.#onRollSave,
rollAttack: VagabondActorSheet.#onRollAttack,
castSpell: VagabondActorSheet.#onCastSpell,
itemEdit: VagabondActorSheet.#onItemEdit,
itemDelete: VagabondActorSheet.#onItemDelete,
itemCreate: VagabondActorSheet.#onItemCreate,
itemToggleEquipped: VagabondActorSheet.#onItemToggleEquipped,
changeTab: VagabondActorSheet.#onChangeTab,
modifyResource: VagabondActorSheet.#onModifyResource,
toggleTrained: VagabondActorSheet.#onToggleTrained,
},
// Drag-drop configuration - use Foundry's built-in system
// Setting to empty array disables ActorSheetV2's default handling
// so we can use our own _onDrop override
dragDrop: [{ dropSelector: null }],
};
/** @override */
static PARTS = {
header: {
template: "systems/vagabond/templates/actor/parts/header.hbs",
},
tabs: {
template: "systems/vagabond/templates/actor/parts/tabs.hbs",
},
body: {
template: "systems/vagabond/templates/actor/parts/body.hbs",
},
};
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Convenient alias for the actor document.
* @returns {VagabondActor}
*/
get actor() {
return this.document;
}
/** @override */
get title() {
return this.document.name;
}
/**
* Get the available tabs for this sheet.
* Subclasses should override to define their tabs.
* @returns {Object[]} Array of tab definitions
*/
get tabs() {
return [
{ id: "main", label: "VAGABOND.TabMain", icon: "fa-solid fa-user" },
{ id: "inventory", label: "VAGABOND.TabInventory", icon: "fa-solid fa-suitcase" },
{ id: "abilities", label: "VAGABOND.TabAbilities", icon: "fa-solid fa-star" },
{ id: "biography", label: "VAGABOND.TabBiography", icon: "fa-solid fa-book" },
];
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
const context = await super._prepareContext(options);
// Basic actor data
context.actor = this.actor;
context.system = this.actor.system;
context.source = this.actor.toObject().system;
context.items = this._prepareItems();
context.effects = this._prepareActiveEffects();
// Sheet state
context.activeTab = this._activeTab;
context.tabs = this.tabs.map((tab) => ({
...tab,
active: tab.id === this._activeTab,
cssClass: tab.id === this._activeTab ? "active" : "",
}));
// Roll data for formulas in templates
context.rollData = this.actor.getRollData();
// Editable state
context.editable = this.isEditable;
context.owner = this.actor.isOwner;
context.limited = this.actor.limited;
// System configuration
context.config = CONFIG.VAGABOND;
// Type-specific context
await this._prepareTypeContext(context, options);
return context;
}
/**
* Prepare type-specific context data.
* Subclasses should override this.
*
* @param {Object} context - The context object to augment
* @param {Object} options - Render options
* @protected
*/
async _prepareTypeContext(_context, _options) {
// Override in subclasses
}
/**
* Organize and classify items for the sheet.
*
* @returns {Object} Categorized items
* @protected
*/
_prepareItems() {
const items = {
weapons: [],
armor: [],
equipment: [],
spells: [],
features: [],
perks: [],
classes: [],
ancestry: null,
};
for (const item of this.actor.items) {
// Set common properties
item.system.isEquipped = item.system.equipped ?? false;
switch (item.type) {
case "weapon":
items.weapons.push(item);
break;
case "armor":
items.armor.push(item);
break;
case "equipment":
items.equipment.push(item);
break;
case "spell":
items.spells.push(item);
break;
case "feature":
items.features.push(item);
break;
case "perk": {
// Add formatted prerequisite string for display (hide "None")
const prereqStr = item.system.getPrerequisiteString?.() || "";
item.prerequisiteString = prereqStr !== "None" ? prereqStr : "";
items.perks.push(item);
break;
}
case "class":
items.classes.push(item);
break;
case "ancestry":
items.ancestry = item;
break;
}
}
// Sort items by name
for (const category of Object.keys(items)) {
if (Array.isArray(items[category])) {
items[category].sort((a, b) => a.name.localeCompare(b.name));
}
}
return items;
}
/**
* Prepare active effects for display.
*
* @returns {Object} Categorized effects
* @protected
*/
_prepareActiveEffects() {
const effects = {
temporary: [],
passive: [],
inactive: [],
};
for (const effect of this.actor.effects) {
if (effect.disabled) {
effects.inactive.push(effect);
} else if (effect.isTemporary) {
effects.temporary.push(effect);
} else {
effects.passive.push(effect);
}
}
return effects;
}
/* -------------------------------------------- */
/* Rendering */
/* -------------------------------------------- */
/** @override */
_preRender(context, options) {
super._preRender(context, options);
// Save scroll positions before re-render
this._saveScrollPositions();
}
/** @override */
_onRender(context, options) {
super._onRender(context, options);
// Apply theme class based on configured theme
this._applyThemeClass();
// Remove stale tab content (ApplicationV2 appends parts without removing old ones)
this._cleanupInactiveTabs();
// Restore scroll positions after re-render
this._restoreScrollPositions();
// Set up drag-and-drop for items
this._setupDragDrop();
// Initialize any content-editable fields
this._initializeEditors();
// Add keyboard accessibility for interactive rows
this._setupKeyboardAccessibility();
}
/**
* Set up keyboard event listeners for elements with role="button".
* This enables Enter/Space key activation for accessibility.
* @protected
*/
_setupKeyboardAccessibility() {
if (!this.element) return;
// Find all elements with role="button" that have data-action
const interactiveElements = this.element.querySelectorAll('[role="button"][data-action]');
for (const el of interactiveElements) {
el.addEventListener("keydown", (event) => {
// Trigger click on Enter or Space
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
el.click();
}
});
}
}
/**
* Apply the configured theme class to the sheet element.
* Foundry v13 doesn't automatically add theme classes to ApplicationV2 sheets,
* so we handle it manually.
* @protected
*/
_applyThemeClass() {
if (!this.element) return;
// Remove any existing theme classes
this.element.classList.remove("theme-light", "theme-dark");
// Get the configured theme for this sheet
// DocumentSheetConfig stores per-document and per-type theme preferences
const sheetConfig = this.document.getFlag("core", "sheetTheme");
const typeConfig = game.settings.get("core", "sheetClasses")?.[this.document.documentName]?.[
this.document.type
];
const defaultTheme = typeConfig?.defaultTheme;
// Determine which theme to apply: document-specific > type default > global
let theme = sheetConfig || defaultTheme;
// If no specific theme, check global preference
if (!theme) {
const uiConfig = game.settings.get("core", "uiConfig");
const colorScheme = uiConfig?.colorScheme?.applications;
if (colorScheme === "dark") {
theme = "dark";
} else if (colorScheme === "light") {
theme = "light";
}
}
// Apply the theme class
if (theme === "dark") {
this.element.classList.add("theme-dark");
} else if (theme === "light") {
this.element.classList.add("theme-light");
}
// If still no theme, it will use body.theme-dark/light via CSS
}
/**
* Remove tab content sections that don't match the active tab.
* ApplicationV2's parts rendering appends new parts without removing old ones,
* so we need to clean up inactive tabs after each render.
* @protected
*/
_cleanupInactiveTabs() {
if (!this.element) return;
const activeTabClass = `${this._activeTab}-tab`;
const tabContents = this.element.querySelectorAll(".tab-content");
for (const tabContent of tabContents) {
// Check if this tab content matches the active tab
if (!tabContent.classList.contains(activeTabClass)) {
tabContent.remove();
}
}
}
/**
* Save scroll positions of scrollable elements before re-render.
* @protected
*/
_saveScrollPositions() {
if (!this.element) return;
// Save main window scroll
const windowEl = this.element.querySelector(".window-content");
if (windowEl) {
this._scrollPositions.window = windowEl.scrollTop;
}
// Save tab content scroll
const tabContent = this.element.querySelector(".tab-content");
if (tabContent) {
this._scrollPositions.tabContent = tabContent.scrollTop;
}
}
/**
* Restore scroll positions of scrollable elements after re-render.
* @protected
*/
_restoreScrollPositions() {
if (!this.element) return;
// Restore main window scroll
const windowEl = this.element.querySelector(".window-content");
if (windowEl && this._scrollPositions.window !== undefined) {
windowEl.scrollTop = this._scrollPositions.window;
}
// Restore tab content scroll
const tabContent = this.element.querySelector(".tab-content");
if (tabContent && this._scrollPositions.tabContent !== undefined) {
tabContent.scrollTop = this._scrollPositions.tabContent;
}
}
/**
* Set up drag-and-drop handlers for dragging items FROM this sheet.
* Drop handling is configured via DEFAULT_OPTIONS.dragDrop and uses
* the _onDrop method override - we don't add manual listeners for drops.
* @protected
*/
_setupDragDrop() {
// Enable dragging items from the sheet
const draggables = this.element.querySelectorAll("[data-item-id]");
for (const el of draggables) {
el.setAttribute("draggable", "true");
el.addEventListener("dragstart", this._onDragStart.bind(this));
}
// Note: Drop handling is managed by Foundry's dragDrop configuration
// in DEFAULT_OPTIONS, which calls our _onDrop override. We don't add
// manual drop listeners here to avoid duplicate item creation.
}
/**
* Initialize rich text editors.
* @protected
*/
_initializeEditors() {
// TinyMCE or ProseMirror editors would be initialized here
// For now, we use simple textareas
}
/* -------------------------------------------- */
/* Drag and Drop */
/* -------------------------------------------- */
/**
* Handle drag start for items.
* @param {DragEvent} event
* @protected
*/
_onDragStart(event) {
const itemId = event.currentTarget.dataset.itemId;
const item = this.actor.items.get(itemId);
if (!item) return;
const dragData = {
type: "Item",
uuid: item.uuid,
};
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
}
/**
* Handle drag over.
* @param {DragEvent} event
* @protected
*/
_onDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
/**
* Handle drop onto the sheet.
* @param {DragEvent} event
* @protected
*/
async _onDrop(event) {
event.preventDefault();
let data;
try {
data = JSON.parse(event.dataTransfer.getData("text/plain"));
} catch (err) {
return;
}
// Handle different drop types
switch (data.type) {
case "Item":
return this._onDropItem(event, data);
case "ActiveEffect":
return this._onDropActiveEffect(event, data);
case "Actor":
return this._onDropActor(event, data);
}
}
/**
* Handle dropping an Item onto the sheet.
* @param {DragEvent} event
* @param {Object} data
* @protected
*/
async _onDropItem(event, data) {
if (!this.actor.isOwner) return;
const item = await Item.fromDropData(data);
if (!item) return;
// If the item is from this actor, it's a sort operation
if (item.parent === this.actor) {
return this._onSortItem(event, item);
}
// Create the item on this actor
return this._onDropItemCreate(item);
}
/**
* Handle creating an Item from a drop.
* @param {VagabondItem} item
* @protected
*/
async _onDropItemCreate(item) {
const itemData = item.toObject();
// Special handling for ancestry (only one allowed)
if (item.type === "ancestry") {
const existingAncestry = this.actor.items.find((i) => i.type === "ancestry");
if (existingAncestry) {
await existingAncestry.delete();
}
}
return this.actor.createEmbeddedDocuments("Item", [itemData]);
}
/**
* Handle sorting items within the sheet.
* @param {DragEvent} event
* @param {VagabondItem} item
* @protected
*/
async _onSortItem(event, item) {
// Get the drop target
const dropTarget = event.target.closest("[data-item-id]");
if (!dropTarget) return;
const targetId = dropTarget.dataset.itemId;
if (targetId === item.id) return;
const target = this.actor.items.get(targetId);
if (!target || target.type !== item.type) return;
// Perform the sort
const siblings = this.actor.items.filter((i) => i.type === item.type && i.id !== item.id);
const sortUpdates = foundry.utils.SortingHelpers.performIntegerSort(item, {
target,
siblings,
});
const updateData = sortUpdates.map((u) => ({
_id: u.target.id,
sort: u.update.sort,
}));
return this.actor.updateEmbeddedDocuments("Item", updateData);
}
/**
* Handle dropping an Active Effect.
* @param {DragEvent} event
* @param {Object} data
* @protected
*/
async _onDropActiveEffect(event, data) {
const effect = await ActiveEffect.fromDropData(data);
if (!effect) return;
if (effect.parent === this.actor) {
return; // No-op for effects already on this actor
}
return this.actor.createEmbeddedDocuments("ActiveEffect", [effect.toObject()]);
}
/**
* Handle dropping an Actor (e.g., for summoning).
* @param {DragEvent} event
* @param {Object} data
* @protected
*/
async _onDropActor(_event, _data) {
// Override in subclasses if needed (e.g., for companion/summon tracking)
}
/* -------------------------------------------- */
/* Action Handlers */
/* -------------------------------------------- */
/**
* Handle form submission.
* @param {Event} event
* @param {HTMLFormElement} form
* @param {FormDataExtended} formData
*/
static async #onFormSubmit(event, form, formData) {
const sheet = this;
const updateData = foundry.utils.expandObject(formData.object);
// Clean up numeric fields that may have empty string values
// This can happen when number inputs are cleared by the user
VagabondActorSheet.#cleanNumericFields(updateData);
await sheet.actor.update(updateData);
}
/**
* Recursively clean numeric fields in update data.
* Empty strings are converted to 0 for numeric resource fields.
* @param {Object} obj - Object to clean
* @param {string} path - Current path for debugging
* @private
*/
static #cleanNumericFields(obj, path = "") {
if (!obj || typeof obj !== "object") return;
for (const [key, value] of Object.entries(obj)) {
const currentPath = path ? `${path}.${key}` : key;
if (typeof value === "object" && value !== null) {
// Recurse into nested objects
VagabondActorSheet.#cleanNumericFields(value, currentPath);
} else if (value === "" || value === null) {
// Check if this should be a numeric field based on common patterns
const numericKeys = ["value", "max", "bonus", "min", "base", "level", "castingMax"];
if (numericKeys.includes(key)) {
obj[key] = 0;
}
}
}
}
/**
* Handle skill roll action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onRollSkill(event, target) {
event.preventDefault();
const skillId = target.dataset.skill;
if (!skillId) return;
const { SkillCheckDialog } = game.vagabond.applications;
await SkillCheckDialog.prompt(this.actor, skillId);
}
/**
* Handle save roll action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onRollSave(event, target) {
event.preventDefault();
const saveType = target.dataset.save;
if (!saveType) return;
const { SaveRollDialog } = game.vagabond.applications;
await SaveRollDialog.prompt(this.actor, saveType);
}
/**
* Handle attack roll action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onRollAttack(event, target) {
event.preventDefault();
const weaponId = target.dataset.weaponId;
const { AttackRollDialog } = game.vagabond.applications;
await AttackRollDialog.prompt(this.actor, weaponId);
}
/**
* Handle spell cast action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onCastSpell(event, target) {
event.preventDefault();
const spellId = target.dataset.spellId;
if (!spellId) return;
const { SpellCastDialog } = game.vagabond.applications;
await SpellCastDialog.prompt(this.actor, spellId);
}
/**
* Handle item edit action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onItemEdit(event, target) {
event.preventDefault();
const itemId = target.closest("[data-item-id]")?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
if (!item) return;
item.sheet.render(true);
}
/**
* Handle item delete action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onItemDelete(event, target) {
event.preventDefault();
const itemId = target.closest("[data-item-id]")?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
if (!item) return;
// Confirm deletion
const confirmed = await Dialog.confirm({
title: game.i18n.format("VAGABOND.ItemDeleteTitle", { name: item.name }),
content: game.i18n.format("VAGABOND.ItemDeleteConfirm", { name: item.name }),
});
if (confirmed) {
await item.delete();
}
}
/**
* Handle item create action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onItemCreate(event, target) {
event.preventDefault();
const type = target.dataset.type;
if (!type) return;
const itemData = {
name: game.i18n.format("VAGABOND.ItemNew", { type }),
type,
};
const [item] = await this.actor.createEmbeddedDocuments("Item", [itemData]);
item?.sheet.render(true);
}
/**
* Handle item equipped toggle.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onItemToggleEquipped(event, target) {
event.preventDefault();
const itemId = target.closest("[data-item-id]")?.dataset.itemId;
if (!itemId) return;
const item = this.actor.items.get(itemId);
if (!item) return;
await item.update({ "system.equipped": !item.system.equipped });
}
/**
* Handle tab change action.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onChangeTab(event, target) {
event.preventDefault();
const tab = target.dataset.tab;
if (!tab) return;
this._activeTab = tab;
this.render();
}
/**
* Handle resource modification.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onModifyResource(event, target) {
event.preventDefault();
const resource = target.dataset.resource;
const delta = parseInt(target.dataset.delta, 10);
if (!resource || isNaN(delta)) return;
await this.actor.modifyResource(resource, delta);
}
/**
* Handle skill trained toggle.
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
static async #onToggleTrained(event, target) {
event.preventDefault();
const skillId = target.dataset.skill;
if (!skillId) return;
const currentValue = this.actor.system.skills[skillId]?.trained ?? false;
await this.actor.update({ [`system.skills.${skillId}.trained`]: !currentValue });
}
}