diff --git a/PROJECT_ROADMAP.json b/PROJECT_ROADMAP.json
index 61e0f0b..3a50baa 100644
--- a/PROJECT_ROADMAP.json
+++ b/PROJECT_ROADMAP.json
@@ -342,19 +342,21 @@
"id": "2.12",
"name": "Implement class feature automation",
"description": "When class item added to character, apply appropriate Active Effects for current level; update on level change",
- "completed": false,
- "tested": false,
+ "completed": true,
+ "tested": true,
"priority": "high",
- "dependencies": ["2.2", "2.3", "1.8", "1.15"]
+ "dependencies": ["2.2", "2.3", "1.8", "1.15"],
+ "notes": "Implemented via _onCreate/_preDelete lifecycle methods and updateActor hook. Made applyClassFeatures() idempotent. Commit 8afcf8c."
},
{
"id": "2.13",
"name": "Implement morale check system (NPC)",
"description": "2d6 vs Morale roll, triggered manually or via hooks on death/half HP",
- "completed": false,
- "tested": false,
+ "completed": true,
+ "tested": true,
"priority": "medium",
- "dependencies": ["2.4", "1.3"]
+ "dependencies": ["2.4", "1.3"],
+ "notes": "rollMorale() for individual, rollGroupMorale() for lowest in selection, auto-prompt at half HP, chat button handler, macro created. Commit f6948dc."
},
{
"id": "2.14",
@@ -376,145 +378,161 @@
"id": "3.1",
"name": "Create base VagabondActorSheet class",
"description": "Extended ActorSheet with common methods, tab handling, drag-drop, context menus",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["2.2"]
+ "dependencies": ["2.2"],
+ "notes": "Uses Foundry v13 ApplicationV2 with HandlebarsApplicationMixin. Includes action handlers, drag-drop, item management."
},
{
"id": "3.2",
"name": "Design Character sheet layout (HTML/Handlebars)",
"description": "Match official Hero Record: Stats column, HP/Armor/Fatigue, Speed section, Saves, Skills grid, Attacks, Inventory, Abilities, Magic",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.1"]
+ "dependencies": ["3.1"],
+ "notes": "Tabbed layout with Main, Inventory, Abilities, Magic, Biography tabs. Templates in templates/actor/."
},
{
"id": "3.3",
"name": "Implement Character sheet - Header section",
"description": "Name, Ancestry, Level, Class, XP, Size, Being Type fields",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2"]
+ "dependencies": ["3.2"],
+ "notes": "Header includes portrait, name, ancestry/class display, level/XP inputs, HP/Mana bars, secondary resources."
},
{
"id": "3.4",
"name": "Implement Character sheet - Stats section",
"description": "Six stats display with large numbers matching official sheet aesthetic",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2"]
+ "dependencies": ["3.2"],
+ "notes": "Stats grid on Main tab with editable values and stat abbreviations."
},
{
"id": "3.5",
"name": "Implement Character sheet - Combat section",
"description": "HP (current/max), Armor, Fatigue, Speed (base/bonus/crawl/travel), Current Luck",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2"]
+ "dependencies": ["3.2"],
+ "notes": "Resource bars in header, secondary resources (Luck, Fatigue, Armor, Speed) displayed."
},
{
"id": "3.6",
"name": "Implement Character sheet - Saves section",
"description": "Reflex, Endure, Will with calculated difficulties, clickable for rolls",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2", "2.7"]
+ "dependencies": ["3.2", "2.7"],
+ "notes": "Saves grid on Main tab with stat formulas and clickable roll action."
},
{
"id": "3.7",
"name": "Implement Character sheet - Skills section",
"description": "12 skills grid with trained checkboxes, stat associations, difficulty numbers, clickable for rolls",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2", "2.5"]
+ "dependencies": ["3.2", "2.5"],
+ "notes": "Skills grid with trained toggle, stat abbreviation, difficulty, crit threshold display."
},
{
"id": "3.8",
"name": "Implement Character sheet - Attacks section",
"description": "Weapon attack skills (Melee/Brawl/Ranged/Finesse) with difficulties, equipped weapons list with roll buttons",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "critical",
- "dependencies": ["3.2", "2.6"]
+ "dependencies": ["3.2", "2.6"],
+ "notes": "Attack skills and equipped weapons on Main tab with roll buttons."
},
{
"id": "3.9",
"name": "Implement Character sheet - Inventory tab",
"description": "Item list with slots display, wealth tracking (G/S/C), occupied/max/bonus slots, drag-drop support",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.2", "2.11"]
+ "dependencies": ["3.2", "2.11"],
+ "notes": "Inventory tab with weapons, armor, equipment sections, slot tracking, wealth inputs."
},
{
"id": "3.10",
"name": "Implement Character sheet - Abilities tab",
"description": "List of Features, Perks, Ancestry traits; expandable descriptions; usage tracking for limited abilities",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.2"]
+ "dependencies": ["3.2"],
+ "notes": "Abilities tab with ancestry, features, perks, and active effects display."
},
{
"id": "3.11",
"name": "Implement Character sheet - Magic tab",
"description": "Mana (current/max/casting max), known spells list with cast buttons, focus indicator",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.2", "2.8"]
+ "dependencies": ["3.2", "2.8"],
+ "notes": "Magic tab with mana display, focus tracking, spell list with cast buttons, reference guide."
},
{
"id": "3.12",
"name": "Implement Character sheet - Biography tab",
"description": "Rich text editor for character background, notes, bonds",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "medium",
- "dependencies": ["3.2"]
+ "dependencies": ["3.2"],
+ "notes": "Biography tab with character details, languages, senses, biography and notes textareas."
},
{
"id": "3.13",
"name": "Create NPC/Monster sheet layout",
"description": "Compact stat block format: HD, HP, TL, Zone, Morale, Armor, Immunities, Weaknesses, Actions, Abilities",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.1", "1.3"]
+ "dependencies": ["3.1", "1.3"],
+ "notes": "NPC sheet with header, stats, actions, abilities, and notes sections (no tabs)."
},
{
"id": "3.14",
"name": "Implement NPC sheet - Stat block section",
"description": "Display all combat-relevant stats in traditional TTRPG stat block format",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.13"]
+ "dependencies": ["3.13"],
+ "notes": "Stats section with zone, speed, size, being type, senses, damage modifiers."
},
{
"id": "3.15",
"name": "Implement NPC sheet - Actions section",
"description": "List of attack actions with clickable roll buttons, damage dice display",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "high",
- "dependencies": ["3.13", "2.4"]
+ "dependencies": ["3.13", "2.4"],
+ "notes": "Dynamic actions list with add/delete, editable fields, roll buttons."
},
{
"id": "3.16",
"name": "Implement NPC sheet - Morale button",
"description": "Clickable morale check with result interpretation",
- "completed": false,
+ "completed": true,
"tested": false,
"priority": "medium",
- "dependencies": ["3.13", "2.13"]
+ "dependencies": ["3.13", "2.13"],
+ "notes": "Morale roll button in header, disabled when morale broken."
}
]
},
diff --git a/lang/en.json b/lang/en.json
index 933bb52..f529205 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -279,5 +279,89 @@
"VAGABOND.InsufficientManaShort": "Insufficient Mana",
"VAGABOND.CastSuccess": "Cast Success!",
"VAGABOND.CastFailed": "Cast Failed",
- "VAGABOND.CriticalCast": "Critical Cast!"
+ "VAGABOND.CriticalCast": "Critical Cast!",
+
+ "VAGABOND.TabMain": "Main",
+ "VAGABOND.TabInventory": "Inventory",
+ "VAGABOND.TabAbilities": "Abilities",
+ "VAGABOND.TabMagic": "Magic",
+ "VAGABOND.TabBiography": "Biography",
+
+ "VAGABOND.CharacterName": "Character Name",
+ "VAGABOND.NPCName": "NPC Name",
+ "VAGABOND.ChangePortrait": "Change Portrait",
+
+ "VAGABOND.ResourceHP": "HP",
+ "VAGABOND.ResourceMana": "Mana",
+ "VAGABOND.ResourceLuck": "Luck",
+ "VAGABOND.ResourceFatigue": "Fatigue",
+ "VAGABOND.Heal": "Heal",
+
+ "VAGABOND.Overburdened": "Overburdened!",
+ "VAGABOND.Weapons": "Weapons",
+ "VAGABOND.Equipment": "Equipment",
+ "VAGABOND.EquippedWeapons": "Equipped Weapons",
+
+ "VAGABOND.RollSkill": "Roll Skill",
+ "VAGABOND.ToggleTrained": "Toggle Trained",
+ "VAGABOND.ToggleEquipped": "Toggle Equipped",
+ "VAGABOND.DeleteItem": "Delete Item",
+ "VAGABOND.CreateWeapon": "Create Weapon",
+ "VAGABOND.CreateArmor": "Create Armor",
+ "VAGABOND.CreateEquipment": "Create Equipment",
+ "VAGABOND.CreatePerk": "Create Perk",
+ "VAGABOND.NoWeapons": "No weapons",
+ "VAGABOND.NoArmor": "No armor",
+ "VAGABOND.NoEquipment": "No equipment",
+ "VAGABOND.NoFeatures": "No features",
+ "VAGABOND.NoPerks": "No perks",
+ "VAGABOND.NoSpells": "No spells known",
+
+ "VAGABOND.ActiveEffects": "Active Effects",
+ "VAGABOND.TemporaryEffects": "Temporary Effects",
+ "VAGABOND.PassiveEffects": "Passive Effects",
+
+ "VAGABOND.KnownSpells": "Known Spells",
+ "VAGABOND.FocusActive": "Active Focus",
+ "VAGABOND.SpellcastingReference": "Spellcasting Reference",
+
+ "VAGABOND.CharacterDetails": "Character Details",
+ "VAGABOND.Size": "Size",
+ "VAGABOND.BeingType": "Being Type",
+ "VAGABOND.Languages": "Languages",
+ "VAGABOND.NoLanguages": "No languages",
+ "VAGABOND.Senses": "Senses",
+ "VAGABOND.Darksight": "Darksight",
+ "VAGABOND.Blindsight": "Blindsight",
+ "VAGABOND.Tremorsense": "Tremorsense",
+ "VAGABOND.BiographyPlaceholder": "Enter character background...",
+ "VAGABOND.Notes": "Notes",
+ "VAGABOND.NotesPlaceholder": "Enter notes...",
+
+ "VAGABOND.RollMorale": "Roll Morale",
+ "VAGABOND.MoraleBroken": "Morale Broken!",
+ "VAGABOND.Immunities": "Immunities",
+ "VAGABOND.Resistances": "Resistances",
+ "VAGABOND.Weaknesses": "Weaknesses",
+ "VAGABOND.AddAction": "Add Action",
+ "VAGABOND.DeleteAction": "Delete Action",
+ "VAGABOND.RollAction": "Roll Action",
+ "VAGABOND.ActionName": "Action Name",
+ "VAGABOND.ActionDescription": "Action description...",
+ "VAGABOND.NoActions": "No actions defined",
+ "VAGABOND.AddAbility": "Add Ability",
+ "VAGABOND.DeleteAbility": "Delete Ability",
+ "VAGABOND.AbilityName": "Ability Name",
+ "VAGABOND.AbilityDescription": "Ability description...",
+ "VAGABOND.NoAbilities": "No abilities defined",
+ "VAGABOND.Passive": "Passive",
+ "VAGABOND.Range": "Range",
+ "VAGABOND.Loot": "Loot",
+ "VAGABOND.LootPlaceholder": "Describe loot and treasure...",
+ "VAGABOND.GMNotes": "GM Notes",
+ "VAGABOND.GMNotesPlaceholder": "Notes for the GM...",
+
+ "VAGABOND.ItemNew": "New {type}",
+ "VAGABOND.ItemDeleteTitle": "Delete {name}",
+ "VAGABOND.ItemDeleteConfirm": "Are you sure you want to delete {name}?"
}
diff --git a/module/sheets/_module.mjs b/module/sheets/_module.mjs
new file mode 100644
index 0000000..8de2719
--- /dev/null
+++ b/module/sheets/_module.mjs
@@ -0,0 +1,8 @@
+/**
+ * Sheet Classes Module
+ * Exports all actor and item sheet classes for the Vagabond RPG system.
+ */
+
+export { default as VagabondActorSheet } from "./base-actor-sheet.mjs";
+export { default as VagabondCharacterSheet } from "./character-sheet.mjs";
+export { default as VagabondNPCSheet } from "./npc-sheet.mjs";
diff --git a/module/sheets/base-actor-sheet.mjs b/module/sheets/base-actor-sheet.mjs
new file mode 100644
index 0000000..2291bd0
--- /dev/null
+++ b/module/sheets/base-actor-sheet.mjs
@@ -0,0 +1,716 @@
+/**
+ * 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":
+ 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 });
+ }
+}
diff --git a/module/sheets/character-sheet.mjs b/module/sheets/character-sheet.mjs
new file mode 100644
index 0000000..8702b61
--- /dev/null
+++ b/module/sheets/character-sheet.mjs
@@ -0,0 +1,367 @@
+/**
+ * Character Sheet for Vagabond RPG
+ *
+ * Extended sheet for player characters with:
+ * - Stats section with all six attributes
+ * - Combat section (HP, Armor, Fatigue, Speed)
+ * - Saves section (Reflex, Endure, Will)
+ * - Skills section (12 skills with trained/difficulty)
+ * - Attacks section (weapons and attack skills)
+ * - Inventory tab
+ * - Abilities tab (features, perks, ancestry)
+ * - Magic tab (mana, spells, focus)
+ * - Biography tab
+ *
+ * @extends VagabondActorSheet
+ */
+
+import VagabondActorSheet from "./base-actor-sheet.mjs";
+
+export default class VagabondCharacterSheet extends VagabondActorSheet {
+ /* -------------------------------------------- */
+ /* Static Properties */
+ /* -------------------------------------------- */
+
+ /** @override */
+ static DEFAULT_OPTIONS = foundry.utils.mergeObject(
+ super.DEFAULT_OPTIONS,
+ {
+ classes: ["vagabond", "sheet", "actor", "character"],
+ position: {
+ width: 750,
+ height: 850,
+ },
+ },
+ { inplace: false }
+ );
+
+ /** @override */
+ static PARTS = {
+ header: {
+ template: "systems/vagabond/templates/actor/character-header.hbs",
+ },
+ tabs: {
+ template: "systems/vagabond/templates/actor/parts/tabs.hbs",
+ },
+ main: {
+ template: "systems/vagabond/templates/actor/character-main.hbs",
+ },
+ inventory: {
+ template: "systems/vagabond/templates/actor/character-inventory.hbs",
+ },
+ abilities: {
+ template: "systems/vagabond/templates/actor/character-abilities.hbs",
+ },
+ magic: {
+ template: "systems/vagabond/templates/actor/character-magic.hbs",
+ },
+ biography: {
+ template: "systems/vagabond/templates/actor/character-biography.hbs",
+ },
+ };
+
+ /* -------------------------------------------- */
+ /* Getters */
+ /* -------------------------------------------- */
+
+ /** @override */
+ 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: "magic", label: "VAGABOND.TabMagic", icon: "fa-solid fa-wand-sparkles" },
+ { id: "biography", label: "VAGABOND.TabBiography", icon: "fa-solid fa-book" },
+ ];
+ }
+
+ /* -------------------------------------------- */
+ /* Data Preparation */
+ /* -------------------------------------------- */
+
+ /** @override */
+ async _prepareTypeContext(context, _options) {
+ // Stats with labels
+ context.stats = this._prepareStats();
+
+ // Skills organized by associated stat
+ context.skills = this._prepareSkills();
+
+ // Saves with calculated difficulties
+ context.saves = this._prepareSaves();
+
+ // Attack skills
+ context.attackSkills = this._prepareAttackSkills();
+
+ // Resources with display data
+ context.resources = this._prepareResources();
+
+ // Speed display
+ context.speed = this._prepareSpeed();
+
+ // Wealth display
+ context.wealth = this.actor.system.wealth;
+
+ // Item slots
+ context.itemSlots = this.actor.system.itemSlots;
+
+ // Character details
+ context.details = this.actor.system.details;
+ context.sizeOptions = CONFIG.VAGABOND?.sizes || {};
+ context.beingTypeOptions = CONFIG.VAGABOND?.beingTypes || {};
+
+ // Focus tracking
+ context.focus = this.actor.system.focus;
+ context.hasFocus = this.actor.system.focus?.active?.length > 0;
+
+ // Class and ancestry info
+ context.ancestry = context.items.ancestry;
+ context.classes = context.items.classes;
+ context.className = context.items.classes[0]?.name || "None";
+ context.ancestryName = context.items.ancestry?.name || "None";
+ }
+
+ /**
+ * Prepare stats for display with labels and colors.
+ * @returns {Object}
+ * @private
+ */
+ _prepareStats() {
+ const system = this.actor.system;
+ const stats = {};
+
+ const statConfig = {
+ might: { label: "VAGABOND.StatMight", abbr: "MIT", color: "stat-might" },
+ dexterity: { label: "VAGABOND.StatDexterity", abbr: "DEX", color: "stat-dexterity" },
+ awareness: { label: "VAGABOND.StatAwareness", abbr: "AWR", color: "stat-awareness" },
+ reason: { label: "VAGABOND.StatReason", abbr: "RSN", color: "stat-reason" },
+ presence: { label: "VAGABOND.StatPresence", abbr: "PRS", color: "stat-presence" },
+ luck: { label: "VAGABOND.StatLuck", abbr: "LUK", color: "stat-luck" },
+ };
+
+ for (const [key, config] of Object.entries(statConfig)) {
+ stats[key] = {
+ ...config,
+ value: system.stats[key].value,
+ path: `system.stats.${key}.value`,
+ };
+ }
+
+ return stats;
+ }
+
+ /**
+ * Prepare skills for display with associated stats and difficulties.
+ * @returns {Object}
+ * @private
+ */
+ _prepareSkills() {
+ const system = this.actor.system;
+ const skillConfig = CONFIG.VAGABOND?.skills || {};
+ const skills = {};
+
+ for (const [skillId, config] of Object.entries(skillConfig)) {
+ const skillData = system.skills[skillId];
+ if (!skillData) continue;
+
+ skills[skillId] = {
+ id: skillId,
+ label: config.label || skillId,
+ stat: config.stat,
+ statAbbr: this._getStatAbbr(config.stat),
+ trained: skillData.trained,
+ difficulty: skillData.difficulty,
+ critThreshold: skillData.critThreshold,
+ hasCritBonus: skillData.critThreshold < 20,
+ };
+ }
+
+ return skills;
+ }
+
+ /**
+ * Prepare saves for display.
+ * @returns {Object}
+ * @private
+ */
+ _prepareSaves() {
+ const system = this.actor.system;
+
+ return {
+ reflex: {
+ id: "reflex",
+ label: "VAGABOND.SaveReflex",
+ stats: "DEX + AWR",
+ difficulty: system.saves.reflex.difficulty,
+ bonus: system.saves.reflex.bonus,
+ },
+ endure: {
+ id: "endure",
+ label: "VAGABOND.SaveEndure",
+ stats: "MIT + MIT",
+ difficulty: system.saves.endure.difficulty,
+ bonus: system.saves.endure.bonus,
+ },
+ will: {
+ id: "will",
+ label: "VAGABOND.SaveWill",
+ stats: "RSN + PRS",
+ difficulty: system.saves.will.difficulty,
+ bonus: system.saves.will.bonus,
+ },
+ };
+ }
+
+ /**
+ * Prepare attack skills for display.
+ * @returns {Object}
+ * @private
+ */
+ _prepareAttackSkills() {
+ const system = this.actor.system;
+
+ const attackConfig = {
+ melee: { label: "VAGABOND.AttackMelee", stat: "might" },
+ brawl: { label: "VAGABOND.AttackBrawl", stat: "might" },
+ ranged: { label: "VAGABOND.AttackRanged", stat: "dexterity" },
+ finesse: { label: "VAGABOND.AttackFinesse", stat: "dexterity" },
+ };
+
+ const attacks = {};
+
+ for (const [key, config] of Object.entries(attackConfig)) {
+ const statValue = system.stats[config.stat]?.value || 0;
+ // Attack difficulty is 20 - stat (always trained)
+ const difficulty = 20 - statValue * 2;
+
+ attacks[key] = {
+ id: key,
+ label: config.label,
+ stat: config.stat,
+ statAbbr: this._getStatAbbr(config.stat),
+ difficulty,
+ critThreshold: system.attacks[key]?.critThreshold || 20,
+ hasCritBonus: (system.attacks[key]?.critThreshold || 20) < 20,
+ };
+ }
+
+ return attacks;
+ }
+
+ /**
+ * Prepare resources for display.
+ * @returns {Object}
+ * @private
+ */
+ _prepareResources() {
+ const system = this.actor.system;
+
+ return {
+ hp: {
+ label: "VAGABOND.ResourceHP",
+ value: system.resources.hp.value,
+ max: system.resources.hp.max,
+ percent: Math.round((system.resources.hp.value / system.resources.hp.max) * 100) || 0,
+ color: this._getResourceColor(system.resources.hp.value, system.resources.hp.max),
+ },
+ mana: {
+ label: "VAGABOND.ResourceMana",
+ value: system.resources.mana.value,
+ max: system.resources.mana.max,
+ castingMax: system.resources.mana.castingMax,
+ percent: Math.round((system.resources.mana.value / system.resources.mana.max) * 100) || 0,
+ },
+ luck: {
+ label: "VAGABOND.ResourceLuck",
+ value: system.resources.luck.value,
+ max: system.resources.luck.max,
+ },
+ fatigue: {
+ label: "VAGABOND.ResourceFatigue",
+ value: system.resources.fatigue.value,
+ max: 5,
+ isDangerous: system.resources.fatigue.value >= 4,
+ },
+ };
+ }
+
+ /**
+ * Prepare speed display.
+ * @returns {Object}
+ * @private
+ */
+ _prepareSpeed() {
+ const system = this.actor.system;
+
+ return {
+ walk: system.speed.walk,
+ fly: system.speed.fly,
+ swim: system.speed.swim,
+ climb: system.speed.climb,
+ hasSpecialMovement: system.speed.fly > 0 || system.speed.swim > 0 || system.speed.climb > 0,
+ };
+ }
+
+ /**
+ * Get stat abbreviation.
+ * @param {string} stat
+ * @returns {string}
+ * @private
+ */
+ _getStatAbbr(stat) {
+ const abbrs = {
+ might: "MIT",
+ dexterity: "DEX",
+ awareness: "AWR",
+ reason: "RSN",
+ presence: "PRS",
+ luck: "LUK",
+ };
+ return abbrs[stat] || stat.toUpperCase().slice(0, 3);
+ }
+
+ /**
+ * Get color class for resource bar based on percentage.
+ * @param {number} value
+ * @param {number} max
+ * @returns {string}
+ * @private
+ */
+ _getResourceColor(value, max) {
+ const percent = (value / max) * 100;
+ if (percent <= 25) return "critical";
+ if (percent <= 50) return "warning";
+ return "healthy";
+ }
+
+ /* -------------------------------------------- */
+ /* Rendering */
+ /* -------------------------------------------- */
+
+ /** @override */
+ async _preparePartContext(partId, context, options) {
+ context = await super._preparePartContext(partId, context, options);
+
+ // Only render the active tab's content
+ if (["main", "inventory", "abilities", "magic", "biography"].includes(partId)) {
+ context.isActiveTab = partId === this._activeTab;
+ }
+
+ return context;
+ }
+
+ /** @override */
+ _configureRenderOptions(options) {
+ super._configureRenderOptions(options);
+
+ // Always render header and tabs
+ options.parts = ["header", "tabs"];
+
+ // Add the active tab's part
+ if (this._activeTab && VagabondCharacterSheet.PARTS[this._activeTab]) {
+ options.parts.push(this._activeTab);
+ } else {
+ options.parts.push("main");
+ }
+ }
+}
diff --git a/module/sheets/npc-sheet.mjs b/module/sheets/npc-sheet.mjs
new file mode 100644
index 0000000..0f7c604
--- /dev/null
+++ b/module/sheets/npc-sheet.mjs
@@ -0,0 +1,279 @@
+/**
+ * NPC/Monster Sheet for Vagabond RPG
+ *
+ * Compact stat block format sheet for NPCs and monsters:
+ * - Header with HD, HP, TL, Zone
+ * - Combat stats (Armor, Morale, Speed)
+ * - Immunities/Weaknesses/Resistances
+ * - Actions list with attack buttons
+ * - Abilities list
+ * - GM notes
+ *
+ * @extends VagabondActorSheet
+ */
+
+import VagabondActorSheet from "./base-actor-sheet.mjs";
+
+export default class VagabondNPCSheet extends VagabondActorSheet {
+ /* -------------------------------------------- */
+ /* Static Properties */
+ /* -------------------------------------------- */
+
+ /** @override */
+ static DEFAULT_OPTIONS = foundry.utils.mergeObject(
+ super.DEFAULT_OPTIONS,
+ {
+ classes: ["vagabond", "sheet", "actor", "npc"],
+ position: {
+ width: 520,
+ height: 600,
+ },
+ actions: {
+ ...VagabondActorSheet.DEFAULT_OPTIONS.actions,
+ rollMorale: VagabondNPCSheet.#onRollMorale,
+ rollAction: VagabondNPCSheet.#onRollAction,
+ addAction: VagabondNPCSheet.#onAddAction,
+ deleteAction: VagabondNPCSheet.#onDeleteAction,
+ addAbility: VagabondNPCSheet.#onAddAbility,
+ deleteAbility: VagabondNPCSheet.#onDeleteAbility,
+ },
+ },
+ { inplace: false }
+ );
+
+ /** @override */
+ static PARTS = {
+ header: {
+ template: "systems/vagabond/templates/actor/npc-header.hbs",
+ },
+ stats: {
+ template: "systems/vagabond/templates/actor/npc-stats.hbs",
+ },
+ actions: {
+ template: "systems/vagabond/templates/actor/npc-actions.hbs",
+ },
+ abilities: {
+ template: "systems/vagabond/templates/actor/npc-abilities.hbs",
+ },
+ notes: {
+ template: "systems/vagabond/templates/actor/npc-notes.hbs",
+ },
+ };
+
+ /* -------------------------------------------- */
+ /* Getters */
+ /* -------------------------------------------- */
+
+ /** @override */
+ get tabs() {
+ // NPC sheets don't use tabs - all content on one page
+ return [];
+ }
+
+ /* -------------------------------------------- */
+ /* Data Preparation */
+ /* -------------------------------------------- */
+
+ /** @override */
+ async _prepareTypeContext(context, _options) {
+ const system = this.actor.system;
+
+ // Core combat stats
+ context.hd = system.hd;
+ context.hp = {
+ value: system.hp.value,
+ max: system.hp.max,
+ percent: Math.round((system.hp.value / system.hp.max) * 100) || 0,
+ isHalf: system.hp.value <= Math.floor(system.hp.max / 2),
+ isDead: system.hp.value <= 0,
+ };
+ context.tl = system.tl;
+ context.armor = system.armor;
+ context.morale = system.morale;
+ context.moraleStatus = system.moraleStatus;
+
+ // Zone with behavior hint
+ context.zone = system.zone;
+ context.zoneBehavior = system.getZoneBehavior?.() || "";
+ context.zoneOptions = {
+ frontline: "VAGABOND.ZoneFrontline",
+ midline: "VAGABOND.ZoneMidline",
+ backline: "VAGABOND.ZoneBackline",
+ };
+
+ // Size and being type
+ context.size = system.size;
+ context.beingType = system.beingType;
+ context.sizeOptions = CONFIG.VAGABOND?.sizes || {};
+ context.beingTypeOptions = CONFIG.VAGABOND?.beingTypes || {};
+
+ // Speed
+ context.speed = {
+ walk: system.speed.value,
+ fly: system.speed.fly,
+ swim: system.speed.swim,
+ climb: system.speed.climb,
+ hasSpecialMovement: system.speed.fly > 0 || system.speed.swim > 0 || system.speed.climb > 0,
+ };
+
+ // Senses
+ context.senses = system.senses;
+ context.hasSenses =
+ system.senses.darksight || system.senses.blindsight > 0 || system.senses.tremorsense > 0;
+
+ // Damage modifiers
+ context.immunities = system.immunities || [];
+ context.weaknesses = system.weaknesses || [];
+ context.resistances = system.resistances || [];
+ context.hasDamageModifiers =
+ context.immunities.length > 0 ||
+ context.weaknesses.length > 0 ||
+ context.resistances.length > 0;
+
+ // Actions with index for editing
+ context.actions = (system.actions || []).map((action, index) => ({
+ ...action,
+ index,
+ }));
+ context.hasActions = context.actions.length > 0;
+
+ // Abilities with index for editing
+ context.abilities = (system.abilities || []).map((ability, index) => ({
+ ...ability,
+ index,
+ }));
+ context.hasAbilities = context.abilities.length > 0;
+
+ // Appearing (encounter numbers)
+ context.appearing = system.appearing;
+
+ // Loot and GM notes
+ context.loot = system.loot;
+ context.gmNotes = system.gmNotes;
+
+ // Damage type options for actions
+ context.damageTypeOptions = CONFIG.VAGABOND?.damageTypes || {};
+ context.attackTypeOptions = {
+ melee: "VAGABOND.AttackMelee",
+ ranged: "VAGABOND.AttackRanged",
+ };
+ }
+
+ /** @override */
+ _configureRenderOptions(options) {
+ super._configureRenderOptions(options);
+
+ // NPC sheets render all parts (no tabs)
+ options.parts = ["header", "stats", "actions", "abilities", "notes"];
+ }
+
+ /* -------------------------------------------- */
+ /* Action Handlers */
+ /* -------------------------------------------- */
+
+ /**
+ * Handle morale roll.
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onRollMorale(event, _target) {
+ event.preventDefault();
+ await this.actor.rollMorale({ trigger: "manual" });
+ }
+
+ /**
+ * Handle action roll (attack).
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onRollAction(event, target) {
+ event.preventDefault();
+ const actionIndex = parseInt(target.dataset.actionIndex, 10);
+ const action = this.actor.system.actions[actionIndex];
+ if (!action) return;
+
+ // Roll the damage for this action
+ const roll = await new Roll(action.damage).evaluate();
+
+ // Create chat message
+ const content = `
+
+
${action.name}
+ ${action.description ? `
${action.description}
` : ""}
+
+ Damage: [[/r ${action.damage}]] ${action.damageType}
+
+ ${action.range ? `
Range: ${action.range}
` : ""}
+
+ `;
+
+ await ChatMessage.create({
+ speaker: ChatMessage.getSpeaker({ actor: this.actor }),
+ content,
+ rolls: [roll],
+ sound: CONFIG.sounds.dice,
+ });
+ }
+
+ /**
+ * Handle adding a new action.
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onAddAction(event, _target) {
+ event.preventDefault();
+ const actions = [...(this.actor.system.actions || [])];
+ actions.push({
+ name: "New Action",
+ description: "",
+ attackType: "melee",
+ damage: "1d6",
+ damageType: "blunt",
+ range: "",
+ properties: [],
+ });
+ await this.actor.update({ "system.actions": actions });
+ }
+
+ /**
+ * Handle deleting an action.
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onDeleteAction(event, target) {
+ event.preventDefault();
+ const actionIndex = parseInt(target.dataset.actionIndex, 10);
+ const actions = [...(this.actor.system.actions || [])];
+ actions.splice(actionIndex, 1);
+ await this.actor.update({ "system.actions": actions });
+ }
+
+ /**
+ * Handle adding a new ability.
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onAddAbility(event, _target) {
+ event.preventDefault();
+ const abilities = [...(this.actor.system.abilities || [])];
+ abilities.push({
+ name: "New Ability",
+ description: "",
+ passive: true,
+ });
+ await this.actor.update({ "system.abilities": abilities });
+ }
+
+ /**
+ * Handle deleting an ability.
+ * @param {PointerEvent} event
+ * @param {HTMLElement} target
+ */
+ static async #onDeleteAbility(event, target) {
+ event.preventDefault();
+ const abilityIndex = parseInt(target.dataset.abilityIndex, 10);
+ const abilities = [...(this.actor.system.abilities || [])];
+ abilities.splice(abilityIndex, 1);
+ await this.actor.update({ "system.abilities": abilities });
+ }
+}
diff --git a/module/vagabond.mjs b/module/vagabond.mjs
index 47a0d07..4884993 100644
--- a/module/vagabond.mjs
+++ b/module/vagabond.mjs
@@ -3,6 +3,8 @@
* @module vagabond
*/
+/* global Actors */
+
// Import configuration
import { VAGABOND } from "./helpers/config.mjs";
@@ -33,11 +35,7 @@ import {
} from "./applications/_module.mjs";
// Import sheet classes
-// import { VagabondCharacterSheet } from "./sheets/actor-sheet.mjs";
-// import { VagabondItemSheet } from "./sheets/item-sheet.mjs";
-
-// Import helper functions
-// import { preloadHandlebarsTemplates } from "./helpers/templates.mjs";
+import { VagabondActorSheet, VagabondCharacterSheet, VagabondNPCSheet } from "./sheets/_module.mjs";
// Import test registration (for Quench)
import { registerQuenchTests } from "./tests/quench-init.mjs";
@@ -46,10 +44,35 @@ import { registerQuenchTests } from "./tests/quench-init.mjs";
/* Foundry VTT Initialization */
/* -------------------------------------------- */
+/**
+ * Preload Handlebars templates.
+ * @returns {Promise}
+ */
+async function preloadHandlebarsTemplates() {
+ const templatePaths = [
+ // Character sheet parts
+ "systems/vagabond/templates/actor/character-header.hbs",
+ "systems/vagabond/templates/actor/character-main.hbs",
+ "systems/vagabond/templates/actor/character-inventory.hbs",
+ "systems/vagabond/templates/actor/character-abilities.hbs",
+ "systems/vagabond/templates/actor/character-magic.hbs",
+ "systems/vagabond/templates/actor/character-biography.hbs",
+ "systems/vagabond/templates/actor/parts/tabs.hbs",
+ // NPC sheet parts
+ "systems/vagabond/templates/actor/npc-header.hbs",
+ "systems/vagabond/templates/actor/npc-stats.hbs",
+ "systems/vagabond/templates/actor/npc-actions.hbs",
+ "systems/vagabond/templates/actor/npc-abilities.hbs",
+ "systems/vagabond/templates/actor/npc-notes.hbs",
+ ];
+
+ return loadTemplates(templatePaths);
+}
+
/**
* Init hook - runs once when Foundry initializes
*/
-Hooks.once("init", () => {
+Hooks.once("init", async () => {
// eslint-disable-next-line no-console
console.log("Vagabond RPG | Initializing Vagabond RPG System");
@@ -66,6 +89,11 @@ Hooks.once("init", () => {
SpellCastDialog,
FavorHinderDebug,
},
+ sheets: {
+ VagabondActorSheet,
+ VagabondCharacterSheet,
+ VagabondNPCSheet,
+ },
};
// Register Actor data models
@@ -90,22 +118,28 @@ Hooks.once("init", () => {
CONFIG.Actor.documentClass = VagabondActor;
CONFIG.Item.documentClass = VagabondItem;
- // Register sheet application classes (TODO: Phase 3-4)
- // Actors.unregisterSheet("core", ActorSheet);
- // Actors.registerSheet("vagabond", VagabondCharacterSheet, {
- // types: ["character"],
- // makeDefault: true,
- // label: "VAGABOND.SheetCharacter"
- // });
+ // Register Actor sheet classes
+ Actors.unregisterSheet("core", ActorSheet);
+ Actors.registerSheet("vagabond", VagabondCharacterSheet, {
+ types: ["character"],
+ makeDefault: true,
+ label: "VAGABOND.SheetCharacter",
+ });
+ Actors.registerSheet("vagabond", VagabondNPCSheet, {
+ types: ["npc"],
+ makeDefault: true,
+ label: "VAGABOND.SheetNPC",
+ });
+ // Register Item sheet classes (TODO: Phase 4)
// Items.unregisterSheet("core", ItemSheet);
// Items.registerSheet("vagabond", VagabondItemSheet, {
// makeDefault: true,
// label: "VAGABOND.SheetItem"
// });
- // Preload Handlebars templates (TODO: Phase 3)
- // return preloadHandlebarsTemplates();
+ // Preload Handlebars templates
+ await preloadHandlebarsTemplates();
});
/* -------------------------------------------- */
diff --git a/styles/scss/_variables.scss b/styles/scss/_variables.scss
index 714a89e..5997b6d 100644
--- a/styles/scss/_variables.scss
+++ b/styles/scss/_variables.scss
@@ -100,3 +100,6 @@ $z-fixed: 300;
$z-modal-backdrop: 400;
$z-modal: 500;
$z-tooltip: 600;
+
+// Breakpoints for responsive layouts
+$breakpoint-narrow: 700px;
diff --git a/styles/scss/components/_buttons.scss b/styles/scss/components/_buttons.scss
index aeb0185..fbe6d8c 100644
--- a/styles/scss/components/_buttons.scss
+++ b/styles/scss/components/_buttons.scss
@@ -33,9 +33,11 @@
padding: 0;
background-color: transparent;
border-color: transparent;
+ color: $color-text-primary;
&:hover:not(:disabled) {
background-color: $color-parchment-dark;
+ color: $color-accent-primary;
}
i {
@@ -43,6 +45,29 @@
}
}
+ // Small icon button (for +/- resource buttons)
+ .btn-icon-sm {
+ @include button-base;
+ @include flex-center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background-color: $color-parchment;
+ border: 1px solid $color-border;
+ color: $color-text-primary;
+
+ &:hover:not(:disabled) {
+ background-color: $color-parchment-dark;
+ border-color: $color-accent-primary;
+ color: $color-accent-primary;
+ }
+
+ i {
+ font-size: 10px;
+ color: inherit;
+ }
+ }
+
// Rollable button (for dice rolls)
.btn-roll {
@include button-base;
diff --git a/styles/scss/sheets/_actor-sheet.scss b/styles/scss/sheets/_actor-sheet.scss
index 206f724..45e123d 100644
--- a/styles/scss/sheets/_actor-sheet.scss
+++ b/styles/scss/sheets/_actor-sheet.scss
@@ -1,44 +1,418 @@
// Vagabond RPG - Actor Sheet Styles
// ==================================
-// Placeholder - will be expanded in Phase 3
+// Base actor sheet styles
.vagabond.sheet.actor {
min-width: 600px;
min-height: 500px;
+ // ==========================================
+ // HEADER - Three Column Layout
+ // ==========================================
.sheet-header {
- @include flex-between;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: $spacing-4;
padding: $spacing-4;
background-color: $color-parchment-dark;
border-bottom: 2px solid $color-border;
- .profile-img {
- width: 100px;
- height: 100px;
- object-fit: cover;
- border: 2px solid $color-border;
- border-radius: $radius-md;
+ // Left column - Portrait
+ .header-left {
+ display: flex;
+ align-items: flex-start;
+
+ .profile-img {
+ width: 100px;
+ height: 100px;
+ object-fit: cover;
+ border: none; // Remove border, container provides it
+ border-radius: $radius-md;
+ cursor: pointer;
+ transition: opacity $transition-fast;
+
+ &:hover {
+ opacity: 0.85;
+ }
+ }
}
- .header-fields {
+ // Center column - Name and details
+ .header-center {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-2;
+ min-width: 200px;
+
+ .actor-name {
+ margin: 0;
+
+ input {
+ @include input-base;
+ width: 100%;
+ font-family: $font-family-header;
+ font-size: $font-size-xl;
+ font-weight: $font-weight-bold;
+ padding: $spacing-2;
+ }
+ }
+
+ .header-fields {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-2;
+
+ .header-field {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-1;
+
+ label {
+ font-size: $font-size-xs;
+ font-weight: $font-weight-semibold;
+ color: $color-text-secondary;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ span {
+ font-size: $font-size-sm;
+ color: $color-text-primary;
+ }
+
+ input {
+ @include input-base;
+ padding: $spacing-1 $spacing-2;
+ font-size: $font-size-sm;
+
+ &[type="number"] {
+ width: 100%;
+ text-align: center;
+ }
+ }
+ }
+ }
+ }
+
+ // Right column - Resources
+ .header-right {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-3;
+ min-width: 200px;
+ max-width: 250px;
+ }
+ }
+
+ // ==========================================
+ // RESOURCE BARS (HP, Mana)
+ // ==========================================
+ .resource-bars {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-2;
+ }
+
+ .resource-bar {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-1;
+
+ > label {
+ font-size: $font-size-xs;
+ font-weight: $font-weight-bold;
+ color: $color-text-secondary;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ .bar-row {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ }
+
+ // The meter container
+ .bar-container {
flex: 1;
- margin-left: $spacing-4;
+ position: relative;
+ height: 28px;
+ background-color: $color-parchment-darker;
+ border: 1px solid $color-border;
+ border-radius: $radius-md;
+ overflow: hidden;
+ box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.15);
+
+ // The fill bar
+ .bar-fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ border-radius: $radius-md 0 0 $radius-md;
+ transition: width $transition-base;
+ }
+
+ // Values overlay - backdrop pill for legibility
+ .bar-values {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: $spacing-1;
+ padding: $spacing-1 $spacing-2;
+ background: rgba($color-parchment-light, 0.85);
+ border-radius: $radius-full;
+ font-weight: $font-weight-semibold;
+ color: $color-text-primary;
+
+ input {
+ width: 32px;
+ padding: 2px 4px;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-bold;
+ text-align: center;
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid $color-border-light;
+ border-radius: $radius-sm;
+
+ &:focus {
+ background: white;
+ outline: none;
+ border-color: $color-accent-primary;
+ }
+ }
+
+ .separator {
+ color: $color-text-secondary;
+ font-size: $font-size-sm;
+ }
+
+ .max {
+ font-size: $font-size-sm;
+ color: $color-text-secondary;
+ }
+ }
+ }
+
+ // Resource +/- buttons
+ .resource-buttons {
+ display: flex;
+ gap: $spacing-1;
+
+ button {
+ @include button-base;
+ @include flex-center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background-color: $color-parchment;
+ border: 1px solid $color-border;
+ color: $color-text-primary;
+
+ &:hover:not(:disabled) {
+ background-color: $color-parchment-dark;
+ border-color: $color-accent-primary;
+ color: $color-accent-primary;
+ }
+
+ i {
+ font-size: 10px;
+ color: inherit;
+ }
+ }
+ }
+
+ // HP-specific colors
+ &.hp {
+ .bar-fill {
+ background: linear-gradient(to bottom, $color-danger, darken($color-danger, 10%));
+ }
+
+ &.healthy .bar-fill {
+ background: linear-gradient(to bottom, $color-success, darken($color-success, 10%));
+ }
+
+ &.warning .bar-fill {
+ background: linear-gradient(to bottom, $color-warning, darken($color-warning, 10%));
+ }
+
+ &.critical .bar-fill {
+ background: linear-gradient(to bottom, $color-danger, darken($color-danger, 15%));
+ }
+ }
+
+ // Mana-specific colors
+ &.mana {
+ .bar-fill {
+ background: linear-gradient(to bottom, $color-info, darken($color-info, 10%));
+ }
+ }
+ }
+
+ // ==========================================
+ // SECONDARY RESOURCES (Luck, Fatigue, etc.)
+ // ==========================================
+ .secondary-resources {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-2;
+ }
+
+ .resource {
+ display: flex;
+ align-items: center;
+ gap: $spacing-1;
+ padding: $spacing-1 $spacing-2;
+ background-color: $color-parchment;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-md;
+ font-size: $font-size-sm;
+
+ label {
+ font-weight: $font-weight-semibold;
+ color: $color-text-secondary;
+ margin-right: auto;
+ }
+
+ .value {
+ font-weight: $font-weight-bold;
+ color: $color-text-primary;
+ }
+
+ .separator {
+ color: $color-text-muted;
+ }
+
+ .max {
+ color: $color-text-secondary;
+ }
+
+ .unit {
+ font-size: $font-size-xs;
+ color: $color-text-muted;
+ }
+
+ input {
+ width: 36px;
+ padding: $spacing-1;
+ font-size: $font-size-sm;
+ font-weight: $font-weight-bold;
+ text-align: center;
+ background: $color-parchment-light;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-sm;
+
+ &:focus {
+ outline: none;
+ border-color: $color-accent-primary;
+ }
+ }
+
+ // Danger state for fatigue
+ &.danger {
+ background-color: rgba($color-danger, 0.1);
+ border-color: $color-danger;
+
+ label,
+ .value {
+ color: $color-danger;
+ }
}
}
}
-// Character sheet specific
+// ==========================================
+// CHARACTER SHEET SPECIFIC
+// ==========================================
.vagabond.sheet.actor.character {
- // Stats column (left side, matching official sheet)
- .stats-column {
- @include flex-column;
- gap: $spacing-3;
+ // ----------------------------------------
+ // Tab Content Area
+ // ----------------------------------------
+ .tab-content {
padding: $spacing-4;
- background-color: $color-parchment-dark;
- border-right: 2px solid $color-border;
+ background-color: $color-parchment;
+ overflow-y: auto;
+ @include custom-scrollbar;
+
+ // Enable container queries for responsive layout based on sheet width
+ container-type: inline-size;
+ container-name: sheet-content;
+ }
+
+ // ----------------------------------------
+ // Section Headers
+ // ----------------------------------------
+ .section-header {
+ font-family: $font-family-header;
+ font-size: $font-size-lg;
+ font-weight: $font-weight-bold;
+ color: $color-text-primary;
+ margin: 0 0 $spacing-3 0;
+ padding-bottom: $spacing-2;
+ border-bottom: 2px solid $color-border;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ .subsection-header {
+ font-family: $font-family-header;
+ font-size: $font-size-base;
+ font-weight: $font-weight-semibold;
+ color: $color-text-secondary;
+ margin: $spacing-3 0 $spacing-2 0;
+ }
+
+ // ----------------------------------------
+ // Main Tab Layout
+ // ----------------------------------------
+ .main-tab {
+ .main-grid {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: $spacing-4;
+
+ // Responsive: stack vertically on narrow container
+ @container sheet-content (max-width: #{$breakpoint-narrow}) {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto;
+
+ .stats-column {
+ order: 1;
+ }
+ .center-column {
+ order: 2;
+ }
+ .attacks-column {
+ order: 3;
+ }
+ }
+ }
+ }
+
+ // ----------------------------------------
+ // Stats Column (Left)
+ // ----------------------------------------
+ .stats-column {
+ min-width: 120px;
+
+ .stats-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-2;
+ }
.stat-block {
- text-align: center;
+ @include flex-column;
+ align-items: center;
+ padding: $spacing-2;
+ background-color: $color-parchment-light;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-md;
.stat-label {
font-family: $font-family-header;
@@ -51,14 +425,333 @@
}
.stat-value {
- @include stat-badge;
- margin: 0 auto;
+ width: 48px;
+ height: 48px;
+ padding: 0;
+ font-family: $font-family-header;
+ font-size: $font-size-2xl;
+ font-weight: $font-weight-bold;
+ text-align: center;
+ color: $color-text-primary;
+ background-color: $color-parchment;
+ border: 2px solid $color-border;
+ border-radius: $radius-md;
+
+ &:focus {
+ outline: none;
+ border-color: $color-accent-primary;
+ }
+ }
+ }
+
+ // Responsive: horizontal stats on narrow container
+ @container sheet-content (max-width: #{$breakpoint-narrow}) {
+ min-width: 0;
+
+ .stats-grid {
+ grid-template-columns: repeat(6, 1fr);
+ gap: $spacing-1;
+ }
+
+ .stat-block {
+ padding: $spacing-1;
+
+ .stat-value {
+ width: 40px;
+ height: 40px;
+ font-size: $font-size-xl;
+ }
+ }
+ }
+ }
+
+ // ----------------------------------------
+ // Center Column (Saves & Skills)
+ // ----------------------------------------
+ .center-column {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-4;
+ }
+
+ // Saves Section
+ .saves-section {
+ .saves-list {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-2;
+ }
+
+ .save-row {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-2 $spacing-3;
+ background-color: $color-parchment-light;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-md;
+ cursor: pointer;
+ transition: all $transition-fast;
+
+ &:hover {
+ background-color: $color-parchment-dark;
+ border-color: $color-accent-primary;
+
+ .roll-icon {
+ color: $color-accent-primary;
+ }
+ }
+
+ .save-label {
+ font-weight: $font-weight-semibold;
+ color: $color-text-primary;
+ }
+
+ .save-stats {
+ font-size: $font-size-sm;
+ color: $color-text-secondary;
+ margin-right: auto;
+ }
+
+ .save-difficulty {
+ font-family: $font-family-header;
+ font-size: $font-size-lg;
+ font-weight: $font-weight-bold;
+ color: $color-text-primary;
+ min-width: 28px;
+ text-align: center;
+ }
+
+ .roll-icon {
+ color: $color-text-muted;
+ transition: color $transition-fast;
+ }
+ }
+ }
+
+ // Skills Section
+ .skills-section {
+ .skills-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-2;
+ }
+
+ .skill-row {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-1 $spacing-2;
+ background-color: $color-parchment-light;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-md;
+ transition: all $transition-fast;
+
+ &:hover {
+ background-color: $color-parchment-dark;
+ border-color: $color-border;
+ }
+
+ &.trained {
+ background-color: rgba($color-success, 0.1);
+ border-color: rgba($color-success, 0.3);
+
+ .skill-trained-toggle {
+ color: $color-success;
+ border-color: $color-success;
+ }
+ }
+
+ .skill-trained-toggle {
+ @include flex-center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ background: transparent;
+ border: 1px solid $color-border;
+ border-radius: $radius-sm;
+ color: $color-text-muted;
+ cursor: pointer;
+ transition: all $transition-fast;
+
+ &:hover {
+ border-color: $color-accent-primary;
+ color: $color-accent-primary;
+ }
+
+ i {
+ font-size: 10px;
+ }
+ }
+
+ .skill-name {
+ font-weight: $font-weight-medium;
+ color: $color-text-primary;
+ flex: 1;
+ }
+
+ .skill-stat {
+ font-size: $font-size-xs;
+ color: $color-text-secondary;
+ }
+
+ .skill-difficulty {
+ font-family: $font-family-header;
+ font-weight: $font-weight-bold;
+ color: $color-text-primary;
+ min-width: 24px;
+ text-align: center;
+ }
+
+ .skill-crit {
+ font-size: $font-size-xs;
+ color: $color-warning;
+
+ i {
+ margin-right: 2px;
+ }
+ }
+
+ .skill-roll-btn {
+ @include flex-center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: $color-text-muted;
+ cursor: pointer;
+ transition: color $transition-fast;
+
+ &:hover {
+ color: $color-accent-primary;
+ }
+ }
+ }
+ }
+
+ // ----------------------------------------
+ // Attacks Column (Right)
+ // ----------------------------------------
+ .attacks-column {
+ min-width: 180px;
+
+ .attack-skills-grid {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-2;
+ }
+
+ // Responsive: horizontal attacks on narrow container
+ @container sheet-content (max-width: #{$breakpoint-narrow}) {
+ min-width: 0;
+
+ .attack-skills-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: $spacing-2;
+ }
+ }
+
+ .attack-skill-row {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-2 $spacing-3;
+ background-color: $color-parchment-light;
+ border: 1px solid $color-border-light;
+ border-radius: $radius-md;
+ cursor: pointer;
+ transition: all $transition-fast;
+
+ &:hover {
+ background-color: $color-parchment-dark;
+ border-color: $color-accent-primary;
+ }
+
+ .attack-name {
+ font-weight: $font-weight-semibold;
+ color: $color-text-primary;
+ }
+
+ .attack-stat {
+ font-size: $font-size-xs;
+ color: $color-text-secondary;
+ margin-right: auto;
+ }
+
+ .attack-difficulty {
+ font-family: $font-family-header;
+ font-weight: $font-weight-bold;
+ color: $color-text-primary;
+ }
+
+ .attack-crit {
+ font-size: $font-size-xs;
+ color: $color-warning;
+ }
+ }
+
+ // Equipped Weapons List
+ .weapon-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .weapon-item {
+ display: flex;
+ align-items: center;
+ gap: $spacing-2;
+ padding: $spacing-2;
+ border-bottom: 1px solid $color-border-light;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .weapon-name {
+ flex: 1;
+ font-weight: $font-weight-medium;
+ color: $color-text-primary;
+ cursor: pointer;
+
+ &:hover {
+ color: $color-accent-primary;
+ }
+ }
+
+ .weapon-damage {
+ font-family: $font-family-mono;
+ font-size: $font-size-sm;
+ color: $color-danger;
+ }
+
+ .weapon-roll {
+ @include flex-center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ background-color: $color-parchment;
+ border: 1px solid $color-border;
+ border-radius: $radius-md;
+ color: $color-text-primary;
+ cursor: pointer;
+ transition: all $transition-fast;
+
+ &:hover {
+ background-color: $color-accent-highlight;
+ border-color: $color-accent-primary;
+ color: $color-accent-primary;
+ }
}
}
}
}
-// NPC/Monster sheet specific
+// ==========================================
+// NPC/MONSTER SHEET SPECIFIC
+// ==========================================
.vagabond.sheet.actor.npc {
min-width: 400px;
diff --git a/templates/actor/character-abilities.hbs b/templates/actor/character-abilities.hbs
new file mode 100644
index 0000000..1e2f1f2
--- /dev/null
+++ b/templates/actor/character-abilities.hbs
@@ -0,0 +1,121 @@
+{{!-- Character Sheet Abilities Tab --}}
+
+ {{!-- Ancestry Section --}}
+ {{#if ancestry}}
+
+
+
+
+ {{#if ancestry.system.traits.length}}
+
+ {{#each ancestry.system.traits}}
+
+ {{this.name}}: {{{this.description}}}
+
+ {{/each}}
+
+ {{/if}}
+
+
+ {{/if}}
+
+ {{!-- Class Features Section --}}
+
+
+ {{!-- Perks Section --}}
+
+
+ {{!-- Active Effects Section --}}
+
+
+
+ {{#if effects.temporary.length}}
+
+
+
+ {{#each effects.temporary}}
+ -
+
+ {{this.name}}
+ {{#if this.duration.remaining}}
+ {{this.duration.remaining}} rounds
+ {{/if}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if effects.passive.length}}
+
+
+
+ {{#each effects.passive}}
+ -
+
+ {{this.name}}
+ {{this.parent.name}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+
diff --git a/templates/actor/character-biography.hbs b/templates/actor/character-biography.hbs
new file mode 100644
index 0000000..b18960e
--- /dev/null
+++ b/templates/actor/character-biography.hbs
@@ -0,0 +1,86 @@
+{{!-- Character Sheet Biography Tab --}}
+
+ {{!-- Character Details --}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{!-- Languages --}}
+
+
+
+ {{#each system.languages}}
+ {{this}}
+ {{else}}
+ {{localize "VAGABOND.NoLanguages"}}
+ {{/each}}
+
+
+
+ {{!-- Senses --}}
+
+
+ {{!-- Biography Text --}}
+
+
+ {{!-- Notes --}}
+
+
diff --git a/templates/actor/character-header.hbs b/templates/actor/character-header.hbs
new file mode 100644
index 0000000..2e9d321
--- /dev/null
+++ b/templates/actor/character-header.hbs
@@ -0,0 +1,118 @@
+{{!-- Character Sheet Header --}}
+
diff --git a/templates/actor/character-inventory.hbs b/templates/actor/character-inventory.hbs
new file mode 100644
index 0000000..95f6c0b
--- /dev/null
+++ b/templates/actor/character-inventory.hbs
@@ -0,0 +1,122 @@
+{{!-- Character Sheet Inventory Tab --}}
+
+ {{!-- Item Slots Header --}}
+
+
+ {{!-- Weapons Section --}}
+
+
+
+ {{#each items.weapons}}
+ -
+
+ {{this.name}}
+ {{this.system.damage}}
+ {{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}
+
+
+
+ {{else}}
+ - {{localize "VAGABOND.NoWeapons"}}
+ {{/each}}
+
+
+
+ {{!-- Armor Section --}}
+
+
+
+ {{#each items.armor}}
+ -
+
+ {{this.name}}
+ +{{this.system.armorValue}} Armor
+ {{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}
+
+
+
+ {{else}}
+ - {{localize "VAGABOND.NoArmor"}}
+ {{/each}}
+
+
+
+ {{!-- Equipment Section --}}
+
+
+
+ {{#each items.equipment}}
+ -
+
+ {{this.name}}
+ x{{this.system.quantity}}
+ {{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}
+
+
+ {{else}}
+ - {{localize "VAGABOND.NoEquipment"}}
+ {{/each}}
+
+
+
diff --git a/templates/actor/character-magic.hbs b/templates/actor/character-magic.hbs
new file mode 100644
index 0000000..e5244e1
--- /dev/null
+++ b/templates/actor/character-magic.hbs
@@ -0,0 +1,114 @@
+{{!-- Character Sheet Magic Tab --}}
+
+ {{!-- Mana Display --}}
+
+
+ {{!-- Known Spells Section --}}
+
+
+
+
+ {{#each items.spells}}
+ -
+
+
+ {{this.name}}
+ {{this.system.damageType}}
+
+
+ {{#if this.system.baseDamage}}
+
+
+ {{this.system.baseDamage}}
+
+ {{/if}}
+ {{#if this.system.baseEffect}}
+
+
+ Effect
+
+ {{/if}}
+
+
+
+
+
+
+ {{else}}
+ - {{localize "VAGABOND.NoSpells"}}
+ {{/each}}
+
+
+
+ {{!-- Spell Casting Reference --}}
+
+
{{localize "VAGABOND.SpellcastingReference"}}
+
+
+
{{localize "VAGABOND.Delivery"}}
+
+ - Touch/Remote/Imbue: 0 Mana
+ - Cube: +1 Mana
+ - Aura/Cone/Glyph/Line/Sphere: +2 Mana
+
+
+
+
{{localize "VAGABOND.Duration"}}
+
+ - Instant: Immediate effect
+ - Focus: Maintained (1 Mana/round vs unwilling)
+ - Continual: Lasts until conditions met
+
+
+
+
+
diff --git a/templates/actor/character-main.hbs b/templates/actor/character-main.hbs
new file mode 100644
index 0000000..cd4c025
--- /dev/null
+++ b/templates/actor/character-main.hbs
@@ -0,0 +1,110 @@
+{{!-- Character Sheet Main Tab --}}
+
+
+ {{!-- Left Column: Stats --}}
+
+
+
+ {{#each stats}}
+
+
+
+
+ {{/each}}
+
+
+
+ {{!-- Center Column: Saves & Skills --}}
+
+ {{!-- Saves Section --}}
+
+
+
+ {{#each saves}}
+
+ {{localize this.label}}
+ ({{this.stats}})
+ {{this.difficulty}}
+
+
+ {{/each}}
+
+
+
+ {{!-- Skills Section --}}
+
+
+
+ {{#each skills}}
+
+
+ {{localize this.label}}
+ ({{this.statAbbr}})
+ {{this.difficulty}}
+ {{#if this.hasCritBonus}}
+
+ {{this.critThreshold}}
+
+ {{/if}}
+
+
+ {{/each}}
+
+
+
+
+ {{!-- Right Column: Attacks & Weapons --}}
+
+ {{!-- Attack Skills --}}
+
+
+
+ {{#each attackSkills}}
+
+ {{localize this.label}}
+ ({{this.statAbbr}})
+ {{this.difficulty}}
+ {{#if this.hasCritBonus}}
+
+ {{this.critThreshold}}
+
+ {{/if}}
+
+ {{/each}}
+
+
+
+ {{!-- Equipped Weapons --}}
+
+
+
+ {{#each items.weapons}}
+ {{#if this.system.equipped}}
+ -
+ {{this.name}}
+ {{this.system.damage}}
+
+
+ {{/if}}
+ {{/each}}
+
+
+
+
+
diff --git a/templates/actor/npc-abilities.hbs b/templates/actor/npc-abilities.hbs
new file mode 100644
index 0000000..c5163f4
--- /dev/null
+++ b/templates/actor/npc-abilities.hbs
@@ -0,0 +1,45 @@
+{{!-- NPC Sheet Abilities Section --}}
+
diff --git a/templates/actor/npc-actions.hbs b/templates/actor/npc-actions.hbs
new file mode 100644
index 0000000..d66c3d9
--- /dev/null
+++ b/templates/actor/npc-actions.hbs
@@ -0,0 +1,80 @@
+{{!-- NPC Sheet Actions Section --}}
+
diff --git a/templates/actor/npc-header.hbs b/templates/actor/npc-header.hbs
new file mode 100644
index 0000000..3778e92
--- /dev/null
+++ b/templates/actor/npc-header.hbs
@@ -0,0 +1,61 @@
+{{!-- NPC Sheet Header --}}
+
diff --git a/templates/actor/npc-notes.hbs b/templates/actor/npc-notes.hbs
new file mode 100644
index 0000000..f9232bf
--- /dev/null
+++ b/templates/actor/npc-notes.hbs
@@ -0,0 +1,20 @@
+{{!-- NPC Sheet Notes Section --}}
+
+ {{!-- Loot --}}
+
+
+ {{!-- GM Notes --}}
+
+
diff --git a/templates/actor/npc-stats.hbs b/templates/actor/npc-stats.hbs
new file mode 100644
index 0000000..b0bd5e8
--- /dev/null
+++ b/templates/actor/npc-stats.hbs
@@ -0,0 +1,136 @@
+{{!-- NPC Sheet Stats Section --}}
+
+
+ {{!-- Zone --}}
+
+
+
+
{{zoneBehavior}}
+
+
+ {{!-- Speed --}}
+
+
+
+
+
+ {{!-- Size & Type --}}
+
+
+
+
+
+
+
+
+
+
+
+ {{!-- Appearing --}}
+
+
+
+
+
+
+ {{!-- Senses --}}
+ {{#if hasSenses}}
+
+
+ {{#if senses.darksight}}
+ {{localize "VAGABOND.Darksight"}}
+ {{/if}}
+ {{#if senses.blindsight}}
+ {{localize "VAGABOND.Blindsight"}} {{senses.blindsight}} ft
+ {{/if}}
+ {{#if senses.tremorsense}}
+ {{localize "VAGABOND.Tremorsense"}} {{senses.tremorsense}} ft
+ {{/if}}
+
+ {{/if}}
+
+ {{!-- Damage Modifiers --}}
+ {{#if hasDamageModifiers}}
+
+ {{#if immunities.length}}
+
+
+
+ {{#each immunities}}
+ {{this}}
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if resistances.length}}
+
+
+
+ {{#each resistances}}
+ {{this}}
+ {{/each}}
+
+
+ {{/if}}
+
+ {{#if weaknesses.length}}
+
+
+
+ {{#each weaknesses}}
+ {{this}}
+ {{/each}}
+
+
+ {{/if}}
+
+ {{/if}}
+
diff --git a/templates/actor/parts/tabs.hbs b/templates/actor/parts/tabs.hbs
new file mode 100644
index 0000000..752c3d8
--- /dev/null
+++ b/templates/actor/parts/tabs.hbs
@@ -0,0 +1,13 @@
+{{!-- Sheet Tabs Navigation --}}
+{{#if tabs.length}}
+
+{{/if}}