- Add Dodge roll under Reflex save (auto-hindered by heavy armor) - Add Block roll under Endure save (hindered vs ranged attacks toggle) - Create DodgeRollDialog and BlockRollDialog with templates - Display defense rolls as indented sub-rows on Main tab - Block row visually dimmed when no shield equipped, shows notification on click 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
918 lines
25 KiB
JavaScript
918 lines
25 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: {
|
|
editImage: VagabondActorSheet.#onEditImage,
|
|
rollSkill: VagabondActorSheet.#onRollSkill,
|
|
rollSave: VagabondActorSheet.#onRollSave,
|
|
rollDodge: VagabondActorSheet.#onRollDodge,
|
|
rollBlock: VagabondActorSheet.#onRollBlock,
|
|
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,
|
|
toggleAttackTrained: VagabondActorSheet.#onToggleAttackTrained,
|
|
removeStatus: VagabondActorSheet.#onRemoveStatus,
|
|
},
|
|
// 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: [],
|
|
statuses: [],
|
|
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;
|
|
case "status":
|
|
items.statuses.push(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 dodge roll action.
|
|
* @param {PointerEvent} event
|
|
* @param {HTMLElement} target
|
|
*/
|
|
static async #onRollDodge(event, target) {
|
|
event.preventDefault();
|
|
const { DodgeRollDialog } = game.vagabond.applications;
|
|
await DodgeRollDialog.prompt(this.actor);
|
|
}
|
|
|
|
/**
|
|
* Handle block roll action.
|
|
* @param {PointerEvent} event
|
|
* @param {HTMLElement} target
|
|
*/
|
|
static async #onRollBlock(event, target) {
|
|
event.preventDefault();
|
|
|
|
// Check if shield is equipped
|
|
const hasShield = this.actor
|
|
.getEquippedArmor()
|
|
.some((armor) => armor.system.armorType === "shield");
|
|
|
|
if (!hasShield) {
|
|
ui.notifications.warn(game.i18n.localize("VAGABOND.RequiresShield"));
|
|
return;
|
|
}
|
|
|
|
const { BlockRollDialog } = game.vagabond.applications;
|
|
await BlockRollDialog.prompt(this.actor);
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
|
|
/**
|
|
* Handle attack skill trained toggle.
|
|
* @param {PointerEvent} event
|
|
* @param {HTMLElement} target
|
|
*/
|
|
static async #onToggleAttackTrained(event, target) {
|
|
event.preventDefault();
|
|
event.stopPropagation(); // Prevent triggering the row's rollAttack action
|
|
const attackId = target.dataset.attack;
|
|
if (!attackId) return;
|
|
|
|
const currentValue = this.actor.system.attacks[attackId]?.trained ?? false;
|
|
await this.actor.update({ [`system.attacks.${attackId}.trained`]: !currentValue });
|
|
}
|
|
|
|
/**
|
|
* Handle status removal action.
|
|
* @param {PointerEvent} event
|
|
* @param {HTMLElement} target
|
|
*/
|
|
static async #onRemoveStatus(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 || item.type !== "status") return;
|
|
|
|
await item.delete();
|
|
}
|
|
|
|
/**
|
|
* Handle image editing via FilePicker.
|
|
* @param {PointerEvent} event
|
|
* @param {HTMLElement} target
|
|
*/
|
|
static async #onEditImage(event, target) {
|
|
event.preventDefault();
|
|
const field = target.dataset.field || "img";
|
|
const current = foundry.utils.getProperty(this.document, field);
|
|
|
|
const fp = new FilePicker({
|
|
type: "image",
|
|
current,
|
|
callback: async (path) => {
|
|
await this.document.update({ [field]: path });
|
|
},
|
|
});
|
|
fp.render(true);
|
|
}
|
|
}
|