Chapitre 04

Sheets et ApplicationV2

Creez des fiches de personnage modernes avec la nouvelle API ApplicationV2 de FoundryVTT v13. Decouvrez le systeme de PARTS, les actions declaratives et le rendu partiel.

Intermediaire ~25 min de lecture

Introduction

Les Sheets sont les interfaces visuelles permettant aux joueurs d'interagir avec leurs personnages, objets et autres documents. Avec FoundryVTT v13, une nouvelle API appelee ApplicationV2 remplace l'ancienne API Application v1.

💡
Pourquoi ApplicationV2 ?

ApplicationV2 apporte des ameliorations majeures : rendu partiel (seules les parties modifiees sont rafraichies), suppression de la dependance jQuery, systeme d'actions declaratif, et meilleure separation des responsabilites.

Application v1 vs ApplicationV2

Voici les differences principales entre les deux API :

Aspect Application v1 (Deprecie) ApplicationV2 (Moderne v13)
Namespace Application, FormApplication foundry.applications.api.ApplicationV2
Rendu getData() + activateListeners() _prepareContext() + _onRender()
Templates Un template unique Systeme de PARTS (multiples templates)
Options static get defaultOptions() static DEFAULT_OPTIONS
Actions Event listeners jQuery manuels Systeme declaratif data-action
jQuery Dependance forte Sans jQuery natif
Reactivite Re-rendu complet Re-rendu partiel par PART

Exemple comparatif

Ancienne API (v1) - A eviter :

// Application v1 (ancienne API - DEPRECIE)
class OldSheet extends Application {
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "path/to/template.hbs",
      width: 500,
      height: 400
    });
  }
  
  getData() {
    return { data: this.object };
  }
  
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".my-button").click(this._onButtonClick.bind(this));
  }
}

Nouvelle API (v2) - Recommandee :

// ApplicationV2 (nouvelle API v13)
class ModernSheet extends foundry.applications.api.ApplicationV2 {
  static DEFAULT_OPTIONS = {
    position: { width: 500, height: 400 },
    actions: {
      myAction: ModernSheet.#onMyAction
    }
  };
  
  static PARTS = {
    main: { template: "path/to/template.hbs" }
  };
  
  async _prepareContext(options) {
    return { data: this.document };
  }
  
  static #onMyAction(event, target) {
    // Gestionnaire d'action (this = instance de la sheet)
    console.log("Action declenchee !");
  }
}

Structure d'une Sheet moderne

Une Sheet moderne herite typiquement de DocumentSheetV2 ou de ses specialisations comme ActorSheetV2 et ItemSheetV2.

Hierarchie des classes

// Hierarchie d'heritage pour les Sheets
foundry.applications.api.ApplicationV2
    |
    +-- HandlebarsApplicationMixin
    |   +-- ApplicationV2Mixin (systeme)
    |       +-- Application5e
    |
    +-- foundry.applications.api.DocumentSheetV2
        +-- foundry.applications.sheets.ActorSheetV2
        |   +-- BaseActorSheet (systeme)
        |       +-- CharacterActorSheet
        |       +-- NPCActorSheet
        |
        +-- foundry.applications.sheets.ItemSheetV2
            +-- ItemSheet5e (systeme)

Structure de base

// module/applications/actor/character-sheet.mjs
const { ActorSheetV2 } = foundry.applications.sheets;
const { HandlebarsApplicationMixin } = foundry.applications.api;

export default class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
  
  /** Configuration statique */
  static DEFAULT_OPTIONS = {
    classes: ["mon-systeme", "actor", "character"],
    position: { width: 600, height: 700 },
    window: {
      resizable: true,
      title: "MON_SYSTEME.Sheet.Character"
    },
    actions: {
      rollAttribute: CharacterSheet.#onRollAttribute
    },
    form: {
      submitOnChange: true,
      closeOnSubmit: false
    }
  };

  /** Templates modulaires */
  static PARTS = {
    header: {
      template: "systems/mon-systeme/templates/actor/header.hbs"
    },
    tabs: {
      template: "systems/mon-systeme/templates/actor/tabs.hbs"
    },
    attributes: {
      template: "systems/mon-systeme/templates/actor/attributes.hbs",
      scrollable: [""]
    },
    biography: {
      template: "systems/mon-systeme/templates/actor/biography.hbs",
      scrollable: [""]
    }
  };

  /** Preparation des donnees pour les templates */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.isEditable = this.isEditable;
    context.system = this.actor.system;
    return context;
  }

  /** Gestionnaire d'action statique */
  static async #onRollAttribute(event, target) {
    const attr = target.dataset.attribute;
    await this.actor.rollAttribute(attr);
  }
}

DEFAULT_OPTIONS

DEFAULT_OPTIONS est un objet statique qui definit la configuration par defaut de l'application.

static DEFAULT_OPTIONS = {
  // Identifiant unique (avec placeholder {id})
  id: "my-app-{id}",
  
  // Classes CSS appliquees a l'application
  classes: ["mon-systeme", "my-app"],
  
  // Tag HTML de l'application (defaut: "div")
  tag: "div",
  
  // Configuration de la fenetre
  window: {
    title: "Titre de la fenetre",
    icon: "fas fa-user",        // Icone FontAwesome
    resizable: true,            // Redimensionnable ?
    minimizable: true,          // Minimisable ?
    controls: [                 // Boutons dans l'en-tete
      {
        action: "configureToken",
        icon: "fa-solid fa-user-circle",
        label: "Token",
        ownership: "OWNER"
      }
    ]
  },
  
  // Position et dimensions
  position: {
    width: 600,
    height: 400,
    top: null,    // null = centre automatique
    left: null
  },
  
  // Actions declaratives
  actions: {
    save: MySheet.#onSave,
    delete: {
      handler: MySheet.#onDelete,
      buttons: [0, 2]  // Boutons souris acceptes (0=gauche, 2=droit)
    }
  },
  
  // Configuration du formulaire
  form: {
    handler: MySheet.#onSubmit,
    submitOnChange: true,     // Soumet a chaque modification
    closeOnSubmit: false      // Ferme apres soumission ?
  }
};
📝
Heritage des options

Les DEFAULT_OPTIONS sont automatiquement fusionnees avec celles de la classe parente. Vous n'avez besoin de specifier que ce que vous voulez modifier ou ajouter.

Systeme de PARTS

Le systeme de PARTS permet de diviser une application en sections independantes, chacune avec son propre template. Cela permet un rendu partiel : seules les parties modifiees sont re-rendues.

static PARTS = {
  // PART simple avec un template
  header: {
    template: "systems/mon-systeme/templates/actor/header.hbs"
  },
  
  // PART avec templates additionnels (partials)
  inventory: {
    template: "systems/mon-systeme/templates/actor/inventory.hbs",
    templates: [
      "systems/mon-systeme/templates/inventory/item-row.hbs",
      "systems/mon-systeme/templates/inventory/encumbrance.hbs"
    ],
    scrollable: [""]  // La PART entiere est scrollable
  },
  
  // PART avec conteneur specifique
  sidebar: {
    container: { 
      id: "main",
      classes: ["main-content"] 
    },
    template: "systems/mon-systeme/templates/actor/sidebar.hbs"
  },
  
  // PART conditionnelle
  spells: {
    container: { classes: ["tab-body"], id: "tabs" },
    template: "systems/mon-systeme/templates/actor/spells.hbs",
    scrollable: [""]
  }
};

Rendu partiel

Pour re-rendre uniquement certaines parties :

// Re-rendre uniquement l'inventaire
await this.render({ parts: ["inventory"] });

// Re-rendre plusieurs parties
await this.render({ parts: ["header", "attributes"] });

Configuration conditionnelle des PARTS

/** @inheritDoc */
_configureRenderParts(options) {
  const parts = super._configureRenderParts(options);
  
  // Retirer une PART conditionnellement
  if (!this.actor.system.hasSpellcasting) {
    delete parts.spells;
  }
  
  return parts;
}

_prepareContext()

La methode _prepareContext() remplace l'ancien getData(). Elle prepare les donnees qui seront passees aux templates.

/**
 * Prepare le contexte global partage par tous les templates.
 * @param {object} options - Options de rendu
 * @returns {Promise} - Contexte pour les templates
 */
async _prepareContext(options) {
  // Appeler la methode parente
  const context = await super._prepareContext(options);
  
  // Donnees de base
  context.document = this.document;
  context.system = this.document.system;
  context.source = this.document.system._source; // Donnees non-derivees
  
  // Permissions
  context.editable = this.isEditable;
  context.owner = this.document.isOwner;
  
  // Configuration systeme
  context.CONFIG = CONFIG.MON_SYSTEME;
  
  // Donnees enrichies (HTML)
  context.enriched = {
    biography: await TextEditor.enrichHTML(
      this.document.system.biography,
      { secrets: this.document.isOwner, relativeTo: this.document }
    )
  };
  
  // Preparer les listes specifiques
  context.abilities = this._prepareAbilities();
  context.items = this._prepareItems();
  
  return context;
}

/** Prepare les caracteristiques pour l'affichage */
_prepareAbilities() {
  const abilities = {};
  for (const [key, ability] of Object.entries(this.actor.system.abilities)) {
    abilities[key] = {
      ...ability,
      label: CONFIG.MON_SYSTEME.abilities[key]?.label ?? key
    };
  }
  return abilities;
}

/** Prepare les items groupes par type */
_prepareItems() {
  const items = {
    weapons: [],
    armor: [],
    gear: []
  };
  
  for (const item of this.actor.items) {
    if (items[item.type]) {
      items[item.type].push(item);
    }
  }
  
  return items;
}

                    

_preparePartContext()

Pour enrichir le contexte specifiquement pour chaque PART :

/**
 * Prepare le contexte specifique a chaque PART.
 * @param {string} partId - Identifiant de la PART
 * @param {object} context - Contexte prepare par _prepareContext
 * @param {object} options - Options de rendu
 */
async _preparePartContext(partId, context, options) {
  context = await super._preparePartContext(partId, context, options);
  
  switch (partId) {
    case "header":
      context.title = this.document.name;
      context.img = this.document.img;
      break;
      
    case "attributes":
      context.fields = this.document.system.schema.fields;
      break;
      
    case "inventory":
      // Calcul du poids uniquement pour cette PART
      context.totalWeight = this._calculateTotalWeight();
      break;
  }
  
  return context;
}

Systeme d'actions

Le systeme d'actions remplace activateListeners() avec une approche declarative.

Declaration dans le template

<!-- Bouton avec action simple -->
<button type="button" data-action="rollAttribute" data-attribute="str">
  Lancer Force
</button>

<!-- Lien avec action -->
<a data-action="editItem" data-item-id="{{item.id}}">
  {{item.name}}
</a>

<!-- Element cliquable quelconque -->
<div class="hp-bar" data-action="adjustHp">
  {{system.hp.value}} / {{system.hp.max}}
</div>

Declaration dans la classe

static DEFAULT_OPTIONS = {
  actions: {
    // Action simple
    rollAttribute: CharacterSheet.#onRollAttribute,
    
    // Action avec configuration
    editItem: {
      handler: CharacterSheet.#onEditItem,
      buttons: [0]  // Clic gauche uniquement
    },
    
    // Action avec plusieurs boutons
    adjustHp: {
      handler: CharacterSheet.#onAdjustHp,
      buttons: [0, 2]  // Clic gauche et droit
    }
  }
};

/**
 * Gestionnaire d'action pour le jet de caracteristique.
 * @this {CharacterSheet} - Instance de l'application
 * @param {Event} event - L'evenement declencheur
 * @param {HTMLElement} target - L'element avec data-action
 */
static async #onRollAttribute(event, target) {
  event.preventDefault();
  const attribute = target.dataset.attribute;
  await this.actor.rollAttribute(attribute);
}

static async #onEditItem(event, target) {
  const itemId = target.dataset.itemId;
  const item = this.actor.items.get(itemId);
  item?.sheet.render(true);
}

static #onAdjustHp(event, target) {
  const delta = event.button === 0 ? 1 : -1; // Gauche = +1, Droit = -1
  const currentHp = this.actor.system.hp.value;
  this.actor.update({ "system.hp.value": currentHp + delta });
}
⚠️
Binding automatique de this

Dans les gestionnaires d'actions statiques, this est automatiquement lie a l'instance de l'application. Vous pouvez donc utiliser this.actor, this.document, etc.

Enregistrement des Sheets

Les Sheets doivent etre enregistrees dans le hook init pour etre utilisables.

// Dans votre fichier principal (mon-systeme.mjs)
Hooks.once("init", function() {
  console.log("Mon Systeme | Initialisation");
  
  const DocumentSheetConfig = foundry.applications.apps.DocumentSheetConfig;
  
  // Desenregistrer les sheets par defaut de Foundry (optionnel)
  DocumentSheetConfig.unregisterSheet(
    Actor, 
    "core", 
    foundry.appv1.sheets.ActorSheet
  );
  
  // Enregistrer la sheet pour les personnages
  DocumentSheetConfig.registerSheet(Actor, "mon-systeme", CharacterSheet, {
    types: ["character"],    // Type(s) d'acteur concernes
    makeDefault: true,       // Sheet par defaut pour ce type
    label: "MON_SYSTEME.SheetClass.Character"  // Cle de traduction
  });
  
  // Enregistrer la sheet pour les PNJ
  DocumentSheetConfig.registerSheet(Actor, "mon-systeme", NPCSheet, {
    types: ["npc"],
    makeDefault: true,
    label: "MON_SYSTEME.SheetClass.NPC"
  });
  
  // Enregistrer les sheets d'items
  DocumentSheetConfig.registerSheet(Item, "mon-systeme", ItemSheet, {
    types: ["weapon", "armor", "gear"],
    makeDefault: true,
    label: "MON_SYSTEME.SheetClass.Item"
  });
  
  // Sheet alternative (non par defaut)
  DocumentSheetConfig.registerSheet(Actor, "mon-systeme", CharacterSheetCompact, {
    types: ["character"],
    makeDefault: false,
    label: "MON_SYSTEME.SheetClass.CharacterCompact"
  });
});
💡
Sheets multiples

Vous pouvez enregistrer plusieurs sheets pour un meme type de document. Les utilisateurs pourront choisir leur sheet preferee via le menu de configuration du document.

Exercice pratique : Creer une fiche de personnage simple

Creons ensemble une fiche de personnage minimaliste mais fonctionnelle.

1. Structure des fichiers

mon-systeme/
  +-- module/
  |   +-- applications/
  |       +-- actor/
  |           +-- character-sheet.mjs
  +-- templates/
      +-- actor/
          +-- character-sheet.hbs
          +-- parts/
              +-- header.hbs
              +-- attributes.hbs

2. La classe CharacterSheet

// module/applications/actor/character-sheet.mjs
const { ActorSheetV2 } = foundry.applications.sheets;
const { HandlebarsApplicationMixin } = foundry.applications.api;

export default class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
  
  static DEFAULT_OPTIONS = {
    classes: ["mon-systeme", "actor", "character"],
    position: { width: 500, height: 600 },
    window: {
      resizable: true
    },
    actions: {
      rollAbility: CharacterSheet.#onRollAbility
    },
    form: {
      submitOnChange: true,
      closeOnSubmit: false
    }
  };

  static PARTS = {
    header: {
      template: "systems/mon-systeme/templates/actor/parts/header.hbs"
    },
    attributes: {
      template: "systems/mon-systeme/templates/actor/parts/attributes.hbs",
      scrollable: [""]
    }
  };

  /** @override */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    
    context.actor = this.actor;
    context.system = this.actor.system;
    context.editable = this.isEditable;
    
    // Preparer les caracteristiques
    context.abilities = Object.entries(this.actor.system.abilities).map(([key, value]) => ({
      key,
      label: CONFIG.MON_SYSTEME.abilities[key]?.label ?? key.toUpperCase(),
      value: value.value,
      mod: Math.floor((value.value - 10) / 2)
    }));
    
    return context;
  }

  static async #onRollAbility(event, target) {
    const ability = target.dataset.ability;
    const mod = this.actor.system.abilities[ability].mod;
    
    const roll = new Roll("1d20 + @mod", { mod });
    await roll.evaluate();
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this.actor }),
      flavor: `Jet de ${CONFIG.MON_SYSTEME.abilities[ability]?.label ?? ability}`
    });
  }
}

3. Template header.hbs

<!-- templates/actor/parts/header.hbs -->
<header class="sheet-header">
  <img class="profile-img" src="{{actor.img}}" alt="{{actor.name}}">
  
  <div class="header-fields">
    <input type="text" name="name" value="{{actor.name}}" 
           placeholder="Nom du personnage"
           {{#unless editable}}disabled{{/unless}}>
    
    <div class="level-display">
      <label>Niveau</label>
      <input type="number" name="system.level" value="{{system.level}}"
             min="1" max="20" {{#unless editable}}disabled{{/unless}}>
    </div>
  </div>
</header>

4. Template attributes.hbs

<!-- templates/actor/parts/attributes.hbs -->
<section class="attributes-panel">
  <h2>Caracteristiques</h2>
  
  <div class="abilities-grid">
    {{#each abilities}}
    <div class="ability-block">
      <label class="ability-label">{{this.label}}</label>
      
      <input type="number" 
             name="system.abilities.{{this.key}}.value" 
             value="{{this.value}}"
             min="1" max="30"
             class="ability-value"
             {{#unless ../editable}}disabled{{/unless}}>
      
      <button type="button" 
              class="ability-mod" 
              data-action="rollAbility" 
              data-ability="{{this.key}}">
        {{#if (gte this.mod 0)}}+{{/if}}{{this.mod}}
      </button>
    </div>
    {{/each}}
  </div>
  
  <!-- Points de vie -->
  <div class="hp-section">
    <h3>Points de Vie</h3>
    <div class="hp-inputs">
      <input type="number" name="system.hp.value" value="{{system.hp.value}}"
             min="0" max="{{system.hp.max}}" class="hp-current">
      <span class="hp-separator">/</span>
      <input type="number" name="system.hp.max" value="{{system.hp.max}}"
             min="1" class="hp-max">
    </div>
  </div>
</section>
Felicitations !

Vous avez cree une fiche de personnage fonctionnelle avec ApplicationV2. Les valeurs se sauvegardent automatiquement grace a submitOnChange: true, et les jets de des sont geres par le systeme d'actions.