- Add ancestry block with header (image, name, type, size) and traits list - Style features and perks lists with grid layout for image/info/description - Add active effects display for temporary and passive effects - Fix perk prerequisites display using getPrerequisiteString() method - Add responsive layout for narrow containers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
721 lines
19 KiB
JavaScript
721 lines
19 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"],
|
|
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,
|
|
},
|
|
};
|
|
|
|
/** @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);
|
|
|
|
// 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();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @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));
|
|
}
|
|
|
|
// Enable dropping items onto the sheet
|
|
this.element.addEventListener("dragover", this._onDragOver.bind(this));
|
|
this.element.addEventListener("drop", this._onDrop.bind(this));
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
await sheet.actor.update(updateData);
|
|
}
|
|
|
|
/**
|
|
* 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 spell = this.actor.items.get(spellId);
|
|
if (!spell) return;
|
|
|
|
const { SpellCastDialog } = game.vagabond.applications;
|
|
await SpellCastDialog.prompt(this.actor, { spell });
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
}
|