From f2c8cfe246922b10864b975b66aac37bafc0e697 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Tue, 4 Aug 2026 14:14:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=A9quipement,=20=C3=A9changes=20de?= =?UTF-8?q?=20coups=20et=20am=C3=A9liorations=20des=20fiches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - équipement des armes et protections (champ equipped, liste des équipés, toggle) - échanges de coups automatisés (attaque/défense/résolution/blessures dans le chat) - dialogue d'attaque par type (personnage/PNJ/créature), portées et difficultés - conservation du scroll des fiches lors des modifs (option scrollable + classe active) - verrouillage des compétences PNJ en mode jeu - corrections review combat (défenseur, difficulté >10, multi-cibles, permissions) --- lang/en.json | 94 +++- lang/fr.json | 94 +++- less/base.less | 299 +++++++++++ less/dialogs.less | 248 +++++++++ .../applications/sheets/base-actor-sheet.mjs | 23 +- module/applications/sheets/creature-sheet.mjs | 28 +- module/applications/sheets/group-sheet.mjs | 10 +- module/applications/sheets/item-sheets.mjs | 24 +- module/applications/sheets/npc-sheet.mjs | 21 +- module/models/defense.mjs | 1 + module/models/weapon.mjs | 1 + module/system/dialogs/combatDialog.mjs | 399 ++++++++++++++ module/system/exchange.mjs | 489 ++++++++++++++++++ module/system/handlebars-manager.mjs | 1 + module/system/hooks.mjs | 4 + module/system/roll.mjs | 15 +- module/vermine2047.mjs | 1 + templates/actor/appv2/character-abilities.hbs | 2 +- templates/actor/appv2/character-combat.hbs | 13 +- templates/actor/appv2/character-equipment.hbs | 2 +- templates/actor/appv2/character-stories.hbs | 2 +- templates/actor/appv2/character-totem.hbs | 2 +- templates/actor/appv2/creature-combat.hbs | 2 +- templates/actor/appv2/creature-effects.hbs | 2 +- templates/actor/appv2/creature-info.hbs | 2 +- templates/actor/appv2/creature-stats.hbs | 2 +- templates/actor/appv2/group-gear.hbs | 2 +- templates/actor/appv2/group-info.hbs | 2 +- templates/actor/appv2/group-reserve.hbs | 2 +- templates/actor/appv2/group-road.hbs | 2 +- templates/actor/appv2/npc-characteristics.hbs | 2 +- templates/actor/appv2/npc-combat.hbs | 3 +- templates/actor/appv2/npc-equipment.hbs | 10 + templates/actor/appv2/npc-notes.hbs | 2 +- templates/actor/appv2/npc-skills.hbs | 2 +- templates/actor/appv2/npc-threat.hbs | 2 +- templates/actor/parts/actor-defenses.hbs | 8 + templates/actor/parts/actor-equipped-gear.hbs | 64 +++ templates/actor/parts/actor-weapons.hbs | 6 + templates/actor/parts/npc-skill-item.hbs | 2 + templates/dialogs/combat-dialog.hbs | 234 +++++++++ templates/item/item-item-sheet.hbs | 2 +- templates/item/item-specialty-sheet.hbs | 2 +- templates/item/item-weapon-sheet.hbs | 2 +- templates/roll-message.hbs | 53 ++ 45 files changed, 2105 insertions(+), 78 deletions(-) create mode 100644 module/system/dialogs/combatDialog.mjs create mode 100644 module/system/exchange.mjs create mode 100644 templates/actor/appv2/npc-equipment.hbs create mode 100644 templates/actor/parts/actor-equipped-gear.hbs create mode 100644 templates/dialogs/combat-dialog.hbs diff --git a/lang/en.json b/lang/en.json index 4e183d0..69d6b9d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -147,6 +147,8 @@ "pools": "Reserves", "self_control": "Self-control", "effort": "Effort", + "equip": "Equip", + "unequip": "Unequip", "group": "Group", "abilities": "Characteristics", "ability": "Characteristic", @@ -253,6 +255,7 @@ "pool_tooling": "Equipment", "pool_group": "Group", "pool_selfControl": "Self-Control", + "pool_pools": "Pools", "pool_total": "Total", "totem_used": "Totem", "totem_keep": "kept", @@ -280,12 +283,101 @@ "error_no_actor_selected": "No actor selected", "error_not_enough_self_control": "Not enough self control", "error_select_ability": "Select an ability", + "attack": "Attack", + "attack_contact": "Melee attack", + "attack_ranged": "Ranged attack", + "defense_esquive": "Dodge", + "defense_parade": "Parry", + "passive_defense": "Passive defense", + "range_short": "Short", + "range_mid": "Medium", + "range_long": "Long", + "second_action": "Second action (+2 difficulty)", + "consume_ammo": "Consume ammo", + "npc_pool": "Pools used", + "exchange": "Combat exchange", + "resolve": "Resolve", + "resolve_result": "Exchange resolution", + "attacker_successes": "Attack successes", + "defender_successes": "Defense successes", + "raw_damage": "Raw damage", + "net_damage": "Net damage", + "hit": "Hit", + "hit_parade": "Hit (insufficient parry)", + "missed_esquive": "Dodged", + "missed_parade": "Parried", + "no_damage": "No damage", + "wound": "Wound", + "no_wound": "No wound", + "apply_wound": "Apply wound", + "wound_applied": "Wound applied", + "error_creature_no_defense": "Creatures cannot defend (passive defense)", + "error_no_permission": "Insufficient permissions", + "error_no_target": "No target selected for this exchange", + "experience_phase": "Experience phase", + "experience_dice": "Experience dice", + "experience_rewards": "Experience rewards", + "collective": "collective", + "collective_rewards": "Collective rewards", + "individual_rewards": "Individual rewards", + "collective_group": "Group (collective pool)", + "danger": "Danger", + "discoveries": "Discoveries", + "duration": "Duration", + "implication": "Implication", + "influence": "Influence", + "interpretation": "Interpretation", + "reward_anecdotic": "Anecdotal", + "reward_minor": "Minor", + "reward_major": "Major", + "reward_exceptionnal": "Exceptional", + "objective_minor": "Minor objective", + "objective_major": "Major objective", + "trauma_reward": "Chosen trauma", + "trauma_reward_5": "Trauma reward (+5D)", + "evolution_dice": "Evolution dice", + "open_experience_phase": "Experience phase", + "open_learning": "Learning", + "learning_limits": "Learning limits (per phase)", + "learned_skill": "Skill", + "learned_specialty": "Specialty", + "learned_reserve": "Reserve", + "learned_characteristic": "Characteristic", + "learned_evolution": "Evolution", + "no_character": "No character in this world.", + "apply": "Apply", + "characteristic": "Characteristic", + "learn_skill": "Skill", + "learn_specialty": "Specialty", + "learn_characteristic": "Characteristic", + "learn_reserve": "Reserve", + "learn_evolution": "Evolution die", + "learn_specialty_name": "Specialty name", + "specialty_hint": "Prerequisite: skill at least Proficient (≥2).", + "characteristic_hint": "Difficulty 9 + Handicap = current value. Max 3 (except Superhuman).", + "reserve_hint": "Difficulty = current value + age Handicap. Max 10.", + "evolution_hint": "Difficulty by age + Handicap = accumulated Evolution dice. Adapted totem +1D, Human -1D.", + "dice_spent": "Dice spent", + "learning_success": "Learning successful!", + "error_no_experience_dice": "No Experience dice available.", + "error_learning_limit": "Limit reached for this type of learning in this phase.", + "error_skill_max": "This skill is already at maximum level.", + "error_characteristic_max": "Characteristic already at maximum (3).", + "error_specialty_requirement": "Specialty requires: skill at least Proficient (≥2).", + "error_reserve_max": "Reserve already at maximum (10).", + "experience_transfer": "Experience dice", + "experience_pull": "Pull from group", + "experience_give": "Give 1D to group", + "pool_experience": "Experience", + "error_no_group": "No group associated with this character.", + "error_no_group_dice": "Not enough group pool dice.", "tabs": { "abilities": "Abilities", "combat": "Combat", "equipment": "Equipment", "stories": "Stories", - "totem": "Totem" + "totem": "Totem", + "experience": "Experience" }, "base_values": "Base Values", "computed_values": "Computed Values", diff --git a/lang/fr.json b/lang/fr.json index 41b2260..10b1185 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -154,6 +154,8 @@ "reserves": "Réserves", "self_control": "Sang-Froid", "effort": "Effort", + "equip": "Équiper", + "unequip": "Déséquiper", "preferred_category": "Catégorie préférée", "wounds": { "name": "Blessures", @@ -200,6 +202,7 @@ "pool_tooling": "Équipement", "pool_group": "Groupe", "pool_selfControl": "Sang-Froid", + "pool_pools": "Réserves", "pool_total": "Total", "totem_used": "Totem", "totem_keep": "gardé", @@ -266,6 +269,94 @@ "totem_dice": "Dés de totem", "keep_totem": "Garder le totem", "totem_hint": "Cochez pour utiliser les dés de totem (double réussite possible)", + "attack": "Attaque", + "attack_contact": "Attaque au contact", + "attack_ranged": "Attaque à distance", + "defense_esquive": "Esquive", + "defense_parade": "Parade", + "passive_defense": "Défense passive", + "range_short": "Courte", + "range_mid": "Moyenne", + "range_long": "Longue", + "second_action": "Deuxième action (+2 difficulté)", + "consume_ammo": "Consommer des munitions", + "npc_pool": "Réserves utilisées", + "exchange": "Échange de coups", + "resolve": "Résoudre", + "resolve_result": "Résolution de l'échange", + "attacker_successes": "Réussites d'attaque", + "defender_successes": "Réussites de défense", + "raw_damage": "Dégâts bruts", + "net_damage": "Dégâts nets", + "hit": "Touché", + "hit_parade": "Touché (parade insuffisante)", + "missed_esquive": "Esquivé", + "missed_parade": "Paré", + "no_damage": "Aucun dégât", + "wound": "Blessure", + "no_wound": "Aucune blessure", + "apply_wound": "Appliquer la blessure", + "wound_applied": "Blessure appliquée", + "error_creature_no_defense": "Les créatures ne peuvent pas se défendre (défense passive)", + "error_no_permission": "Permissions insuffisantes", + "error_no_target": "Aucune cible sélectionnée pour cet échange", + "experience_phase": "Phase d'Expérience", + "experience_dice": "Dés d'Expérience", + "experience_rewards": "Récompenses d'Expérience", + "collective": "collectifs", + "collective_rewards": "Récompenses collectives", + "individual_rewards": "Récompenses individuelles", + "collective_group": "Groupe (réserve collective)", + "danger": "Danger", + "discoveries": "Découvertes", + "duration": "Durée", + "implication": "Implication", + "influence": "Influence", + "interpretation": "Interprétation", + "reward_anecdotic": "Anecdotique", + "reward_minor": "Mineure", + "reward_major": "Majeure", + "reward_exceptionnal": "Exceptionnelle", + "objective_minor": "Objectif mineur", + "objective_major": "Objectif majeur", + "trauma_reward": "Traumatisme choisi", + "trauma_reward_5": "Récompense de Traumatisme (+5D)", + "evolution_dice": "Dés d'Évolution", + "open_experience_phase": "Phase d'Expérience", + "open_learning": "Apprentissage", + "learning_limits": "Limites d'apprentissage (par phase)", + "learned_skill": "Compétence", + "learned_specialty": "Spécialité", + "learned_reserve": "Réserve", + "learned_characteristic": "Caractéristique", + "learned_evolution": "Évolution", + "no_character": "Aucun personnage dans ce monde.", + "apply": "Appliquer", + "characteristic": "Caractéristique", + "learn_skill": "Compétence", + "learn_specialty": "Spécialité", + "learn_characteristic": "Caractéristique", + "learn_reserve": "Réserve", + "learn_evolution": "Dé d'Évolution", + "learn_specialty_name": "Nom de la spécialité", + "specialty_hint": "Prérequis : compétence au moins Confirmée (≥2).", + "characteristic_hint": "Difficulté 9 + Handicap = valeur actuelle. Max 3 (Surhumain excepté).", + "reserve_hint": "Difficulté = valeur actuelle + Handicap d'âge. Max 10.", + "evolution_hint": "Difficulté selon l'âge + Handicap = Dés d'Évolution accumulés. Totem Adapté +1D, Humain -1D.", + "dice_spent": "Dés dépensés", + "learning_success": "Apprentissage réussi !", + "error_no_experience_dice": "Pas de Dés d'Expérience disponibles.", + "error_learning_limit": "Limite atteinte pour ce type d'apprentissage dans cette phase.", + "error_skill_max": "Cette compétence est déjà au niveau maximal.", + "error_characteristic_max": "Caractéristique déjà au maximum (3).", + "error_specialty_requirement": "Spécialité requise : compétence au moins Confirmée (≥2).", + "error_reserve_max": "Réserve déjà au maximum (10).", + "experience_transfer": "Dés d'Expérience", + "experience_pull": "Puiser du Groupe", + "experience_give": "Donner 1D au Groupe", + "pool_experience": "Expérience", + "error_no_group": "Aucun Groupe associé à ce personnage.", + "error_no_group_dice": "Réserve de Groupe insuffisante.", "error_select_skill": "Veuillez sélectionner une compétence", "needed": "nécessaire", "cost": "Coût", @@ -281,7 +372,8 @@ "totem": "Totem et ajustements", "combat": "Combat et reserves", "equipment": "Matériel", - "stories": "Histoire" + "stories": "Histoire", + "experience": "Expérience" }, "ammo": "Munitions", "ammo_sm": "Mun", diff --git a/less/base.less b/less/base.less index 7bc97a5..fba1522 100644 --- a/less/base.less +++ b/less/base.less @@ -788,6 +788,195 @@ ol.chat-log { } } + // ── Arme du jet ─────────────────────────────────────────────────── + div.weapon-block { + margin: 4px 0 6px; + padding: 6px 8px; + border: 1px solid @color-hemolymph; + background: rgba(0, 0, 0, 0.1); + + .weapon-block-header { + align-items: center; + gap: 6px; + margin-bottom: 2px; + + .weapon-icon { + color: @color-hemolymph; + } + + .weapon-name { + font-family: "DistressBlack"; + text-transform: uppercase; + font-size: @font-size-12; + font-weight: 900; + color: @theme-color-dark; + } + + .weapon-targets { + margin-left: auto; + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + color: @theme-color-accent; + } + } + + .weapon-details { + gap: 4px 12px; + + .weapon-detail { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + + strong { + text-transform: uppercase; + } + } + } + } + + // ── Échange de coups ───────────────────────────────────────────── + div.exchange-actions { + margin: 4px 0 6px; + padding: 6px 8px; + border: 1px dashed @color-hemolymph; + background: fade(@color-hemolymph, 8%); + + .exchange-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + font-family: "DistressBlack"; + text-transform: uppercase; + font-size: @font-size-12; + font-weight: 900; + color: @theme-color-dark; + + i { color: @color-hemolymph; } + } + + .exchange-target { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 3px 0; + + .exchange-target-name { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + color: @theme-color-accent; + } + + button.exchange-defend { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + padding: 2px 8px; + cursor: pointer; + color: @color-chitin-dark; + background: @color-text-light-0; + border: 1px solid @color-border-dark-4; + border-radius: 3px; + .transition(); + + &:hover { + color: @color-text-light-0; + background: @color-hemolymph; + border-color: @color-hemolymph; + } + } + + .exchange-passive { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + color: @color-amber; + opacity: 0.85; + font-style: italic; + } + } + + button.exchange-resolve { + margin-top: 4px; + font-family: "DistressBlack"; + text-transform: uppercase; + font-size: @font-size-11; + padding: 3px 12px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-hemolymph, 25%); + border: 1px solid @color-hemolymph; + border-radius: 3px; + .transition(); + + &:hover { + color: @theme-color-dark; + background: @color-hemolymph; + } + } + } + + div.exchange-resolution { + margin: 4px 0 6px; + padding: 6px 8px; + border: 1px solid @color-amber; + background: fade(@color-amber, 8%); + + .resolution-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + font-family: "DistressBlack"; + text-transform: uppercase; + font-size: @font-size-12; + font-weight: 900; + color: @theme-color-dark; + + i { color: @color-amber; } + } + + .resolution-line { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + padding: 1px 0; + + strong { font-weight: 700; } + + .res-hit { color: @color-hemolymph; font-weight: 700; } + .res-dodged, + .res-parried { color: @color-acid-green; font-weight: 700; } + &.res-wound { color: @color-hemolymph; font-weight: 700; } + &.res-noshock, + &.res-nodamage { color: @color-acid-green; } + } + + button.exchange-apply { + margin-top: 6px; + font-family: "DistressBlack"; + text-transform: uppercase; + font-size: @font-size-11; + padding: 3px 12px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-hemolymph, 25%); + border: 1px solid @color-hemolymph; + border-radius: 3px; + .transition(); + + &:hover:not(:disabled) { + color: @theme-color-dark; + background: @color-hemolymph; + } + + &:disabled { + opacity: 0.7; + cursor: default; + color: @color-acid-green; + border-color: @color-acid-green; + } + } + } + // ── Composition du jet ──────────────────────────────────────────── div.roll-pool { margin: 4px 0; @@ -846,3 +1035,113 @@ ol.chat-log { padding: 15px; } } + +// ── Onglet Expérience ───────────────────────────────────────────────── +.tab.experience { + .experience-summary { + display: flex; + gap: 10px; + margin-bottom: 10px; + + .exp-stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px; + border: 1px solid @color-border-dark-4; + background: fade(@color-chitin-dark, 30%); + + label { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-11; + color: @color-amber; + } + + .exp-value { + font-family: "DistressBlack", sans-serif; + font-size: @font-size-24; + color: @color-acid-green; + } + } + } + + .exp-actions { + gap: 8px; + margin-bottom: 10px; + + button { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-12; + padding: 5px 14px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-hemolymph, 20%); + border: 1px solid @color-hemolymph; + border-radius: 3px; + .transition(); + + &:hover { + color: @theme-color-dark; + background: @color-hemolymph; + } + } + } + + .exp-rewards { + margin-bottom: 10px; + padding: 8px; + border: 1px solid @color-border-dark-4; + background: fade(@color-chitin-dark, 20%); + + h4 { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-12; + color: @theme-color-dark; + margin: 0 0 6px; + + .exp-total { + color: @color-amber; + } + } + + .exp-reward-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: @font-size-12; + + label { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-10; + color: @color-amber; + } + } + + .exp-learned { + display: flex; + flex-wrap: wrap; + gap: 6px; + + span { + padding: 2px 8px; + font-size: @font-size-11; + border: 1px solid @color-border-dark-4; + border-radius: 3px; + opacity: 0.6; + + &.learned { + opacity: 1; + color: @color-acid-green; + border-color: @color-acid-green; + } + } + } + } +} diff --git a/less/dialogs.less b/less/dialogs.less index 2e95594..a4090bd 100644 --- a/less/dialogs.less +++ b/less/dialogs.less @@ -399,6 +399,254 @@ iframe { } } + // ── Combat dialog (attaque) ────────────────────────────────────── + .combat-mode-label { + margin: 0; + text-align: center; + font-size: @font-size-14; + color: @color-hemolymph; + text-transform: uppercase; + } + + .locked-value { + flex: 1; + display: flex; + align-items: center; + min-height: 28px; + padding: 0 8px; + font-size: @font-size-12; + color: @color-chitin-dark; + background: @color-text-light-0; + border-radius: 3px; + .custom-select-style(); + } + + .combat-dialog-weapon { + border: 1px solid @color-hemolymph; + padding: 6px 8px; + background: fade(@color-chitin-dark, 40%); + + .weapon-block-header { + align-items: center; + gap: 6px; + + .weapon-icon { color: @color-hemolymph; } + + .weapon-name { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-12; + font-weight: 900; + color: @theme-color-dark; + } + } + + .weapon-details { + gap: 4px 12px; + flex-wrap: wrap; + + .weapon-detail { + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + color: @color-text-light-0; + + strong { text-transform: uppercase; } + } + } + } + + .combat-difficulty-options { + flex-wrap: wrap; + gap: 8px; + + .difficulty-option { + display: flex; + align-items: center; + gap: 4px; + font-family: "Roboto", sans-serif; + text-transform: none; + font-size: @font-size-12; + color: @color-text-light-0; + cursor: pointer; + + input[type="radio"] { flex: none; } + + &.locked { + opacity: 0.8; + font-style: italic; + } + } + } + + .combat-targets { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px 2px; + + .label { + font-size: @font-size-11; + color: @color-amber; + } + + .combat-target-tag { + padding: 2px 8px; + font-size: @font-size-11; + font-family: "Roboto", sans-serif; + color: @color-text-light-0; + background: fade(@color-hemolymph, 20%); + border: 1px solid fade(@color-hemolymph, 50%); + border-radius: 3px; + } + } + + // ── Phase d'Expérience ─────────────────────────────────────────── + .exp-phase-characters { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + + details > div:not(summary) { + padding: 8px; + background: fade(@color-chitin-dark, 30%); + } + } + + // ── Dialogue d'apprentissage ───────────────────────────────────── + .learning-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid @color-hemolymph; + background: fade(@color-hemolymph, 10%); + + .label { + font-size: @font-size-12; + color: @color-amber; + } + + .exp-value { + font-family: "DistressBlack", sans-serif; + font-size: @font-size-20; + color: @color-acid-green; + } + } + + .learning-type { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px 2px; + + label { + display: flex; + align-items: center; + gap: 4px; + font-family: "Roboto", sans-serif; + text-transform: none; + font-size: @font-size-12; + color: @color-text-light-0; + cursor: pointer; + + input[type="radio"] { flex: none; } + + &:hover { color: @color-amber; } + } + } + + .learning-targets { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 1px solid @color-border-dark-4; + background: fade(@color-chitin-dark, 30%); + + .learning-hint { + margin: 0; + font-size: @font-size-11; + font-style: italic; + color: @color-text-light-0; + opacity: 0.75; + } + } + + .learning-result { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + padding: 8px; + border: 1px solid @color-amber; + background: fade(@color-amber, 8%); + + .context-item { + display: flex; + align-items: center; + gap: 6px; + + .context-label { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-11; + color: @color-amber; + } + + .context-value { + font-family: "Roboto", sans-serif; + font-size: @font-size-13; + font-weight: 700; + color: @color-text-light-0; + } + } + } + + // ── Transfert de Dés d'Expérience ───────────────────────────────── + .experience-section { + .experience-controls { + gap: 8px; + padding: 8px; + + .experience-group { + font-size: @font-size-11; + color: @color-text-light-0; + opacity: 0.8; + + #exp-group { + font-weight: 700; + color: @color-acid-green; + } + } + + button[data-action="giveToGroup"] { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-11; + padding: 3px 12px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-acid-green, 15%); + border: 1px solid @color-acid-green; + border-radius: 3px; + .transition(); + + &:hover:not(:disabled) { + color: @theme-color-dark; + background: @color-acid-green; + } + + &:disabled { + opacity: 0.6; + cursor: default; + } + } + } + } + // ── Total dice pool section ───────────────────────────────────── .total-section { align-items: center; diff --git a/module/applications/sheets/base-actor-sheet.mjs b/module/applications/sheets/base-actor-sheet.mjs index 6e695bf..a18bd4d 100644 --- a/module/applications/sheets/base-actor-sheet.mjs +++ b/module/applications/sheets/base-actor-sheet.mjs @@ -30,8 +30,10 @@ export default class VermineBaseActorSheet extends HandlebarsApplicationMixin(fo toggleSheet: VermineBaseActorSheet.#onToggleSheet, edit: VermineBaseActorSheet.#onItemEdit, delete: VermineBaseActorSheet.#onItemDelete, + toggleEquip: VermineBaseActorSheet.#onToggleEquip, create: VermineBaseActorSheet.#onItemCreate, roll: VermineBaseActorSheet.#onRollItem, + attack: VermineBaseActorSheet.#onAttack, clickRadio: VermineBaseActorSheet.#onClickRadioHexa, effectControl: VermineBaseActorSheet.#onEffectControl, chooseTotem: VermineBaseActorSheet.#onChooseTotem @@ -237,7 +239,8 @@ export default class VermineBaseActorSheet extends HandlebarsApplicationMixin(fo const dialog = await RollDialog.create({ actorId: this.document.id, rolltype: "skill", - label: item.system.skill + label: item.system.skill, + weapon: item }) if (dialog) dialog.render(true) return @@ -245,6 +248,24 @@ export default class VermineBaseActorSheet extends HandlebarsApplicationMixin(fo item?.roll() } + static async #onAttack(event, target) { + const id = target.closest("[data-item-id]")?.dataset?.itemId + if (!id) return + const item = this.document.items.get(id) + if (!item) return + const { default: CombatDialog } = await import("../../system/dialogs/combatDialog.mjs") + const dialog = await CombatDialog.create({ actorId: this.document.id, weapon: item }) + if (dialog) dialog.render(true) + } + + static async #onToggleEquip(event, target) { + const id = target.closest("[data-item-id]")?.dataset?.itemId + if (!id) return + const item = this.document.items.get(id) + if (!item) return + await item.update({ "system.equipped": !item.system.equipped }) + } + static #onClickRadioHexa(event, target) { event.preventDefault() event.stopPropagation() diff --git a/module/applications/sheets/creature-sheet.mjs b/module/applications/sheets/creature-sheet.mjs index 0719e9a..f1c1c4e 100644 --- a/module/applications/sheets/creature-sheet.mjs +++ b/module/applications/sheets/creature-sheet.mjs @@ -13,12 +13,12 @@ export default class VermineCreatureSheetV2 extends VermineBaseActorSheet { } static PARTS = { - main: { template: "systems/vermine2047/templates/actor/appv2/creature-main.hbs" }, + main: { template: "systems/vermine2047/templates/actor/appv2/creature-main.hbs", scrollable: [""] }, tabs: { template: "templates/generic/tab-navigation.hbs" }, - info: { template: "systems/vermine2047/templates/actor/appv2/creature-info.hbs" }, - stats: { template: "systems/vermine2047/templates/actor/appv2/creature-stats.hbs" }, - combat: { template: "systems/vermine2047/templates/actor/appv2/creature-combat.hbs" }, - effects: { template: "systems/vermine2047/templates/actor/appv2/creature-effects.hbs" } + info: { template: "systems/vermine2047/templates/actor/appv2/creature-info.hbs", scrollable: [""] }, + stats: { template: "systems/vermine2047/templates/actor/appv2/creature-stats.hbs", scrollable: [""] }, + combat: { template: "systems/vermine2047/templates/actor/appv2/creature-combat.hbs", scrollable: [""] }, + effects: { template: "systems/vermine2047/templates/actor/appv2/creature-effects.hbs", scrollable: [""] } } tabGroups = { sheet: "info" } @@ -94,24 +94,14 @@ export default class VermineCreatureSheetV2 extends VermineBaseActorSheet { return context } - /** @override - Les créatures n'ont pas de caractéristiques/compétences : on lance directement le pool d'attaque. */ + /** @override - Les créatures n'ont pas de caractéristiques/compétences : on ouvre le dialogue d'attaque (pool calculé). */ async _onRoll(event) { event.preventDefault() const el = event.currentTarget const type = el.dataset.type if (type !== "creature-attack") return super._onRoll(event) - const pool = this.document.system.computed?.attack ?? 0 - if (!pool) { - ui.notifications.warn(game.i18n.localize("VERMINE.error_select_ability")) - return - } - const difficulty = this.document.system.combatStatus?.difficulty ?? 7 - const { VermineUtils } = await import("../../system/roll.mjs") - await VermineUtils.roll({ - actor: this.document, - NoD: pool, - difficulty: parseInt(difficulty, 10), - rollLabel: game.i18n.localize("ADVERSITY.attack") - }) + const { default: CombatDialog } = await import("../../system/dialogs/combatDialog.mjs") + const dialog = await CombatDialog.create({ actorId: this.document.id }) + if (dialog) dialog.render(true) } } diff --git a/module/applications/sheets/group-sheet.mjs b/module/applications/sheets/group-sheet.mjs index 9dade71..f5b1438 100644 --- a/module/applications/sheets/group-sheet.mjs +++ b/module/applications/sheets/group-sheet.mjs @@ -16,12 +16,12 @@ export default class VermineGroupSheetV2 extends VermineBaseActorSheet { } static PARTS = { - main: { template: "systems/vermine2047/templates/actor/appv2/group-main.hbs" }, + main: { template: "systems/vermine2047/templates/actor/appv2/group-main.hbs", scrollable: [""] }, tabs: { template: "templates/generic/tab-navigation.hbs" }, - info: { template: "systems/vermine2047/templates/actor/appv2/group-info.hbs" }, - gear: { template: "systems/vermine2047/templates/actor/appv2/group-gear.hbs" }, - road: { template: "systems/vermine2047/templates/actor/appv2/group-road.hbs" }, - reserve: { template: "systems/vermine2047/templates/actor/appv2/group-reserve.hbs" } + info: { template: "systems/vermine2047/templates/actor/appv2/group-info.hbs", scrollable: [""] }, + gear: { template: "systems/vermine2047/templates/actor/appv2/group-gear.hbs", scrollable: [""] }, + road: { template: "systems/vermine2047/templates/actor/appv2/group-road.hbs", scrollable: [""] }, + reserve: { template: "systems/vermine2047/templates/actor/appv2/group-reserve.hbs", scrollable: [""] } } tabGroups = { sheet: "info" } diff --git a/module/applications/sheets/item-sheets.mjs b/module/applications/sheets/item-sheets.mjs index 2fbad61..cf35e16 100644 --- a/module/applications/sheets/item-sheets.mjs +++ b/module/applications/sheets/item-sheets.mjs @@ -3,71 +3,71 @@ import { VermineBaseItemSheet } from "./base-item-sheet.mjs" // ── Item générique ──────────────────────────────────────────────────── export class VermineItemSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["item-gear"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-item-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-item-sheet.hbs", scrollable: [""] } } } // ── Arme ────────────────────────────────────────────────────────────── export class VermineWeaponSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["weapon"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-weapon-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-weapon-sheet.hbs", scrollable: [""] } } } // ── Défense ─────────────────────────────────────────────────────────── export class VermineDefenseSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["defense"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-defense-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-defense-sheet.hbs", scrollable: [""] } } } // ── Véhicule ────────────────────────────────────────────────────────── export class VermineVehicleSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["vehicle"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-vehicle-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-vehicle-sheet.hbs", scrollable: [""] } } } // ── Capacité ────────────────────────────────────────────────────────── export class VermineAbilitySheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["ability"], position: { width: 560 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-ability-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-ability-sheet.hbs", scrollable: [""] } } } // ── Spécialité ──────────────────────────────────────────────────────── export class VermineSpecialtySheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["specialty"], position: { width: 400 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-specialty-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-specialty-sheet.hbs", scrollable: [""] } } } // ── Historique ──────────────────────────────────────────────────────── export class VermineBackgroundSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["background"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-background-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-background-sheet.hbs", scrollable: [""] } } } // ── Traumatisme ─────────────────────────────────────────────────────── export class VermineTraumaSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["trauma"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-trauma-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-trauma-sheet.hbs", scrollable: [""] } } } // ── Évolution ───────────────────────────────────────────────────────── export class VermineEvolutionSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["evolution"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-evolution-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-evolution-sheet.hbs", scrollable: [""] } } } // ── Rumeur ──────────────────────────────────────────────────────────── export class VermineRumorSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["rumor"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-rumor-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-rumor-sheet.hbs", scrollable: [""] } } } // ── Cible ───────────────────────────────────────────────────────────── export class VermineTargetSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["target"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-target-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-target-sheet.hbs", scrollable: [""] } } } // ── Rite ────────────────────────────────────────────────────────────── export class VermineRiteSheetV2 extends VermineBaseItemSheet { static DEFAULT_OPTIONS = { classes: ["rite"], position: { width: 520 } } - static PARTS = { main: { template: "systems/vermine2047/templates/item/item-rite-sheet.hbs" } } + static PARTS = { main: { template: "systems/vermine2047/templates/item/item-rite-sheet.hbs", scrollable: [""] } } } diff --git a/module/applications/sheets/npc-sheet.mjs b/module/applications/sheets/npc-sheet.mjs index 8107631..baeb1f9 100644 --- a/module/applications/sheets/npc-sheet.mjs +++ b/module/applications/sheets/npc-sheet.mjs @@ -13,13 +13,14 @@ export default class VermineNpcSheetV2 extends VermineBaseActorSheet { } static PARTS = { - main: { template: "systems/vermine2047/templates/actor/appv2/npc-main.hbs" }, + main: { template: "systems/vermine2047/templates/actor/appv2/npc-main.hbs", scrollable: [""] }, tabs: { template: "templates/generic/tab-navigation.hbs" }, - characteristics: { template: "systems/vermine2047/templates/actor/appv2/npc-characteristics.hbs" }, - skills: { template: "systems/vermine2047/templates/actor/appv2/npc-skills.hbs" }, - threat: { template: "systems/vermine2047/templates/actor/appv2/npc-threat.hbs" }, - combat: { template: "systems/vermine2047/templates/actor/appv2/npc-combat.hbs" }, - notes: { template: "systems/vermine2047/templates/actor/appv2/npc-notes.hbs" } + characteristics: { template: "systems/vermine2047/templates/actor/appv2/npc-characteristics.hbs", scrollable: [""] }, + skills: { template: "systems/vermine2047/templates/actor/appv2/npc-skills.hbs", scrollable: [""] }, + equipment: { template: "systems/vermine2047/templates/actor/appv2/npc-equipment.hbs", scrollable: [""] }, + threat: { template: "systems/vermine2047/templates/actor/appv2/npc-threat.hbs", scrollable: [""] }, + combat: { template: "systems/vermine2047/templates/actor/appv2/npc-combat.hbs", scrollable: [""] }, + notes: { template: "systems/vermine2047/templates/actor/appv2/npc-notes.hbs", scrollable: [""] } } tabGroups = { sheet: "characteristics" } @@ -28,6 +29,7 @@ export default class VermineNpcSheetV2 extends VermineBaseActorSheet { const tabs = { characteristics: { id: "characteristics", group: "sheet", icon: "fas fa-dice", label: "VERMINE.abilities" }, skills: { id: "skills", group: "sheet", icon: "fas fa-brain", label: "VERMINE.skills" }, + equipment: { id: "equipment", group: "sheet", icon: "fas fa-hammer", label: "VERMINE.tabs.equipment" }, threat: { id: "threat", group: "sheet", icon: "fas fa-exclamation-triangle", label: "ADVERSITY.threat" }, combat: { id: "combat", group: "sheet", icon: "fas fa-sword", label: "VERMINE.combat" }, notes: { id: "notes", group: "sheet", icon: "fas fa-sticky-note", label: "IDENTITY.notes" } @@ -61,11 +63,18 @@ export default class VermineNpcSheetV2 extends VermineBaseActorSheet { case "skills": context.tab = context.tabs.skills break + case "equipment": + context.tab = context.tabs.equipment + context.weapons = doc.itemTypes.weapon + context.defenses = doc.itemTypes.defense + break case "threat": context.tab = context.tabs.threat break case "combat": context.tab = context.tabs.combat + context.equippedWeapons = doc.itemTypes.weapon.filter(i => i.system.equipped) + context.equippedDefenses = doc.itemTypes.defense.filter(i => i.system.equipped) const { prepareActiveEffectCategories } = await import("../../system/effects.mjs") context.effects = prepareActiveEffectCategories(doc.effects) break diff --git a/module/models/defense.mjs b/module/models/defense.mjs index ff42f11..d9bf988 100644 --- a/module/models/defense.mjs +++ b/module/models/defense.mjs @@ -15,6 +15,7 @@ export default class VermineDefenseData extends foundry.abstract.TypeDataModel { return { ...baseItemSchema(), level: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }), + equipped: new fields.BooleanField({ required: true, initial: false }), specificLevel: new fields.SchemaField({ label: new fields.StringField({ required: true, initial: "" }), level: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }) diff --git a/module/models/weapon.mjs b/module/models/weapon.mjs index 6bd5db1..a97f879 100644 --- a/module/models/weapon.mjs +++ b/module/models/weapon.mjs @@ -15,6 +15,7 @@ export default class VermineWeaponData extends foundry.abstract.TypeDataModel { return { ...baseItemSchema(), skill: new fields.StringField({ required: true, nullable: false, initial: "" }), + equipped: new fields.BooleanField({ required: true, initial: false }), min_range: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }), max_range: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }), damage: new fields.SchemaField({ diff --git a/module/system/dialogs/combatDialog.mjs b/module/system/dialogs/combatDialog.mjs new file mode 100644 index 0000000..b067f4e --- /dev/null +++ b/module/system/dialogs/combatDialog.mjs @@ -0,0 +1,399 @@ +import { VermineUtils } from "../roll.mjs"; +import { VermineExchange } from "../exchange.mjs"; + +const { HandlebarsApplicationMixin } = foundry.applications.api; + +/** + * Dialogue d'attaque pour les échanges de coups. + * Prépare le pool, la difficulté (contact / distance) et le contexte + * d'échange (cibles, dégâts de base) selon le type d'acteur : + * - Personnage : capacité + compétence de l'arme. + * - PNJ : pool d'attaque du niveau de menace + réserves de rôle. + * - Créature : pool d'attaque calculé, difficulté du statut de combat. + */ +export default class CombatDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { + + static DEFAULT_OPTIONS = { + classes: ["vermine-roll"], + tag: "form", + window: { + icon: "fas fa-crosshairs", + resizable: false + }, + position: { + width: 520, + height: 600 + }, + actions: { + roll: CombatDialog.#onRoll, + cancel: CombatDialog.#onCancel + } + }; + + static PARTS = { + main: { template: "systems/vermine2047/templates/dialogs/combat-dialog.hbs" } + }; + + static async create({ actorId, weapon = null }) { + const actor = await game.actors.get(actorId); + if (!actor) { + ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected")); + return null; + } + const mode = actor.type === "creature" ? "contact" : VermineExchange.modeForWeapon(weapon); + return new CombatDialog({ actor, weapon, mode }); + } + + constructor(options = {}) { + super(options); + this.actor = options.actor; + this.weapon = options.weapon ?? null; + this.mode = options.mode ?? "contact"; + this.abilityKey = VermineExchange.attackAbility(this.mode); + this.skillKey = this.weapon?.system?.skill || null; + this.skillValue = this.skillKey ? (this.actor.system?.skills?.[this.skillKey]?.value || 0) : 0; + this.skillPool = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.dicePool || 0) : 0; + this.skillReroll = this.skillValue ? (CONFIG.VERMINE.SkillLevels?.[this.skillValue]?.reroll || 0) : 0; + this.basePool = VermineExchange.baseAttackPool(this.actor, this.weapon, this.mode); + this.baseDamage = VermineExchange.baseDamage(this.actor, this.weapon); + } + + get title() { + return game.i18n.localize("VERMINE.attack"); + } + + async _prepareContext() { + const actor = this.actor; + const isCharacter = actor.type === "character"; + const isNpc = actor.type === "npc"; + const isCreature = actor.type === "creature"; + + const targets = [...game.user.targets].map(t => ({ + id: t.actor?.id ?? null, + uuid: t.actor?.uuid ?? null, + name: t.name, + canDefend: t.actor?.type !== "creature" + })); + + let difficultyOptions = []; + let lockedDifficulty = null; + let defaultDifficulty = null; + + if (isCreature) { + const d = parseInt(actor.system?.combatStatus?.difficulty, 10) || 9; + lockedDifficulty = d; + difficultyOptions = [{ difficulty: d, label: "", locked: true }]; + defaultDifficulty = d; + } else if (this.mode === "ranged") { + difficultyOptions = VermineExchange.rangeDifficultyOptions().map(o => ({ + difficulty: o.difficulty, + label: game.i18n.localize(o.label), + rangeKey: o.key + })); + defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; + } else if (isNpc) { + difficultyOptions = VermineExchange.npcDifficultyOptions(actor).map(d => ({ + difficulty: d, + label: String(d) + })); + defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; + } else { + difficultyOptions = VermineExchange.contactDifficultyOptions(this.skillValue).map(d => ({ + difficulty: d, + label: String(d) + })); + defaultDifficulty = difficultyOptions[0]?.difficulty ?? 7; + } + + // Cible de type créature : défense passive, la difficulté d'attaque est + // forcée au statut de combat de la créature (toute réussite ≥1 touche). + const creatureTargets = targets.filter(t => t.id && !t.canDefend); + const creatureLocked = !isCreature && creatureTargets.length && creatureTargets.length === targets.length; + if (creatureLocked) { + const ref = creatureTargets[0]; + const creature = ref.id ? game.actors.get(ref.id) : null; + const actor = creature ?? (ref.uuid ? await fromUuid(ref.uuid) : null); + const d = parseInt(actor?.system?.combatStatus?.difficulty, 10) || 7; + lockedDifficulty = d; + difficultyOptions = [{ difficulty: d, label: "", locked: true }]; + defaultDifficulty = d; + } + + const threat = actor.system?.threat?.value || 1; + const role = actor.system?.role?.value || 1; + + return { + actor, + system: actor.system, + config: CONFIG.VERMINE, + actorType: actor.type, + mode: this.mode, + weapon: this.weapon, + abilityKey: this.abilityKey, + abilityValue: actor.system?.abilities?.[this.abilityKey]?.value || 0, + skillKey: this.skillKey, + skillValue: this.skillValue, + basePool: this.basePool, + baseDamage: this.baseDamage, + isCharacter, + isNpc, + isCreature, + creatureLocked, + npcThreatPool: isNpc ? (CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0) : 0, + npcPoolMax: isNpc ? (CONFIG.VERMINE.npcRoleLevels?.[role]?.pools || 0) : 0, + difficultyOptions, + lockedDifficulty, + defaultDifficulty, + hasAmmo: this.mode === "ranged" && (this.weapon?.system?.ammo ?? 0) > 0, + availableSpecialties: actor.items.filter(i => i.type === "specialty"), + availableItems: actor.items.filter(i => i.type === "item"), + targets + }; + } + + async _onRender(context, options) { + this.element.dataset.actorId = this.actor.id; + this.element.dataset.weaponId = this.weapon?.id ?? ""; + this.element.dataset.mode = this.mode; + + for (const inp of this.element.querySelectorAll("[data-roll]")) { + inp.addEventListener("change", this.#onInputChange.bind(this)); + } + + const selfControl = this.element.querySelector("#self_control"); + if (selfControl) { + selfControl.addEventListener("change", this.#onChangeSelfControl.bind(this)); + } + + this.element.querySelector("#second-action")?.addEventListener("change", () => this.#updateUI()); + this.element.querySelector("#handicap")?.addEventListener("change", () => this.#updateUI()); + this.element.querySelector("#human-totem")?.addEventListener("change", () => this.#updateUI()); + this.element.querySelector("#adapted-totem")?.addEventListener("change", () => this.#updateUI()); + this.element.querySelector("#pools-spent")?.addEventListener("change", () => this.#updateUI()); + + const ability = this.element.querySelector("#ability"); + if (ability && this.actor.type === "character") { + const sc = this.element.querySelector("#self_control"); + if (sc) sc.max = ability.value; + } + + this.#updateUI(); + } + + // ── Getters ────────────────────────────────────────────────────────── + + get #el() { return this.element; } + + #getDifficultyBase() { + const el = this.#el.querySelector('input[name="combat-difficulty"]:checked'); + return parseInt(el?.value, 10) || 7; + } + + #getSecondAction() { + return this.#el.querySelector("#second-action")?.checked ?? false; + } + + #getHandicap() { + const sel = this.#el.querySelector("#handicap"); + return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1); + } + + #getSelfCtrl() { + return parseInt(this.#el.querySelector("#self_control")?.value, 10) || 0; + } + + #getPoolsSpent() { + return parseInt(this.#el.querySelector("#pools-spent")?.value, 10) || 0; + } + + #getUseAmmo() { + return this.#el.querySelector("#use-ammo")?.checked ?? false; + } + + #getTotems() { + return { + human: this.#el.querySelector("#human-totem")?.checked ?? false, + adapted: this.#el.querySelector("#adapted-totem")?.checked ?? false + }; + } + + #getKeepTotem() { + return this.#el.querySelector("#keep-totem-select")?.value ?? null; + } + + getDifficultySelect() { + return this.#getDifficultyBase() + (this.#getSecondAction() ? 2 : 0); + } + + getSkillCategory() { + if (this.actor.type !== "character" || !this.skillKey) return null; + return this.actor.system.skills[this.skillKey]?.category ?? null; + } + + getSkillLevel() { + return this.actor.type === "character" && this.skillValue ? this.skillValue : null; + } + + getReroll() { + if (this.actor.type === "npc") { + const exp = this.actor.system?.experience?.value || 1; + return CONFIG.VERMINE.npcExperienceLevels?.[exp]?.rerolls || 0; + } + if (this.actor.type === "creature") return 0; + return this.skillReroll; + } + + getMaxEffort() { + if (this.actor.type === "character") return this.actor.system?.abilities?.[this.abilityKey]?.value || 0; + return 0; + } + + getPoolBreakdown() { + const ability = this.actor.type === "character" + ? (this.actor.system?.abilities?.[this.abilityKey]?.value || 0) + : 0; + const skill = this.actor.type === "character" ? this.skillPool : 0; + const selfControl = this.#getSelfCtrl(); + const pools = this.actor.type === "npc" ? this.#getPoolsSpent() : 0; + const specialty = this.#hasSpecialtySelected() ? 1 : 0; + const help = this.#el.querySelector("#helped")?.checked ? 1 : 0; + const toolsChecked = this.#el.querySelector("input[name='usingTools']:checked"); + const tooling = toolsChecked && toolsChecked.value !== "0" ? 1 : 0; + const group = parseInt(this.#el.querySelector("#group")?.value, 10) || 0; + const total = this.basePool + selfControl + pools + specialty + help + tooling + group; + return { ability, skill, selfControl, pools, specialty, help, tooling, group, total }; + } + + getDicePool() { + return this.getPoolBreakdown().total; + } + + #hasSpecialtySelected() { + const checked = this.#el.querySelector("input[name='usingSpecialization']:checked"); + return Boolean(checked && checked.value !== "aucune"); + } + + getSpecialtyName() { + const checked = this.#el.querySelector("input[name='usingSpecialization']:checked"); + return checked && checked.value !== "aucune" ? checked.value : null; + } + + getLabel() { + const base = this.mode === "ranged" + ? game.i18n.localize("VERMINE.attack_ranged") + : game.i18n.localize("VERMINE.attack_contact"); + return this.weapon ? `${base} (${this.weapon.name})` : base; + } + + // ── UI ─────────────────────────────────────────────────────────────── + + #updateUI() { + const total = this.getDicePool(); + const totalEl = this.#el.querySelector("#dice-pool-total"); + if (totalEl) totalEl.textContent = `${total}D`; + + const finalEls = this.#el.querySelectorAll("#final-difficulty"); + for (const el of finalEls) el.textContent = this.getDifficultySelect(); + + const b = this.getPoolBreakdown(); + const bonusEl = this.#el.querySelector("#total-bonus"); + if (bonusEl) bonusEl.textContent = b.selfControl + b.pools + b.specialty + b.help + b.tooling + b.group; + + const handSel = this.#el.querySelector("#handicap"); + const handEl = this.#el.querySelector("#current-handicap"); + if (handSel && handEl) { + handEl.textContent = handSel.options[handSel.selectedIndex]?.text ?? ""; + } + } + + #onInputChange() { + this.#updateUI(); + } + + #onChangeSelfControl(ev) { + const valEl = this.#el.querySelector("#self_control_value"); + if (valEl) valEl.textContent = ev.currentTarget.value; + this.#updateUI(); + } + + static async #onCancel(event, target) { + this.close(); + } + + static async #onRoll(event, target) { + const selfCtrl = this.getPoolBreakdown().selfControl; + if (selfCtrl > 0) { + const current = this.actor?.system?.attributes?.self_control?.value ?? 0; + if (current < selfCtrl) { + ui.notifications.warn(game.i18n.localize("VERMINE.error_not_enough_self_control")); + return; + } + } + + const finalDifficulty = this.getDifficultySelect(); + + // Consommation de munitions (armes à distance) + if (this.mode === "ranged" && this.#getUseAmmo() && this.weapon) { + const ammo = this.weapon.system.ammo || 0; + if (ammo > 0) { + await this.weapon.update({ "system.ammo": ammo - 1 }); + } + } + + if (selfCtrl > 0) { + const newVal = this.actor.system.attributes.self_control.value - selfCtrl; + await this.actor.update({ "system.attributes.self_control.value": newVal }); + } + + const targets = [...game.user.targets].map(t => ({ + id: t.actor?.id ?? null, + uuid: t.actor?.uuid ?? null, + name: t.name, + canDefend: t.actor?.type !== "creature" + })); + + const attack = { + id: foundry.utils.randomID(), + attackerId: this.actor.id, + attackerName: this.actor.name, + mode: this.mode, + difficulty: finalDifficulty, + range: this.mode === "ranged" ? this.#getRangeKey() : null, + secondAction: this.#getSecondAction(), + baseDamage: this.baseDamage, + targets, + weapon: this.weapon + ? { name: this.weapon.name, img: this.weapon.img, damage: foundry.utils.duplicate(this.weapon.system.damage) } + : null + }; + + await VermineUtils.roll({ + actor: this.actor, + NoD: this.getDicePool(), + Reroll: this.getReroll(), + difficulty: finalDifficulty, + handicap: this.#getHandicap(), + rollLabel: this.getLabel(), + totems: this.#getTotems(), + self_control: selfCtrl, + max_effort: this.getMaxEffort(), + keepTotem: this.#getKeepTotem(), + skillCategory: this.getSkillCategory(), + skillLevel: this.getSkillLevel(), + hasSpecialty: this.#hasSpecialtySelected(), + poolBreakdown: this.getPoolBreakdown(), + specialtyName: this.getSpecialtyName(), + weapon: this.weapon?.toObject() ?? null, + targets: targets.map(t => t.name), + attack, + messageFlags: { "vermine-exchange": attack } + }); + + this.close(); + } + + #getRangeKey() { + const el = this.#el.querySelector('input[name="combat-difficulty"]:checked'); + return el?.dataset?.rangeKey ?? null; + } +} diff --git a/module/system/exchange.mjs b/module/system/exchange.mjs new file mode 100644 index 0000000..2acccba --- /dev/null +++ b/module/system/exchange.mjs @@ -0,0 +1,489 @@ +/** + * Moteur de résolution des échanges de coups. + * + * Gère l'attaque (contact / distance), la défense (esquive / parade), + * le calcul des dégâts, la déduction des protections et l'application + * des blessures pour les personnages, PNJ et créatures. + * + * Le flux est assisté : les cartes de chat calculent et affichent tout, + * un bouton "Appliquer" coche la blessure sur l'acteur cible. + * + * Liens entre messages (flags "world") : + * - "vermine-exchange" sur le message d'attaque + * - "vermine-defense" sur le message de défense + */ + +const FLAG_EXCHANGE = 'vermine-exchange' +const FLAG_DEFENSE = 'vermine-defense' + +export class VermineExchange { + + // ── Tables de difficulté ──────────────────────────────────────────── + + /** + * Capacité d'attaque selon le mode. + * @param {string} mode 'contact' ou 'ranged' + * @returns {string} clé de capacité (vigor / precision) + */ + static attackAbility(mode) { + return mode === 'ranged' ? 'precision' : 'vigor' + } + + /** + * Difficultés d'attaque au contact selon le niveau de compétence (0-5). + * Incompétent/Débutant = 7, Confirmé = 5/7, Expert = 5/7/9, + * Maître/Légende = 3/5/7/9. + * @param {number} skillLevel valeur de compétence (0-5) + * @returns {number[]} + */ + static contactDifficultyOptions(skillLevel) { + switch (parseInt(skillLevel, 10) || 0) { + case 2: return [5, 7] + case 3: return [5, 7, 9] + case 4: + case 5: return [3, 5, 7, 9] + default: return [7] + } + } + + /** + * Difficultés d'attaque à distance par portée. + * @returns {{key: string, label: string, difficulty: number}[]} + */ + static rangeDifficultyOptions() { + return [ + { key: 'short', label: 'VERMINE.range_short', difficulty: 5 }, + { key: 'mid', label: 'VERMINE.range_mid', difficulty: 7 }, + { key: 'long', label: 'VERMINE.range_long', difficulty: 9 } + ] + } + + /** + * Difficultés proposées au PNJ selon son niveau d'expérience + * (champ "contact" : "7", "5 ou 7", "5,7 ou 9", "3,5,7 ou 9"). + * @param {Actor} actor + * @returns {number[]} + */ + static npcDifficultyOptions(actor) { + const level = actor.system?.experience?.value || 1 + const cfg = CONFIG.VERMINE.npcExperienceLevels?.[level] || {} + const raw = cfg.contact || '7' + return raw.split(',').flatMap(part => part.trim().split(/\s+ou\s+/).map(Number)) + } + + /** + * Mode d'attaque déduit de la compétence de l'arme. + * @param {Item|null} weapon + * @returns {string} 'contact' ou 'ranged' + */ + static modeForWeapon(weapon) { + const skill = weapon?.system?.skill || '' + return ['firearms', 'archery', 'throwing'].includes(skill) ? 'ranged' : 'contact' + } + + // ── Pool et dégâts ────────────────────────────────────────────────── + + /** + * Pool d'attaque de base (sans les bonus optionnels du dialogue). + * Personnage : caractéristique + pool de compétence. + * PNJ : pool d'attaque du niveau de menace. + * Créature : pool d'attaque calculé. + * @param {Actor} actor L'attaquant + * @param {Item|null} weapon + * @param {string} mode + * @returns {number} + */ + static baseAttackPool(actor, weapon, mode) { + const type = actor.type + if (type === 'creature') { + return actor.system?.computed?.attack || 0 + } + if (type === 'npc') { + const threat = actor.system?.threat?.value || 1 + return CONFIG.VERMINE.npcThreatLevels?.[threat]?.attack || 0 + } + const abilityKey = this.attackAbility(mode) + const ability = actor.system?.abilities?.[abilityKey]?.value || 0 + const skillKey = weapon?.system?.skill + const skillValue = skillKey ? actor.system?.skills?.[skillKey]?.value || 0 : 0 + const skillPool = skillValue ? CONFIG.VERMINE.SkillLevels?.[skillValue]?.dicePool || 0 : 0 + return ability + skillPool + } + + /** + * Dégâts de base de l'arme (hors réussites de l'attaque). + * Le trait "Vigueur+" ajoute la vigueur de l'attaquant. + * PNJ sans arme et créatures utilisent leurs dégâts propres. + * @param {Actor} actor L'attaquant + * @param {Item|null} weapon + * @returns {number} + */ + static baseDamage(actor, weapon) { + const type = actor.type + if (type === 'creature') { + return actor.system?.computed?.damage || 0 + } + if (weapon?.system?.damage) { + let base = weapon.system.damage.value || 0 + if (weapon.system.damage.addVigor) base += this.#vigor(actor) + return base + } + return this.#vigor(actor) + } + + static #vigor(actor) { + if (actor.type === 'npc') { + const threat = actor.system?.threat?.value || 1 + return CONFIG.VERMINE.npcThreatLevels?.[threat]?.vigor || 0 + } + if (actor.type === 'creature') return actor.system?.computed?.vigor || 0 + return actor.system?.abilities?.vigor?.value || 0 + } + + // ── Protection et blessures ───────────────────────────────────────── + + /** + * Protection du défenseur. + * Parade : seule la protection utilisée compte. + * Sinon : somme des protections équipées (créature : protection de rôle). + * @param {Actor} defender + * @param {number|null} parryLevel Protection utilisée lors d'une parade + * @returns {number} + */ + static protectionTotal(defender, parryLevel = null) { + if (parryLevel !== null) return Math.max(0, parryLevel) + if (defender.type === 'creature') { + return defender.system?.computed?.protection || 0 + } + const defenses = defender.itemTypes?.defense?.filter(d => d.system.equipped) || [] + return defenses.reduce((sum, d) => sum + (d.system.level || 0), 0) + } + + /** + * Catégorie de blessure : le seuil le plus élevé inférieur ou égal + * aux dégâts nets subis. + * @param {number} netDamage + * @param {Actor} defender + * @returns {string|null} 'minorWound', 'majorWound', 'deadlyWound' ou null + */ + static highestWound(netDamage, defender) { + const sys = defender.system + const deadly = sys?.deadlyWound?.threshold ?? Number.POSITIVE_INFINITY + const major = sys?.majorWound?.threshold ?? Number.POSITIVE_INFINITY + const minor = sys?.minorWound?.threshold ?? Number.POSITIVE_INFINITY + if (netDamage >= deadly) return 'deadlyWound' + if (netDamage >= major) return 'majorWound' + if (netDamage >= minor) return 'minorWound' + return null + } + + /** + * Clé i18n du libellé d'une catégorie de blessure. + * @param {string|null} category + * @returns {string} + */ + static woundLabelKey(category) { + if (category === 'deadlyWound') return 'VERMINE.wounds.deadly_wounds' + if (category === 'majorWound') return 'VERMINE.wounds.heavy_wounds' + if (category === 'minorWound') return 'VERMINE.wounds.light_wounds' + return 'VERMINE.no_wound' + } + + /** + * Vérifie que l'utilisateur peut appliquer une blessure sur l'acteur. + * @param {Actor} actor + * @returns {boolean} + */ + static canApplyWound(actor) { + return Boolean(actor) && (game.user.isGM || actor.isOwner) + } + + /** + * Applique +1 dans la catégorie de blessure indiquée (plafonné au max). + * @param {Actor} actor + * @param {string} woundCategory 'minorWound' | 'majorWound' | 'deadlyWound' + * @returns {Promise} nouvelle valeur, ou false si refusé + */ + static async applyWound(actor, woundCategory) { + if (!actor || !woundCategory) return null + if (!this.canApplyWound(actor)) return false + const current = actor.system?.[woundCategory]?.value || 0 + const max = actor.system?.[woundCategory]?.max ?? Number.POSITIVE_INFINITY + const next = Math.min(current + 1, max) + await actor.update({ [`system.${woundCategory}.value`]: next }) + return next + } + + // ── Résolution ────────────────────────────────────────────────────── + + /** + * Résout l'échange pour une cible donnée (défense la plus récente liée + * à cette cible, ou abstention si aucune défense). + * @param {ChatMessage} attackMessage + * @param {string|null} targetId id d'acteur de la cible (défaut : première cible) + * @returns {Promise} résultat de la résolution + */ + static async resolve(attackMessage, targetId = null) { + const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE) + if (!exchange) return null + + const target = (targetId && exchange.targets?.find(t => t.id === targetId)) || exchange.targets?.[0] || null + if (!target) return null + + const defs = this.#defenseMessagesFor(exchange.id, target.id) + const defenderMessage = defs[defs.length - 1] + const defense = defenderMessage ? defenderMessage.getFlag('world', FLAG_DEFENSE) : null + + const attackerSuccesses = this.#messageTotal(attackMessage) + const defenderSuccesses = defense ? this.#messageTotal(defenderMessage) : 0 + const hit = attackerSuccesses > defenderSuccesses + + // Le flag de défense porte actorId/actorUuid (clés différentes de celles + // des cibles) : on normalise la référence avant résolution de l'acteur. + const defenderRef = defense + ? { id: defense.actorId ?? null, uuid: defense.actorUuid ?? null, name: defense.actorName ?? '' } + : target + const defender = await this.#resolveDefender(defenderRef) + + const damage = (exchange.baseDamage || 0) + attackerSuccesses + const parryLevel = defense?.type === 'parade' ? (defense.parryLevel ?? 0) : null + const protection = hit && parryLevel !== null + ? parryLevel + : (defender ? this.protectionTotal(defender, hit ? null : parryLevel) : 0) + const netDamage = hit ? Math.max(0, damage - protection) : 0 + const woundCategory = hit && defender ? this.highestWound(netDamage, defender) : null + + return { + exchangeId: exchange.id, + defenderId: defenderRef.id ?? null, + defenderUuid: defenderRef.uuid ?? null, + defenderName: defense?.actorName || target.name || '', + defenseType: defense?.type || null, + attackerSuccesses, + defenderSuccesses, + hit, + damage, + protection, + netDamage, + woundCategory, + woundThreshold: woundCategory && defender ? defender.system[woundCategory].threshold : null, + canApply: Boolean(defender) && this.canApplyWound(defender) + } + } + + /** + * Rend le bloc de résolution (HTML) pour insertion dans le message. + * @param {Object} r Résultat de VermineExchange.resolve + * @returns {string} + */ + static renderResolution(r) { + const t = (key) => game.i18n.localize(key) + const verdict = !r.hit + ? (r.defenseType === 'parade' + ? `${t('VERMINE.missed_parade')}` + : `${t('VERMINE.missed_esquive')}`) + : (r.defenseType === 'parade' + ? `${t('VERMINE.hit_parade')}` + : `${t('VERMINE.hit')}`) + + const lines = [ + `
${t('VERMINE.attacker_successes')}: ${r.attackerSuccesses} · ${t('VERMINE.defender_successes')}: ${r.defenderSuccesses} → ${verdict}
` + ] + if (r.hit) { + lines.push(`
${t('VERMINE.raw_damage')}: ${r.damage}${r.protection ? ` − ${t('ADVERSITY.protection')}: ${r.protection}` : ''} = ${t('VERMINE.net_damage')}: ${r.netDamage}
`) + if (r.woundCategory) { + lines.push(`
${t('VERMINE.wound')}: ${t(this.woundLabelKey(r.woundCategory))} (${t('VERMINE.wounds.threshold')} ${r.woundThreshold}D)
`) + } else { + lines.push(`
${t('VERMINE.no_wound')}
`) + } + } else { + lines.push(`
${t('VERMINE.no_damage')}
`) + } + + const apply = (r.hit && r.woundCategory && r.canApply) + ? `` + : '' + + return `
+
${t('VERMINE.resolve_result')}
+ ${lines.join('')} + ${apply} +
` + } + + // ── Liaison chat ──────────────────────────────────────────────────── + + /** + * Lie les interactions d'échange de coups sur un message de chat. + * @param {HTMLElement} html élément du message + * @param {ChatMessage} message + */ + static bindChat(html, message) { + const exchange = message.getFlag('world', FLAG_EXCHANGE) + if (exchange) { + html.querySelectorAll('.exchange-defend').forEach(btn => { + btn.addEventListener('click', ev => this.#onDefense(ev, message)) + }) + html.querySelectorAll('.exchange-resolve').forEach(btn => { + btn.addEventListener('click', ev => this.#onResolve(ev, message)) + }) + } + html.querySelectorAll('[data-action="exchange-apply"]').forEach(btn => { + btn.addEventListener('click', ev => this.#onApply(ev, message)) + }) + } + + static async #onDefense(ev, attackMessage) { + ev.preventDefault() + ev.stopPropagation() + const btn = ev.currentTarget + const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE) + if (!exchange) return + const type = btn.dataset.type + const actor = await this.#resolveDefender({ id: btn.dataset.targetId, uuid: btn.dataset.targetUuid }) + if (!actor) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_actor_selected')) + return + } + if (!game.user.isGM && !actor.isOwner) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission')) + return + } + if (actor.type === 'creature') { + ui.notifications.warn(game.i18n.localize('VERMINE.error_creature_no_defense')) + return + } + const { default: RollDialog } = await import('./dialogs/rollDialog.mjs') + const locks = this.#defenseLocks(actor, exchange, type) + const dialog = await RollDialog.create({ + actor, + rolltype: 'skill', + label: locks.skill, + locks, + defense: { + exchangeId: exchange.id, + type, + actorId: actor.id, + actorUuid: actor.uuid, + actorName: actor.name, + difficulty: exchange.difficulty, + parryLevel: type === 'parade' ? this.#parryLevel(actor) : null + } + }) + if (dialog) dialog.render(true) + } + + static #defenseLocks(actor, exchange, type) { + if (type === 'parade') { + return { ability: 'vigor', skill: this.#parrySkill(actor), difficulty: exchange.difficulty } + } + return { + ability: 'reflexes', + skill: exchange.mode === 'ranged' ? 'alertness' : 'close', + difficulty: exchange.difficulty + } + } + + static #parrySkill(actor) { + const hasMelee = actor.itemTypes?.weapon?.some(w => w.system.equipped && w.system.skill === 'melee') + return hasMelee ? 'melee' : 'close' + } + + static #parryLevel(actor) { + const defenses = actor.itemTypes?.defense?.filter(d => d.system.equipped) || [] + return defenses.reduce((best, d) => Math.max(best, d.system.level || 0), 0) + } + + static async #onResolve(ev, attackMessage) { + ev.preventDefault() + const btn = ev.currentTarget + if (!game.user.isGM && !attackMessage.isOwner) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission')) + return + } + const exchange = attackMessage.getFlag('world', FLAG_EXCHANGE) + if (!exchange) return + if (!exchange.targets?.length) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_target')) + return + } + const wrapper = btn.closest('.exchange-actions') + const container = wrapper?.parentElement + if (!container) return + container.querySelectorAll('.exchange-resolution').forEach(el => el.remove()) + + const targets = exchange.targets?.length ? exchange.targets : [null] + for (const target of targets) { + const result = await this.resolve(attackMessage, target?.id ?? null) + if (!result) continue + const block = document.createElement('div') + block.innerHTML = this.renderResolution(result) + const node = block.firstElementChild + container.appendChild(node) + this.bindChat(node, attackMessage) + } + + const msgEl = btn.closest('.vermine-roll-message') + if (msgEl) await attackMessage.update({ content: msgEl.outerHTML }) + } + + static async #onApply(ev, attackMessage) { + ev.preventDefault() + const btn = ev.currentTarget + const defender = await this.#resolveDefender({ id: btn.dataset.defenderId, uuid: btn.dataset.defenderUuid }) + const wound = btn.dataset.wound + if (!defender || !wound) return + if (!this.canApplyWound(defender)) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_permission')) + return + } + const next = await this.applyWound(defender, wound) + if (next === false) return + btn.disabled = true + btn.innerHTML = ` ${game.i18n.localize('VERMINE.wound_applied')}` + // Persiste l'état du bouton seulement si l'utilisateur peut modifier le message + if (game.user.isGM || attackMessage.isOwner) { + const msgEl = btn.closest('.vermine-roll-message') + if (msgEl) await attackMessage.update({ content: msgEl.outerHTML }) + } + } + + // ── Helpers internes ──────────────────────────────────────────────── + + /** + * Résout un acteur depuis son id d'acteur monde (acteurs liés) ou son + * uuid (tokens non liés via fromUuid). + * @param {{id: string|null, uuid: string|null}} ref + * @returns {Promise} + */ + static async #resolveDefender({ id = null, uuid = null }) { + if (id) { + const actor = game.actors.get(id) + if (actor) return actor + } + if (uuid) { + const doc = await fromUuid(uuid) + if (!doc) return null + if (doc.documentType === 'Actor') return doc + if (doc.documentType === 'Token') return doc.actor ?? null + } + return null + } + + static #defenseMessagesFor(exchangeId, targetId = null) { + return game.messages.contents.filter(m => { + const f = m.getFlag('world', FLAG_DEFENSE) + if (!f || f.exchangeId !== exchangeId) return false + if (targetId) return f.actorId === targetId + return true + }) + } + + static #messageTotal(message) { + const el = document.createElement('div') + el.innerHTML = message.content || '' + const totalEl = el.querySelector('#total') + const val = parseInt(totalEl?.innerText, 10) + return Number.isNaN(val) ? 0 : val + } +} diff --git a/module/system/handlebars-manager.mjs b/module/system/handlebars-manager.mjs index 7bfc732..16dfb54 100644 --- a/module/system/handlebars-manager.mjs +++ b/module/system/handlebars-manager.mjs @@ -12,6 +12,7 @@ export const preloadHandlebarsTemplates = async function () { "systems/vermine2047/templates/actor/parts/actor-items.hbs", "systems/vermine2047/templates/actor/parts/actor-weapons.hbs", "systems/vermine2047/templates/actor/parts/actor-defenses.hbs", + "systems/vermine2047/templates/actor/parts/actor-equipped-gear.hbs", // Character partials. "systems/vermine2047/templates/actor/character/character-id.hbs", diff --git a/module/system/hooks.mjs b/module/system/hooks.mjs index 4b6f19d..4267e3e 100644 --- a/module/system/hooks.mjs +++ b/module/system/hooks.mjs @@ -41,6 +41,10 @@ export const registerHooks = function () { }); Hooks.on('renderChatMessageHTML', async (message, html, data) => { + // Échanges de coups : liaisons ouvertes à tous (propriétaires de cibles inclus) + const { VermineExchange } = await import('./exchange.mjs'); + VermineExchange.bindChat(html, message); + let rerollTitle = html.querySelector(".reroll-fromroll h4"); if (rerollTitle) { rerollTitle.addEventListener("click", () => { html.querySelector(".reroll").classList.toggle('visible') }) diff --git a/module/system/roll.mjs b/module/system/roll.mjs index 0677c65..4c1532b 100644 --- a/module/system/roll.mjs +++ b/module/system/roll.mjs @@ -31,7 +31,11 @@ export class VermineUtils { hasSpecialty = false, handicap = 0, poolBreakdown = null, - specialtyName = null + specialtyName = null, + weapon = null, + targets = [], + attack = null, + messageFlags = null }) { // Validate inputs if (!actor) { @@ -168,7 +172,11 @@ export class VermineUtils { hasSpecialty, handicap, poolBreakdown, - specialtyName + specialtyName, + weapon, + targets, + attack, + messageFlags }); return roll; @@ -493,6 +501,9 @@ export class VermineUtils { speaker: ChatMessage.getSpeaker(), content: content }; + if (param.messageFlags) { + chatData.flags = { world: param.messageFlags }; + } const msg = await ChatMessage.create(chatData); return msg; } diff --git a/module/vermine2047.mjs b/module/vermine2047.mjs index 2fb261b..e7f33f3 100644 --- a/module/vermine2047.mjs +++ b/module/vermine2047.mjs @@ -180,6 +180,7 @@ Hooks.once('init', async function () { "systems/vermine2047/templates/actor/appv2/npc-main.hbs", "systems/vermine2047/templates/actor/appv2/npc-characteristics.hbs", "systems/vermine2047/templates/actor/appv2/npc-skills.hbs", + "systems/vermine2047/templates/actor/appv2/npc-equipment.hbs", "systems/vermine2047/templates/actor/appv2/npc-threat.hbs", "systems/vermine2047/templates/actor/appv2/npc-combat.hbs", "systems/vermine2047/templates/actor/appv2/npc-notes.hbs", diff --git a/templates/actor/appv2/character-abilities.hbs b/templates/actor/appv2/character-abilities.hbs index f49104a..fe1bca2 100644 --- a/templates/actor/appv2/character-abilities.hbs +++ b/templates/actor/appv2/character-abilities.hbs @@ -1,4 +1,4 @@ -
+
{{!-- Character --}}

{{ localize 'VERMINE.abilities' }}

diff --git a/templates/actor/appv2/character-combat.hbs b/templates/actor/appv2/character-combat.hbs index 6b862be..be302f7 100644 --- a/templates/actor/appv2/character-combat.hbs +++ b/templates/actor/appv2/character-combat.hbs @@ -1,4 +1,4 @@ -
+

situation de combat par défaut

@@ -20,6 +20,7 @@
+ {{> "systems/vermine2047/templates/actor/parts/actor-equipped-gear.hbs"}}

@@ -42,7 +43,6 @@ unavailable {{/ifgt}}"> {{#iflteq @row @root.system.attributes.self_control.max }} - {{#if @root.isEditMode}} - {{/if}} {{/iflteq}}

@@ -88,7 +87,6 @@ unavailable {{/ifgt}}"> {{#iflteq @row @root.system.attributes.effort.max }} - {{#if @root.isEditMode}} - {{/if}} {{/iflteq}}
@@ -124,7 +121,6 @@
- {{#if @root.isEditMode}} - {{/if}}
{{/repeat}}
@@ -155,7 +150,6 @@
- {{#if @root.isEditMode}} - {{/if}}
{{/repeat}} @@ -189,7 +182,6 @@
- {{#if @root.isEditMode}} - {{/if}}
{{/repeat}} diff --git a/templates/actor/appv2/character-equipment.hbs b/templates/actor/appv2/character-equipment.hbs index 50e2fec..30ed020 100644 --- a/templates/actor/appv2/character-equipment.hbs +++ b/templates/actor/appv2/character-equipment.hbs @@ -1,4 +1,4 @@ -
+

{{localize 'ITEMS.weapons'}}

{{> "systems/vermine2047/templates/actor/parts/actor-weapons.hbs"}} diff --git a/templates/actor/appv2/character-stories.hbs b/templates/actor/appv2/character-stories.hbs index 707df2c..a0c45bd 100644 --- a/templates/actor/appv2/character-stories.hbs +++ b/templates/actor/appv2/character-stories.hbs @@ -1,4 +1,4 @@ -
+

{{ localize 'IDENTITY.theme'}}

diff --git a/templates/actor/appv2/character-totem.hbs b/templates/actor/appv2/character-totem.hbs index c140979..53c4540 100644 --- a/templates/actor/appv2/character-totem.hbs +++ b/templates/actor/appv2/character-totem.hbs @@ -1,4 +1,4 @@ -
+
{{#if system.identity.totem}}
+
{{!-- Computed Values --}}
diff --git a/templates/actor/appv2/creature-effects.hbs b/templates/actor/appv2/creature-effects.hbs index 5e27d31..79138bd 100644 --- a/templates/actor/appv2/creature-effects.hbs +++ b/templates/actor/appv2/creature-effects.hbs @@ -1,4 +1,4 @@ -
+

{{ localize "UI.effects.name" }}

    {{#each effects as |section sid|}} diff --git a/templates/actor/appv2/creature-info.hbs b/templates/actor/appv2/creature-info.hbs index 8a34d6e..c40cafb 100644 --- a/templates/actor/appv2/creature-info.hbs +++ b/templates/actor/appv2/creature-info.hbs @@ -1,4 +1,4 @@ -
    +

    {{ localize 'IDENTITY.profile' }}

    diff --git a/templates/actor/appv2/creature-stats.hbs b/templates/actor/appv2/creature-stats.hbs index a63969d..697f455 100644 --- a/templates/actor/appv2/creature-stats.hbs +++ b/templates/actor/appv2/creature-stats.hbs @@ -1,4 +1,4 @@ -
    +

    {{ localize 'VERMINE.base_values' }}

    {{!-- Total attaque (lancable) --}} diff --git a/templates/actor/appv2/group-gear.hbs b/templates/actor/appv2/group-gear.hbs index 08a9758..64561bc 100644 --- a/templates/actor/appv2/group-gear.hbs +++ b/templates/actor/appv2/group-gear.hbs @@ -1,4 +1,4 @@ -
    +
      diff --git a/templates/actor/appv2/group-info.hbs b/templates/actor/appv2/group-info.hbs index 259a9e9..1aa504e 100644 --- a/templates/actor/appv2/group-info.hbs +++ b/templates/actor/appv2/group-info.hbs @@ -1,4 +1,4 @@ -
      +
      {{!-- Group Identity --}} diff --git a/templates/actor/appv2/group-reserve.hbs b/templates/actor/appv2/group-reserve.hbs index 2de5d41..91878ba 100644 --- a/templates/actor/appv2/group-reserve.hbs +++ b/templates/actor/appv2/group-reserve.hbs @@ -1,4 +1,4 @@ -
      +
      {{!-- Group Reserve and Morale --}}
      diff --git a/templates/actor/appv2/group-road.hbs b/templates/actor/appv2/group-road.hbs index 678551d..068f394 100644 --- a/templates/actor/appv2/group-road.hbs +++ b/templates/actor/appv2/group-road.hbs @@ -1,4 +1,4 @@ -
      +
      1. {{ localize 'IDENTITY.name'}}
        diff --git a/templates/actor/appv2/npc-characteristics.hbs b/templates/actor/appv2/npc-characteristics.hbs index 3751cc8..cb3caa3 100644 --- a/templates/actor/appv2/npc-characteristics.hbs +++ b/templates/actor/appv2/npc-characteristics.hbs @@ -1,4 +1,4 @@ -
        +

        {{ localize 'VERMINE.abilities' }}

        {{!-- Caracteristiques physiques --}} diff --git a/templates/actor/appv2/npc-combat.hbs b/templates/actor/appv2/npc-combat.hbs index de167d5..1240d82 100644 --- a/templates/actor/appv2/npc-combat.hbs +++ b/templates/actor/appv2/npc-combat.hbs @@ -1,4 +1,4 @@ -
        +

        {{ localize "VERMINE.self_control"}}

        @@ -27,6 +27,7 @@

        + {{> "systems/vermine2047/templates/actor/parts/actor-equipped-gear.hbs"}}

        {{ localize "VERMINE.wounds.name"}}

          diff --git a/templates/actor/appv2/npc-equipment.hbs b/templates/actor/appv2/npc-equipment.hbs new file mode 100644 index 0000000..92e252d --- /dev/null +++ b/templates/actor/appv2/npc-equipment.hbs @@ -0,0 +1,10 @@ +
          +
          +

          {{localize 'ITEMS.weapons'}}

          + {{> "systems/vermine2047/templates/actor/parts/actor-weapons.hbs"}} +
          +
          +

          {{localize 'ITEMS.defenses'}}

          + {{> "systems/vermine2047/templates/actor/parts/actor-defenses.hbs"}} +
          +
          diff --git a/templates/actor/appv2/npc-notes.hbs b/templates/actor/appv2/npc-notes.hbs index b0fbe82..a10f7e9 100644 --- a/templates/actor/appv2/npc-notes.hbs +++ b/templates/actor/appv2/npc-notes.hbs @@ -1,4 +1,4 @@ -
          +

          {{ localize 'IDENTITY.notes' }}

          diff --git a/templates/actor/appv2/npc-skills.hbs b/templates/actor/appv2/npc-skills.hbs index f9c207d..bc57b65 100644 --- a/templates/actor/appv2/npc-skills.hbs +++ b/templates/actor/appv2/npc-skills.hbs @@ -1,4 +1,4 @@ -
          +

          {{ localize 'VERMINE.skills' }}

          {{!-- Category Selector --}} diff --git a/templates/actor/appv2/npc-threat.hbs b/templates/actor/appv2/npc-threat.hbs index 149f3b1..0925240 100644 --- a/templates/actor/appv2/npc-threat.hbs +++ b/templates/actor/appv2/npc-threat.hbs @@ -1,4 +1,4 @@ -
          +

          {{ localize 'ADVERSITY.threat_details' }}

          {{!-- Menace --}} diff --git a/templates/actor/parts/actor-defenses.hbs b/templates/actor/parts/actor-defenses.hbs index be21830..e05794b 100644 --- a/templates/actor/parts/actor-defenses.hbs +++ b/templates/actor/parts/actor-defenses.hbs @@ -15,6 +15,7 @@ data-type="defense" data-action="create" > +
          @@ -42,6 +43,13 @@ +{{/if}} + +{{#if equippedDefenses.length}} +
          +

          {{ localize 'ITEMS.defenses'}}

          +
            + {{#each equippedDefenses as |item|}} +
          1. +
            +
            + +
            + {{item.name}} +
            + + +
          2. + {{/each}} +
          +
          +{{/if}} diff --git a/templates/actor/parts/actor-weapons.hbs b/templates/actor/parts/actor-weapons.hbs index 161933d..db358da 100644 --- a/templates/actor/parts/actor-weapons.hbs +++ b/templates/actor/parts/actor-weapons.hbs @@ -9,6 +9,7 @@
          +
          @@ -37,10 +38,15 @@
          + +
          diff --git a/templates/actor/parts/npc-skill-item.hbs b/templates/actor/parts/npc-skill-item.hbs index 64f7af6..9eef9aa 100644 --- a/templates/actor/parts/npc-skill-item.hbs +++ b/templates/actor/parts/npc-skill-item.hbs @@ -8,6 +8,7 @@ {{/if}}
          + {{#if @root.isEditMode}} + {{/if}} {{skill.value}}D
          diff --git a/templates/dialogs/combat-dialog.hbs b/templates/dialogs/combat-dialog.hbs new file mode 100644 index 0000000..8b341b9 --- /dev/null +++ b/templates/dialogs/combat-dialog.hbs @@ -0,0 +1,234 @@ +
          + + + + + {{#if weapon}} +
          +
          + + {{ weapon.name }} +
          +
          + + {{localize "VERMINE.damages"}}: + {{#if weapon.system.damage.addVigor}}{{localize "VERMINE.vigor"}}+ {{/if}} + {{ weapon.system.damage.value }} ({{ weapon.system.damage.type }}) + + + {{localize "VERMINE.ranges"}}: + {{ weapon.system.min_range }} / {{ weapon.system.max_range }} + + + {{localize "VERMINE.ammo"}}: + {{ weapon.system.ammo }} + +
          +
          + {{/if}} + +

          {{#ife mode "ranged"}}{{localize 'VERMINE.attack_ranged'}}{{else}}{{localize 'VERMINE.attack_contact'}}{{/ife}}

          + +
          + + +
          +
          + + {{#if isCharacter}} +
          {{ smarttlk 'ABILITIES' abilityKey 'name' }} / {{ abilityValue }}
          + {{else if isNpc}} +
          {{localize 'ADVERSITY.threat'}} / {{ npcThreatPool }}
          + {{else}} +
          {{localize 'ADVERSITY.attack'}} / {{ basePool }}
          + {{/if}} +
          +
          + + {{#if isCharacter}} +
          {{ smarttlk 'SKILLS' skillKey 'name' }} / {{ skillValue }}
          + {{else}} +
          {{localize 'VERMINE.pool_total'}}
          + {{/if}} +
          +
          + + +
          + + {{localize 'VERMINE.difficulty'}} + {{ defaultDifficulty }} + +
          +
          + {{#each difficultyOptions}} + + {{/each}} +
          + {{#unless isCreature}} + {{#unless creatureLocked}} + + {{/unless}} + {{/unless}} + {{#if hasAmmo}} + + {{/if}} +
          + + +
          +
          +
          + + + {{#if isNpc}} +
          + + + / {{ npcPoolMax }} +
          + {{/if}} + + + {{#if targets.length}} +
          + {{localize 'ITEMS.targets'}}: + {{#each targets}} + {{ this.name }} + {{/each}} +
          + {{/if}} + + +
          + + {{localize 'VERMINE.bonuses'}} + +0D + +
          + +
          + + +
          + +
          + + + D +
          + + {{#if isCharacter}} +
          + + +
          + {{/if}} + + {{#if availableItems.length}} +
          + +
          + + {{#each availableItems as |item ind|}} + + {{/each}} +
          +
          + {{/if}} + + {{#ifgt @root.system.adaptation.totems.human.value 0}} +
          + +
          + + {{#ifgt @root.system.adaptation.totems.adapted.value 0}} + + {{/ifgt}} +
          +
          + {{/ifgt}} + +
          +
          + + +
          + + 0D + {{#ifgt @root.system.adaptation.totems.human.value 0}} + {{#ifgt @root.system.adaptation.totems.adapted.value 0}} + + ({{localize 'VERMINE.keep_totem'}} + ) + + {{/ifgt}} + {{/ifgt}} + + ({{localize 'VERMINE.difficulty'}}: {{ defaultDifficulty }}) + +
          + +
          + +
          + + +
          +
          diff --git a/templates/item/item-item-sheet.hbs b/templates/item/item-item-sheet.hbs index 81a5dc2..32cea79 100644 --- a/templates/item/item-item-sheet.hbs +++ b/templates/item/item-item-sheet.hbs @@ -15,7 +15,7 @@ {{#each @root.config.skillCategories as |skillCategory sckey|}} - {{#each @root.config.skills as |skill key|}} + {{#each (sortedSkills @root.config.skills) as |skill key|}} {{#ife skill.category sckey}} diff --git a/templates/item/item-specialty-sheet.hbs b/templates/item/item-specialty-sheet.hbs index a256de3..c98cf83 100644 --- a/templates/item/item-specialty-sheet.hbs +++ b/templates/item/item-specialty-sheet.hbs @@ -20,7 +20,7 @@ > {{#each @root.config.skillCategories as |skillCategory sckey|}} - {{#each @root.config.skills as |skill key|}} + {{#each (sortedSkills @root.config.skills) as |skill key|}} {{#ife skill.category sckey}} - {{#each @root.config.skills as |skill key|}} + {{#each (sortedSkills @root.config.skills) as |skill key|}} {{#ife skill.category sckey}}