Implement character sheet foundation with ApplicationV2

Add actor sheet implementation using Foundry VTT v13 ApplicationV2 API:
- Base actor sheet class with tab navigation, drag-drop, scroll preservation
- Character sheet with header, main tab (stats, saves, skills, attacks)
- NPC sheet structure (templates only, styling pending)
- Resource bars with fill effect and backdrop pills for legibility
- Responsive layout using CSS Container Queries
- Fix for ApplicationV2 tab switching (cleanup stale parts)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-14 16:43:28 -06:00
parent f6948dc7d6
commit 8e097c9b2d
22 changed files with 3328 additions and 75 deletions

View File

@ -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."
}
]
},

View File

@ -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}?"
}

View File

@ -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";

View File

@ -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 });
}
}

View File

@ -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");
}
}
}

279
module/sheets/npc-sheet.mjs Normal file
View File

@ -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 = `
<div class="vagabond npc-action">
<h3>${action.name}</h3>
${action.description ? `<p class="description">${action.description}</p>` : ""}
<p class="damage">
<strong>Damage:</strong> [[/r ${action.damage}]] ${action.damageType}
</p>
${action.range ? `<p class="range"><strong>Range:</strong> ${action.range}</p>` : ""}
</div>
`;
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 });
}
}

View File

@ -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();
});
/* -------------------------------------------- */

View File

@ -100,3 +100,6 @@ $z-fixed: 300;
$z-modal-backdrop: 400;
$z-modal: 500;
$z-tooltip: 600;
// Breakpoints for responsive layouts
$breakpoint-narrow: 700px;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,121 @@
{{!-- Character Sheet Abilities Tab --}}
<section class="sheet-body tab-content abilities-tab">
{{!-- Ancestry Section --}}
{{#if ancestry}}
<div class="abilities-section ancestry">
<h2 class="section-header">{{localize "VAGABOND.Ancestry"}}</h2>
<div class="ancestry-block" data-item-id="{{ancestry.id}}">
<div class="ancestry-header">
<img class="ancestry-img" src="{{ancestry.img}}" alt="{{ancestry.name}}" />
<div class="ancestry-info">
<span class="ancestry-name" data-action="itemEdit">{{ancestry.name}}</span>
<span class="ancestry-being-type">{{ancestry.system.beingType}}</span>
<span class="ancestry-size">{{ancestry.system.size}}</span>
</div>
</div>
{{#if ancestry.system.traits.length}}
<div class="ancestry-traits">
{{#each ancestry.system.traits}}
<div class="trait">
<strong>{{this.name}}:</strong> {{{this.description}}}
</div>
{{/each}}
</div>
{{/if}}
</div>
</div>
{{/if}}
{{!-- Class Features Section --}}
<div class="abilities-section features">
<h2 class="section-header">{{localize "VAGABOND.Features"}}</h2>
<ul class="ability-list">
{{#each items.features}}
<li class="ability-item" data-item-id="{{this.id}}">
<img class="ability-img" src="{{this.img}}" alt="{{this.name}}" />
<div class="ability-info">
<span class="ability-name" data-action="itemEdit">{{this.name}}</span>
{{#if this.system.sourceClass}}
<span class="ability-source">{{this.system.sourceClass}} Lv{{this.system.levelGained}}</span>
{{/if}}
</div>
<div class="ability-description">{{{this.system.description}}}</div>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</li>
{{else}}
<li class="ability-item empty">{{localize "VAGABOND.NoFeatures"}}</li>
{{/each}}
</ul>
</div>
{{!-- Perks Section --}}
<div class="abilities-section perks">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Perks"}}</h2>
<button type="button" class="item-create" data-action="itemCreate" data-type="perk"
data-tooltip="{{localize 'VAGABOND.CreatePerk'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="ability-list">
{{#each items.perks}}
<li class="ability-item" data-item-id="{{this.id}}">
<img class="ability-img" src="{{this.img}}" alt="{{this.name}}" />
<div class="ability-info">
<span class="ability-name" data-action="itemEdit">{{this.name}}</span>
{{#if this.system.prerequisites}}
<span class="ability-prereqs">{{this.system.prerequisites}}</span>
{{/if}}
</div>
<div class="ability-description">{{{this.system.description}}}</div>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</li>
{{else}}
<li class="ability-item empty">{{localize "VAGABOND.NoPerks"}}</li>
{{/each}}
</ul>
</div>
{{!-- Active Effects Section --}}
<div class="abilities-section effects">
<h2 class="section-header">{{localize "VAGABOND.ActiveEffects"}}</h2>
{{#if effects.temporary.length}}
<div class="effects-group temporary">
<h3 class="subsection-header">{{localize "VAGABOND.TemporaryEffects"}}</h3>
<ul class="effect-list">
{{#each effects.temporary}}
<li class="effect-item">
<img class="effect-icon" src="{{this.icon}}" alt="{{this.name}}" />
<span class="effect-name">{{this.name}}</span>
{{#if this.duration.remaining}}
<span class="effect-duration">{{this.duration.remaining}} rounds</span>
{{/if}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if effects.passive.length}}
<div class="effects-group passive">
<h3 class="subsection-header">{{localize "VAGABOND.PassiveEffects"}}</h3>
<ul class="effect-list">
{{#each effects.passive}}
<li class="effect-item">
<img class="effect-icon" src="{{this.icon}}" alt="{{this.name}}" />
<span class="effect-name">{{this.name}}</span>
<span class="effect-source">{{this.parent.name}}</span>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</section>

View File

@ -0,0 +1,86 @@
{{!-- Character Sheet Biography Tab --}}
<section class="sheet-body tab-content biography-tab">
{{!-- Character Details --}}
<div class="biography-section details">
<h2 class="section-header">{{localize "VAGABOND.CharacterDetails"}}</h2>
<div class="details-grid">
<div class="detail-field">
<label>{{localize "VAGABOND.Size"}}</label>
<select name="system.details.size">
{{#each sizeOptions}}
<option value="{{@key}}" {{#if (eq @key ../details.size)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
<div class="detail-field">
<label>{{localize "VAGABOND.BeingType"}}</label>
<select name="system.details.beingType">
{{#each beingTypeOptions}}
<option value="{{@key}}" {{#if (eq @key ../details.beingType)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
</div>
</div>
{{!-- Languages --}}
<div class="biography-section languages">
<h2 class="section-header">{{localize "VAGABOND.Languages"}}</h2>
<div class="languages-list">
{{#each system.languages}}
<span class="language-tag">{{this}}</span>
{{else}}
<span class="no-languages">{{localize "VAGABOND.NoLanguages"}}</span>
{{/each}}
</div>
</div>
{{!-- Senses --}}
<div class="biography-section senses">
<h2 class="section-header">{{localize "VAGABOND.Senses"}}</h2>
<div class="senses-grid">
<div class="sense-field">
<label>
<input type="checkbox" name="system.senses.darksight"
{{#if system.senses.darksight}}checked{{/if}} />
{{localize "VAGABOND.Darksight"}}
</label>
</div>
<div class="sense-field">
<label>{{localize "VAGABOND.Blindsight"}}</label>
<input type="number" name="system.senses.blindsight"
value="{{system.senses.blindsight}}" min="0" />
<span class="unit">ft</span>
</div>
<div class="sense-field">
<label>{{localize "VAGABOND.Tremorsense"}}</label>
<input type="number" name="system.senses.tremorsense"
value="{{system.senses.tremorsense}}" min="0" />
<span class="unit">ft</span>
</div>
</div>
</div>
{{!-- Biography Text --}}
<div class="biography-section biography-text">
<h2 class="section-header">{{localize "VAGABOND.Biography"}}</h2>
<div class="editor-container">
<textarea class="biography-editor" name="system.biography"
placeholder="{{localize 'VAGABOND.BiographyPlaceholder'}}">{{system.biography}}</textarea>
</div>
</div>
{{!-- Notes --}}
<div class="biography-section notes">
<h2 class="section-header">{{localize "VAGABOND.Notes"}}</h2>
<div class="editor-container">
<textarea class="notes-editor" name="system.notes"
placeholder="{{localize 'VAGABOND.NotesPlaceholder'}}">{{system.notes}}</textarea>
</div>
</div>
</section>

View File

@ -0,0 +1,118 @@
{{!-- Character Sheet Header --}}
<header class="sheet-header">
<div class="header-left">
<img class="profile-img" src="{{actor.img}}" alt="{{actor.name}}"
data-edit="img" data-tooltip="VAGABOND.ChangePortrait" />
</div>
<div class="header-center">
<h1 class="actor-name">
<input type="text" name="name" value="{{actor.name}}" placeholder="{{localize 'VAGABOND.CharacterName'}}" />
</h1>
<div class="header-fields">
<div class="header-field">
<label>{{localize "VAGABOND.Ancestry"}}</label>
<span class="ancestry-name">{{ancestryName}}</span>
</div>
<div class="header-field">
<label>{{localize "VAGABOND.Class"}}</label>
<span class="class-name">{{className}}</span>
</div>
<div class="header-field">
<label>{{localize "VAGABOND.Level"}}</label>
<input type="number" name="system.level" value="{{system.level}}" min="1" max="10" />
</div>
<div class="header-field">
<label>{{localize "VAGABOND.XP"}}</label>
<input type="number" name="system.xp" value="{{system.xp}}" min="0" />
</div>
</div>
</div>
<div class="header-right">
<div class="resource-bars">
{{!-- HP Bar --}}
<div class="resource-bar hp {{resources.hp.color}}">
<label>{{localize resources.hp.label}}</label>
<div class="bar-row">
<div class="bar-container">
<div class="bar-fill" style="width: {{resources.hp.percent}}%"></div>
<div class="bar-values">
<input type="number" name="system.resources.hp.value" value="{{resources.hp.value}}" min="0" />
<span class="separator">/</span>
<span class="max">{{resources.hp.max}}</span>
</div>
</div>
<div class="resource-buttons">
<button type="button" data-action="modifyResource" data-resource="hp" data-delta="-1"
data-tooltip="{{localize 'VAGABOND.Damage'}}">
<i class="fa-solid fa-minus"></i>
</button>
<button type="button" data-action="modifyResource" data-resource="hp" data-delta="1"
data-tooltip="{{localize 'VAGABOND.Heal'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
</div>
</div>
{{!-- Mana Bar --}}
{{#if resources.mana.max}}
<div class="resource-bar mana">
<label>{{localize resources.mana.label}}</label>
<div class="bar-row">
<div class="bar-container">
<div class="bar-fill" style="width: {{resources.mana.percent}}%"></div>
<div class="bar-values">
<input type="number" name="system.resources.mana.value" value="{{resources.mana.value}}" min="0" />
<span class="separator">/</span>
<span class="max">{{resources.mana.max}}</span>
</div>
</div>
<div class="resource-buttons">
<button type="button" data-action="modifyResource" data-resource="mana" data-delta="-1">
<i class="fa-solid fa-minus"></i>
</button>
<button type="button" data-action="modifyResource" data-resource="mana" data-delta="1">
<i class="fa-solid fa-plus"></i>
</button>
</div>
</div>
</div>
{{/if}}
</div>
<div class="secondary-resources">
<div class="resource luck">
<label>{{localize resources.luck.label}}</label>
<input type="number" name="system.resources.luck.value"
value="{{resources.luck.value}}" min="0" max="{{resources.luck.max}}" />
<span class="separator">/</span>
<span class="max">{{resources.luck.max}}</span>
</div>
<div class="resource fatigue {{#if resources.fatigue.isDangerous}}danger{{/if}}">
<label>{{localize resources.fatigue.label}}</label>
<input type="number" name="system.resources.fatigue.value"
value="{{resources.fatigue.value}}" min="0" max="5" />
<span class="separator">/</span>
<span class="max">5</span>
</div>
<div class="resource armor">
<label>{{localize "VAGABOND.Armor"}}</label>
<span class="value">{{system.armor}}</span>
</div>
<div class="resource speed">
<label>{{localize "VAGABOND.Speed"}}</label>
<span class="value">{{speed.walk}}</span>
<span class="unit">ft</span>
</div>
</div>
</div>
</header>

View File

@ -0,0 +1,122 @@
{{!-- Character Sheet Inventory Tab --}}
<section class="sheet-body tab-content inventory-tab">
{{!-- Item Slots Header --}}
<div class="inventory-header">
<div class="item-slots {{#if itemSlots.overburdened}}overburdened{{/if}}">
<span class="slots-label">{{localize "VAGABOND.ItemSlots"}}:</span>
<span class="slots-used">{{itemSlots.used}}</span>
<span class="slots-separator">/</span>
<span class="slots-max">{{itemSlots.max}}</span>
{{#if itemSlots.overburdened}}
<span class="overburdened-warning">
<i class="fa-solid fa-exclamation-triangle"></i>
{{localize "VAGABOND.Overburdened"}}
</span>
{{/if}}
</div>
<div class="wealth">
<div class="currency gold">
<input type="number" name="system.wealth.gold" value="{{wealth.gold}}" min="0" />
<label>{{localize "VAGABOND.Gold"}}</label>
</div>
<div class="currency silver">
<input type="number" name="system.wealth.silver" value="{{wealth.silver}}" min="0" />
<label>{{localize "VAGABOND.Silver"}}</label>
</div>
<div class="currency copper">
<input type="number" name="system.wealth.copper" value="{{wealth.copper}}" min="0" />
<label>{{localize "VAGABOND.Copper"}}</label>
</div>
</div>
</div>
{{!-- Weapons Section --}}
<div class="inventory-section weapons">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Weapons"}}</h2>
<button type="button" class="item-create" data-action="itemCreate" data-type="weapon"
data-tooltip="{{localize 'VAGABOND.CreateWeapon'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="item-list">
{{#each items.weapons}}
<li class="item-row" data-item-id="{{this.id}}">
<img class="item-img" src="{{this.img}}" alt="{{this.name}}" />
<span class="item-name" data-action="itemEdit">{{this.name}}</span>
<span class="item-damage">{{this.system.damage}}</span>
<span class="item-slots">{{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}</span>
<button type="button" class="item-equipped {{#if this.system.equipped}}active{{/if}}"
data-action="itemToggleEquipped" data-tooltip="{{localize 'VAGABOND.ToggleEquipped'}}">
<i class="fa-solid fa-shield-halved"></i>
</button>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</li>
{{else}}
<li class="item-row empty">{{localize "VAGABOND.NoWeapons"}}</li>
{{/each}}
</ul>
</div>
{{!-- Armor Section --}}
<div class="inventory-section armor">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Armor"}}</h2>
<button type="button" class="item-create" data-action="itemCreate" data-type="armor"
data-tooltip="{{localize 'VAGABOND.CreateArmor'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="item-list">
{{#each items.armor}}
<li class="item-row" data-item-id="{{this.id}}">
<img class="item-img" src="{{this.img}}" alt="{{this.name}}" />
<span class="item-name" data-action="itemEdit">{{this.name}}</span>
<span class="item-armor-value">+{{this.system.armorValue}} Armor</span>
<span class="item-slots">{{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}</span>
<button type="button" class="item-equipped {{#if this.system.equipped}}active{{/if}}"
data-action="itemToggleEquipped" data-tooltip="{{localize 'VAGABOND.ToggleEquipped'}}">
<i class="fa-solid fa-shield-halved"></i>
</button>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</li>
{{else}}
<li class="item-row empty">{{localize "VAGABOND.NoArmor"}}</li>
{{/each}}
</ul>
</div>
{{!-- Equipment Section --}}
<div class="inventory-section equipment">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Equipment"}}</h2>
<button type="button" class="item-create" data-action="itemCreate" data-type="equipment"
data-tooltip="{{localize 'VAGABOND.CreateEquipment'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="item-list">
{{#each items.equipment}}
<li class="item-row" data-item-id="{{this.id}}">
<img class="item-img" src="{{this.img}}" alt="{{this.name}}" />
<span class="item-name" data-action="itemEdit">{{this.name}}</span>
<span class="item-quantity">x{{this.system.quantity}}</span>
<span class="item-slots">{{this.system.slots}} slot{{#if (gt this.system.slots 1)}}s{{/if}}</span>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</li>
{{else}}
<li class="item-row empty">{{localize "VAGABOND.NoEquipment"}}</li>
{{/each}}
</ul>
</div>
</section>

View File

@ -0,0 +1,114 @@
{{!-- Character Sheet Magic Tab --}}
<section class="sheet-body tab-content magic-tab">
{{!-- Mana Display --}}
<div class="magic-header">
<div class="mana-display">
<label>{{localize "VAGABOND.Mana"}}</label>
<div class="mana-values">
<input type="number" name="system.resources.mana.value"
value="{{resources.mana.value}}" min="0" max="{{resources.mana.max}}" />
<span class="separator">/</span>
<span class="max">{{resources.mana.max}}</span>
</div>
{{#if resources.mana.castingMax}}
<div class="casting-max">
<span class="label">{{localize "VAGABOND.CastingMax"}}:</span>
<span class="value">{{resources.mana.castingMax}}</span>
</div>
{{/if}}
</div>
{{!-- Focus Status --}}
{{#if hasFocus}}
<div class="focus-display">
<h3>{{localize "VAGABOND.FocusActive"}}</h3>
<ul class="focus-list">
{{#each focus.active}}
<li class="focus-item">
<span class="focus-spell">{{this.spellName}}</span>
{{#if this.target}}
<span class="focus-target">on {{this.target}}</span>
{{/if}}
{{#if this.manaCostPerRound}}
<span class="focus-cost">{{this.manaCostPerRound}} Mana/round</span>
{{/if}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
{{!-- Known Spells Section --}}
<div class="spells-section">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.KnownSpells"}}</h2>
<button type="button" class="item-create" data-action="itemCreate" data-type="spell"
data-tooltip="{{localize 'VAGABOND.CreateSpell'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="spell-list">
{{#each items.spells}}
<li class="spell-item" data-item-id="{{this.id}}">
<img class="spell-img" src="{{this.img}}" alt="{{this.name}}" />
<div class="spell-info">
<span class="spell-name" data-action="itemEdit">{{this.name}}</span>
<span class="spell-type">{{this.system.damageType}}</span>
</div>
<div class="spell-details">
{{#if this.system.baseDamage}}
<span class="spell-damage">
<i class="fa-solid fa-burst"></i>
{{this.system.baseDamage}}
</span>
{{/if}}
{{#if this.system.baseEffect}}
<span class="spell-effect" data-tooltip="{{this.system.baseEffect}}">
<i class="fa-solid fa-wand-sparkles"></i>
Effect
</span>
{{/if}}
</div>
<div class="spell-actions">
<button type="button" class="spell-cast" data-action="castSpell" data-spell-id="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.CastSpell'}}">
<i class="fa-solid fa-magic"></i>
{{localize "VAGABOND.Cast"}}
</button>
<button type="button" class="item-delete" data-action="itemDelete"
data-tooltip="{{localize 'VAGABOND.DeleteItem'}}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</li>
{{else}}
<li class="spell-item empty">{{localize "VAGABOND.NoSpells"}}</li>
{{/each}}
</ul>
</div>
{{!-- Spell Casting Reference --}}
<div class="casting-reference">
<h3>{{localize "VAGABOND.SpellcastingReference"}}</h3>
<div class="reference-grid">
<div class="reference-section delivery">
<h4>{{localize "VAGABOND.Delivery"}}</h4>
<ul>
<li><strong>Touch/Remote/Imbue:</strong> 0 Mana</li>
<li><strong>Cube:</strong> +1 Mana</li>
<li><strong>Aura/Cone/Glyph/Line/Sphere:</strong> +2 Mana</li>
</ul>
</div>
<div class="reference-section duration">
<h4>{{localize "VAGABOND.Duration"}}</h4>
<ul>
<li><strong>Instant:</strong> Immediate effect</li>
<li><strong>Focus:</strong> Maintained (1 Mana/round vs unwilling)</li>
<li><strong>Continual:</strong> Lasts until conditions met</li>
</ul>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,110 @@
{{!-- Character Sheet Main Tab --}}
<section class="sheet-body tab-content main-tab">
<div class="main-grid">
{{!-- Left Column: Stats --}}
<div class="stats-column">
<h2 class="section-header">{{localize "VAGABOND.Stats"}}</h2>
<div class="stats-grid">
{{#each stats}}
<div class="stat-block {{this.color}}">
<label class="stat-label" data-tooltip="{{localize this.label}}">{{this.abbr}}</label>
<input type="number" class="stat-value" name="{{this.path}}"
value="{{this.value}}" min="1" max="10" />
</div>
{{/each}}
</div>
</div>
{{!-- Center Column: Saves & Skills --}}
<div class="center-column">
{{!-- Saves Section --}}
<div class="saves-section">
<h2 class="section-header">{{localize "VAGABOND.Saves"}}</h2>
<div class="saves-list">
{{#each saves}}
<div class="save-row" data-action="rollSave" data-save="{{this.id}}">
<span class="save-label">{{localize this.label}}</span>
<span class="save-stats">({{this.stats}})</span>
<span class="save-difficulty">{{this.difficulty}}</span>
<i class="fa-solid fa-dice-d20 roll-icon"></i>
</div>
{{/each}}
</div>
</div>
{{!-- Skills Section --}}
<div class="skills-section">
<h2 class="section-header">{{localize "VAGABOND.Skills"}}</h2>
<div class="skills-grid">
{{#each skills}}
<div class="skill-row {{#if this.trained}}trained{{/if}}" data-skill="{{this.id}}">
<button type="button" class="skill-trained-toggle"
data-action="toggleTrained" data-skill="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.ToggleTrained'}}">
{{#if this.trained}}
<i class="fa-solid fa-check"></i>
{{else}}
<i class="fa-regular fa-circle"></i>
{{/if}}
</button>
<span class="skill-name">{{localize this.label}}</span>
<span class="skill-stat">({{this.statAbbr}})</span>
<span class="skill-difficulty">{{this.difficulty}}</span>
{{#if this.hasCritBonus}}
<span class="skill-crit" data-tooltip="{{localize 'VAGABOND.CritThreshold'}}">
<i class="fa-solid fa-star"></i>{{this.critThreshold}}
</span>
{{/if}}
<button type="button" class="skill-roll-btn"
data-action="rollSkill" data-skill="{{this.id}}"
data-tooltip="{{localize 'VAGABOND.RollSkill'}}">
<i class="fa-solid fa-dice-d20"></i>
</button>
</div>
{{/each}}
</div>
</div>
</div>
{{!-- Right Column: Attacks & Weapons --}}
<div class="attacks-column">
{{!-- Attack Skills --}}
<div class="attack-skills-section">
<h2 class="section-header">{{localize "VAGABOND.Attacks"}}</h2>
<div class="attack-skills-grid">
{{#each attackSkills}}
<div class="attack-skill-row" data-action="rollAttack" data-attack-skill="{{this.id}}">
<span class="attack-name">{{localize this.label}}</span>
<span class="attack-stat">({{this.statAbbr}})</span>
<span class="attack-difficulty">{{this.difficulty}}</span>
{{#if this.hasCritBonus}}
<span class="attack-crit">
<i class="fa-solid fa-star"></i>{{this.critThreshold}}
</span>
{{/if}}
</div>
{{/each}}
</div>
</div>
{{!-- Equipped Weapons --}}
<div class="equipped-weapons-section">
<h3 class="subsection-header">{{localize "VAGABOND.EquippedWeapons"}}</h3>
<ul class="weapon-list">
{{#each items.weapons}}
{{#if this.system.equipped}}
<li class="weapon-item" data-item-id="{{this.id}}">
<span class="weapon-name" data-action="itemEdit">{{this.name}}</span>
<span class="weapon-damage">{{this.system.damage}}</span>
<button type="button" class="weapon-roll" data-action="rollAttack"
data-weapon-id="{{this.id}}" data-tooltip="{{localize 'VAGABOND.RollAttack'}}">
<i class="fa-solid fa-dice-d20"></i>
</button>
</li>
{{/if}}
{{/each}}
</ul>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,45 @@
{{!-- NPC Sheet Abilities Section --}}
<section class="npc-abilities">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Abilities"}}</h2>
<button type="button" class="ability-add" data-action="addAbility"
data-tooltip="{{localize 'VAGABOND.AddAbility'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="ability-list">
{{#each abilities}}
<li class="ability-item" data-ability-index="{{this.index}}">
<div class="ability-header">
<input type="text" class="ability-name" name="system.abilities.{{this.index}}.name"
value="{{this.name}}" placeholder="{{localize 'VAGABOND.AbilityName'}}" />
<div class="ability-controls">
<label class="ability-passive">
<input type="checkbox" name="system.abilities.{{this.index}}.passive"
{{#if this.passive}}checked{{/if}} />
{{localize "VAGABOND.Passive"}}
</label>
<button type="button" class="ability-delete" data-action="deleteAbility"
data-ability-index="{{this.index}}" data-tooltip="{{localize 'VAGABOND.DeleteAbility'}}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
<div class="ability-description">
<textarea name="system.abilities.{{this.index}}.description"
placeholder="{{localize 'VAGABOND.AbilityDescription'}}">{{this.description}}</textarea>
</div>
</li>
{{else}}
<li class="ability-item empty">
<p>{{localize "VAGABOND.NoAbilities"}}</p>
<button type="button" data-action="addAbility">
<i class="fa-solid fa-plus"></i>
{{localize "VAGABOND.AddAbility"}}
</button>
</li>
{{/each}}
</ul>
</section>

View File

@ -0,0 +1,80 @@
{{!-- NPC Sheet Actions Section --}}
<section class="npc-actions">
<div class="section-header-row">
<h2 class="section-header">{{localize "VAGABOND.Actions"}}</h2>
<button type="button" class="action-add" data-action="addAction"
data-tooltip="{{localize 'VAGABOND.AddAction'}}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<ul class="action-list">
{{#each actions}}
<li class="action-item" data-action-index="{{this.index}}">
<div class="action-header">
<input type="text" class="action-name" name="system.actions.{{this.index}}.name"
value="{{this.name}}" placeholder="{{localize 'VAGABOND.ActionName'}}" />
<div class="action-controls">
<button type="button" class="action-roll" data-action="rollAction"
data-action-index="{{this.index}}" data-tooltip="{{localize 'VAGABOND.RollAction'}}">
<i class="fa-solid fa-dice-d20"></i>
</button>
<button type="button" class="action-delete" data-action="deleteAction"
data-action-index="{{this.index}}" data-tooltip="{{localize 'VAGABOND.DeleteAction'}}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
<div class="action-details">
<div class="action-field attack-type">
<label>{{localize "VAGABOND.AttackType"}}</label>
<select name="system.actions.{{this.index}}.attackType">
{{#each ../attackTypeOptions}}
<option value="{{@key}}" {{#if (eq @key ../this.attackType)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
<div class="action-field damage">
<label>{{localize "VAGABOND.Damage"}}</label>
<input type="text" name="system.actions.{{this.index}}.damage"
value="{{this.damage}}" placeholder="1d6" />
</div>
<div class="action-field damage-type">
<label>{{localize "VAGABOND.DamageType"}}</label>
<select name="system.actions.{{this.index}}.damageType">
{{#each ../damageTypeOptions}}
<option value="{{@key}}" {{#if (eq @key ../this.damageType)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
<div class="action-field range">
<label>{{localize "VAGABOND.Range"}}</label>
<input type="text" name="system.actions.{{this.index}}.range"
value="{{this.range}}" placeholder="60 ft" />
</div>
</div>
<div class="action-description">
<textarea name="system.actions.{{this.index}}.description"
placeholder="{{localize 'VAGABOND.ActionDescription'}}">{{this.description}}</textarea>
</div>
</li>
{{else}}
<li class="action-item empty">
<p>{{localize "VAGABOND.NoActions"}}</p>
<button type="button" data-action="addAction">
<i class="fa-solid fa-plus"></i>
{{localize "VAGABOND.AddAction"}}
</button>
</li>
{{/each}}
</ul>
</section>

View File

@ -0,0 +1,61 @@
{{!-- NPC Sheet Header --}}
<header class="sheet-header npc-header">
<div class="header-portrait">
<img class="profile-img" src="{{actor.img}}" alt="{{actor.name}}"
data-edit="img" data-tooltip="VAGABOND.ChangePortrait" />
</div>
<div class="header-info">
<h1 class="actor-name">
<input type="text" name="name" value="{{actor.name}}" placeholder="{{localize 'VAGABOND.NPCName'}}" />
</h1>
<div class="header-stats">
<div class="stat-box hd">
<label>HD</label>
<input type="number" name="system.hd" value="{{hd}}" min="0" />
</div>
<div class="stat-box tl">
<label>TL</label>
<input type="number" name="system.tl" value="{{tl}}" min="0" step="0.1" />
</div>
<div class="stat-box armor">
<label>{{localize "VAGABOND.Armor"}}</label>
<input type="number" name="system.armor" value="{{armor}}" min="0" />
</div>
<div class="stat-box morale">
<label>{{localize "VAGABOND.Morale"}}</label>
<input type="number" name="system.morale" value="{{morale}}" min="2" max="12" />
<button type="button" class="morale-roll" data-action="rollMorale"
data-tooltip="{{localize 'VAGABOND.RollMorale'}}"
{{#if moraleStatus.broken}}disabled{{/if}}>
<i class="fa-solid fa-dice"></i>
</button>
</div>
</div>
</div>
<div class="header-hp">
<div class="hp-bar {{#if hp.isDead}}dead{{else if hp.isHalf}}half{{/if}}">
<label>HP</label>
<div class="bar-container">
<div class="bar-fill" style="width: {{hp.percent}}%"></div>
<div class="bar-values">
<input type="number" name="system.hp.value" value="{{hp.value}}" min="0" />
<span class="separator">/</span>
<input type="number" name="system.hp.max" value="{{hp.max}}" min="1" />
</div>
</div>
</div>
{{#if moraleStatus.broken}}
<div class="morale-broken">
<i class="fa-solid fa-flag"></i>
{{localize "VAGABOND.MoraleBroken"}}
</div>
{{/if}}
</div>
</header>

View File

@ -0,0 +1,20 @@
{{!-- NPC Sheet Notes Section --}}
<section class="npc-notes">
{{!-- Loot --}}
<div class="notes-section loot">
<h2 class="section-header">{{localize "VAGABOND.Loot"}}</h2>
<div class="editor-container">
<textarea name="system.loot"
placeholder="{{localize 'VAGABOND.LootPlaceholder'}}">{{loot}}</textarea>
</div>
</div>
{{!-- GM Notes --}}
<div class="notes-section gm-notes">
<h2 class="section-header">{{localize "VAGABOND.GMNotes"}}</h2>
<div class="editor-container">
<textarea name="system.gmNotes"
placeholder="{{localize 'VAGABOND.GMNotesPlaceholder'}}">{{gmNotes}}</textarea>
</div>
</div>
</section>

View File

@ -0,0 +1,136 @@
{{!-- NPC Sheet Stats Section --}}
<section class="npc-stats">
<div class="stats-row">
{{!-- Zone --}}
<div class="stat-group zone">
<label>{{localize "VAGABOND.Zone"}}</label>
<select name="system.zone">
{{#each zoneOptions}}
<option value="{{@key}}" {{#if (eq @key ../zone)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
<p class="zone-hint">{{zoneBehavior}}</p>
</div>
{{!-- Speed --}}
<div class="stat-group speed">
<label>{{localize "VAGABOND.Speed"}}</label>
<div class="speed-values">
<div class="speed-item walk">
<i class="fa-solid fa-shoe-prints"></i>
<input type="number" name="system.speed.value" value="{{speed.walk}}" min="0" />
<span class="unit">ft</span>
</div>
{{#if speed.hasSpecialMovement}}
{{#if speed.fly}}
<div class="speed-item fly">
<i class="fa-solid fa-feather"></i>
<input type="number" name="system.speed.fly" value="{{speed.fly}}" min="0" />
<span class="unit">ft</span>
</div>
{{/if}}
{{#if speed.swim}}
<div class="speed-item swim">
<i class="fa-solid fa-water"></i>
<input type="number" name="system.speed.swim" value="{{speed.swim}}" min="0" />
<span class="unit">ft</span>
</div>
{{/if}}
{{#if speed.climb}}
<div class="speed-item climb">
<i class="fa-solid fa-mountain"></i>
<input type="number" name="system.speed.climb" value="{{speed.climb}}" min="0" />
<span class="unit">ft</span>
</div>
{{/if}}
{{/if}}
</div>
</div>
{{!-- Size & Type --}}
<div class="stat-group type">
<div class="type-field">
<label>{{localize "VAGABOND.Size"}}</label>
<select name="system.size">
{{#each sizeOptions}}
<option value="{{@key}}" {{#if (eq @key ../size)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
<div class="type-field">
<label>{{localize "VAGABOND.BeingType"}}</label>
<select name="system.beingType">
{{#each beingTypeOptions}}
<option value="{{@key}}" {{#if (eq @key ../beingType)}}selected{{/if}}>
{{localize this}}
</option>
{{/each}}
</select>
</div>
</div>
{{!-- Appearing --}}
<div class="stat-group appearing">
<label>{{localize "VAGABOND.Appearing"}}</label>
<input type="text" name="system.appearing" value="{{appearing}}" placeholder="1d6" />
</div>
</div>
{{!-- Senses --}}
{{#if hasSenses}}
<div class="senses-row">
<label>{{localize "VAGABOND.Senses"}}:</label>
{{#if senses.darksight}}
<span class="sense-tag">{{localize "VAGABOND.Darksight"}}</span>
{{/if}}
{{#if senses.blindsight}}
<span class="sense-tag">{{localize "VAGABOND.Blindsight"}} {{senses.blindsight}} ft</span>
{{/if}}
{{#if senses.tremorsense}}
<span class="sense-tag">{{localize "VAGABOND.Tremorsense"}} {{senses.tremorsense}} ft</span>
{{/if}}
</div>
{{/if}}
{{!-- Damage Modifiers --}}
{{#if hasDamageModifiers}}
<div class="damage-modifiers">
{{#if immunities.length}}
<div class="modifier-row immunities">
<label>{{localize "VAGABOND.Immunities"}}:</label>
<div class="modifier-tags">
{{#each immunities}}
<span class="modifier-tag immune">{{this}}</span>
{{/each}}
</div>
</div>
{{/if}}
{{#if resistances.length}}
<div class="modifier-row resistances">
<label>{{localize "VAGABOND.Resistances"}}:</label>
<div class="modifier-tags">
{{#each resistances}}
<span class="modifier-tag resist">{{this}}</span>
{{/each}}
</div>
</div>
{{/if}}
{{#if weaknesses.length}}
<div class="modifier-row weaknesses">
<label>{{localize "VAGABOND.Weaknesses"}}:</label>
<div class="modifier-tags">
{{#each weaknesses}}
<span class="modifier-tag weak">{{this}}</span>
{{/each}}
</div>
</div>
{{/if}}
</div>
{{/if}}
</section>

View File

@ -0,0 +1,13 @@
{{!-- Sheet Tabs Navigation --}}
{{#if tabs.length}}
<nav class="sheet-tabs">
{{#each tabs}}
<button type="button" class="tab-button {{this.cssClass}}"
data-action="changeTab" data-tab="{{this.id}}"
data-tooltip="{{localize this.label}}">
<i class="{{this.icon}}"></i>
<span class="tab-label">{{localize this.label}}</span>
</button>
{{/each}}
</nav>
{{/if}}