Gestion des traits d'arme et des munitions

This commit is contained in:
2026-04-21 18:50:49 +02:00
parent 74f1b581f7
commit 6ef454d533
22 changed files with 1448 additions and 195 deletions

View File

@@ -52,17 +52,18 @@ export class TravellerActor extends Actor {
}
}
applyDamage(amount, { ignoreArmor = false } = {}) {
applyDamage(amount, { ignoreArmor = false, ap = 0, stun = false } = {}) {
if (this.type === "character") {
return ActorCharacter.applyDamage(this, amount, { ignoreArmor });
return ActorCharacter.applyDamage(this, amount, { ignoreArmor, ap, stun });
} else if (this.type === "creature") {
if (isNaN(amount) || amount === 0) return;
if (isNaN(amount) || amount === 0) return Promise.resolve({ incapRounds: 0 });
if (amount < 0) amount = Math.abs(amount);
const armorValue = ignoreArmor ? 0 : (this.system.armor ?? 0);
const rawArmor = ignoreArmor ? 0 : (this.system.armor ?? 0);
const armorValue = Math.max(0, rawArmor - ap);
const effective = Math.max(0, amount - armorValue);
if (effective === 0) return;
if (effective === 0) return Promise.resolve({ incapRounds: 0 });
const newValue = Math.max(0, (this.system.life.value ?? 0) - effective);
return this.update({ "system.life.value": newValue });
return this.update({ "system.life.value": newValue }).then(() => ({ incapRounds: 0 }));
}
}

View File

@@ -51,13 +51,14 @@ export class CharacterPrompts {
}
static async openEditorFullView(title, html) {
const safeTitle = title || game.i18n.localize("MGT2.Actor.Species") || "Species";
const htmlContent = await renderTemplate("systems/mgt2/templates/editor-fullview.html", {
config: CONFIG.MGT2,
html
html: html ?? ""
});
const theme = game.settings.get("mgt2", "theme");
await DialogV2.wait({
window: { title },
window: { title: safeTitle },
content: htmlContent,
rejectClose: false,
buttons: [

View File

@@ -254,7 +254,10 @@ export class ActorCharacter {
}
updateData["system.inventory.weight"] = onHandWeight;
updateData["system.states.encumbrance"] = onHandWeight > $this.system.inventory.encumbrance.normal;
// Use the threshold from updateData if it was just recalculated (e.g. STR/END talent change),
// otherwise fall back to the persisted value.
const encumbranceThreshold = updateData["system.inventory.encumbrance.normal"] ?? $this.system.inventory.encumbrance.normal;
updateData["system.states.encumbrance"] = onHandWeight > encumbranceThreshold;
await $this.update(updateData);
@@ -275,6 +278,8 @@ export class ActorCharacter {
let heavy = normal * 2;
foundry.utils.setProperty(changed, "system.inventory.encumbrance.normal", normal);
foundry.utils.setProperty(changed, "system.inventory.encumbrance.heavy", heavy);
// Also update the encumbrance state flag against the new threshold
foundry.utils.setProperty(changed, "system.states.encumbrance", $this.system.inventory.weight > normal);
}
//console.log(foundry.utils.getProperty(changed, "system.characteristics.strength.value"));
@@ -331,25 +336,39 @@ export class ActorCharacter {
// $this.update({ system: { characteristics: data } });
// }
static applyDamage($this, amount, { ignoreArmor = false } = {}) {
if (isNaN(amount) || amount === 0) return;
static async applyDamage($this, amount, { ignoreArmor = false, ap = 0, stun = false } = {}) {
if (isNaN(amount) || amount === 0) return { incapRounds: 0 };
const rank1 = $this.system.config.damages.rank1;
const rank2 = $this.system.config.damages.rank2;
const rank3 = $this.system.config.damages.rank3;
if (amount < 0) amount = Math.abs(amount);
if (!ignoreArmor) {
const rawArmor = $this.system.inventory?.armor ?? 0;
const armorValue = Math.max(0, rawArmor - ap);
amount = Math.max(0, amount - armorValue);
if (amount === 0) return { incapRounds: 0 };
}
// ── Stun / Incapacitating: only deduct from endurance (rank3) ─────────
if (stun) {
const endKey = rank3; // "endurance" by default
const prevEnd = $this.system.characteristics[endKey].value;
const newEnd = Math.max(0, prevEnd - amount);
const incapRounds = newEnd === 0 ? Math.max(0, amount - prevEnd) : 0;
await $this.update({
system: { characteristics: { [endKey]: { value: newEnd, dm: this.getModifier(newEnd) } } }
});
return { incapRounds };
}
// ── Normal damage cascade: rank1 → rank2 → rank3 ────────────────────
const data = {};
data[rank1] = { value: $this.system.characteristics[rank1].value };
data[rank2] = { value: $this.system.characteristics[rank2].value };
data[rank3] = { value: $this.system.characteristics[rank3].value };
if (amount < 0) amount = Math.abs(amount);
if (!ignoreArmor) {
const armorValue = $this.system.inventory?.armor ?? 0;
amount = Math.max(0, amount - armorValue);
if (amount === 0) return;
}
for (const [key, rank] of Object.entries(data)) {
if (rank.value > 0) {
if (rank.value >= amount) {
@@ -364,7 +383,8 @@ export class ActorCharacter {
}
}
$this.update({ system: { characteristics: data } });
await $this.update({ system: { characteristics: data } });
return { incapRounds: 0 };
}
static applyHealing($this, amount, type) {

View File

@@ -3,6 +3,7 @@ import { MGT2 } from "../../config.js";
import { MGT2Helper } from "../../helper.js";
import { RollPromptHelper } from "../../roll-prompt.js";
import { CharacterPrompts } from "../../actors/character-prompts.js";
import WeaponData from "../../models/items/weapon.mjs";
export default class TravellerCharacterSheet extends MGT2ActorSheet {
@@ -200,8 +201,7 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
i._range = i.system.range.isMelee
? game.i18n.localize("MGT2.Melee")
: MGT2Helper.getRangeDisplay(i.system.range);
if (i.system.traits?.length > 0)
i._subInfo = i.system.traits.map(x => x.name).join(", ");
i._subInfo = WeaponData.getTraitsSummary(i.system.traits);
weapons.push(i);
break;
@@ -589,7 +589,14 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
encumbrance: this.actor.system.states.encumbrance,
difficulty: null,
damageFormula: null,
damageAP: 0,
blastRadius: 0,
stun: false,
radiation: false,
isMelee: false,
isRanged: false,
bulky: false,
veryBulky: false,
};
const cardButtons = [];
@@ -659,6 +666,18 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
rollOptions.damageFormula = itemObj.system.damage;
if (itemObj.type === "weapon") {
rollOptions.isMelee = itemObj.system.range?.isMelee === true;
rollOptions.isRanged = itemObj.type === "weapon" && !itemObj.system.range?.isMelee;
rollOptions.damageAP = itemObj.system.traits?.ap ?? 0;
rollOptions.blastRadius = itemObj.system.traits?.blast ?? 0;
rollOptions.stun = itemObj.system.traits?.stun === true;
rollOptions.radiation = itemObj.system.traits?.radiation === true;
rollOptions.scope = itemObj.system.traits?.scope === true;
rollOptions.zeroG = itemObj.system.traits?.zeroG === true;
rollOptions.autoLevel = itemObj.system.traits?.auto ?? 0;
rollOptions.itemId = itemObj._id;
rollOptions.magazine = itemObj.system.magazine ?? -1; // -1 = not tracked
rollOptions.bulky = itemObj.system.traits?.bulky === true;
rollOptions.veryBulky = itemObj.system.traits?.veryBulky === true;
}
if (itemObj.type === "disease") {
if (itemObj.system.subType === "disease")
@@ -680,6 +699,10 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
const rollModifiers = [];
const rollFormulaParts = [];
// Auto trait — fire mode
const autoLevel = rollOptions.autoLevel ?? 0;
const autoMode = autoLevel > 0 ? (userRollData.autoMode ?? "single") : "single";
if (userRollData.diceModifier) {
rollFormulaParts.push("3d6", userRollData.diceModifier);
} else {
@@ -730,14 +753,127 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.CustomDM") + " " + (customDMVal > 0 ? `+${customDMVal}` : `${customDMVal}`));
}
if (rollOptions.isRanged) {
const rangedRange = parseInt(userRollData.rangedRange ?? "0", 10);
if (rangedRange === 1) {
rollFormulaParts.push("+1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeShort") + " +1");
} else if (rangedRange === -2) {
rollFormulaParts.push("-2");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeLong") + " 2");
} else if (rangedRange === -4) {
rollFormulaParts.push("-4");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.RangeExtreme") + " 4");
}
const rangedAim = parseInt(userRollData.rangedAim ?? "0", 10);
// Auto: burst/full-auto cancels all aiming advantages (rules p.75)
if (rangedAim > 0 && autoMode === "single") {
rollFormulaParts.push(`+${rangedAim}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Aim") + ` +${rangedAim}`);
if (userRollData.rangedLaserSight === true || userRollData.rangedLaserSight === "true") {
rollFormulaParts.push("+1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.LaserSight") + " +1");
}
}
const rangedFastTarget = parseInt(userRollData.rangedFastTarget ?? "0", 10);
if (rangedFastTarget < 0) {
rollFormulaParts.push(`${rangedFastTarget}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.FastTarget") + ` ${rangedFastTarget}`);
}
if (userRollData.rangedCover === true || userRollData.rangedCover === "true") {
rollFormulaParts.push("-2");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Cover") + " 2");
}
if (userRollData.rangedProne === true || userRollData.rangedProne === "true") {
rollFormulaParts.push("-1");
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Prone") + " 1");
}
if (userRollData.rangedDodge === true || userRollData.rangedDodge === "true") {
const dodgeDM = parseInt(userRollData.rangedDodgeDM ?? "0", 10);
if (dodgeDM < 0) {
rollFormulaParts.push(`${dodgeDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Dodge") + ` ${dodgeDM}`);
}
}
}
if (rollOptions.isMelee) {
if (userRollData.meleeDodge === true || userRollData.meleeDodge === "true") {
const dodgeDM = parseInt(userRollData.meleeDodgeDM ?? "0", 10);
if (dodgeDM < 0) {
rollFormulaParts.push(`${dodgeDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Dodge") + ` ${dodgeDM}`);
}
}
if (userRollData.meleeParry === true || userRollData.meleeParry === "true") {
const parryDM = parseInt(userRollData.meleeParryDM ?? "0", 10);
if (parryDM < 0) {
rollFormulaParts.push(`${parryDM}`);
rollModifiers.push(game.i18n.localize("MGT2.RollPrompt.Parry") + ` ${parryDM}`);
}
}
}
if (MGT2Helper.hasValue(userRollData, "difficulty") && userRollData.difficulty !== "") rollOptions.difficulty = userRollData.difficulty;
// ── Bulky / Very Bulky trait: STR penalty ────────────────────────────
const strDm = this.actor.system.characteristics.strength?.dm ?? 0;
if (rollOptions.veryBulky) {
// Very Bulky: requires STR DM ≥ +2
if (strDm < 2) {
const penalty = strDm - 2;
rollFormulaParts.push(`${penalty}`);
rollModifiers.push(game.i18n.localize("MGT2.WeaponTraits.VeryBulky") + ` ${penalty}`);
ui.notifications.warn(game.i18n.format("MGT2.Notifications.VeryBulkyPenalty", { penalty }));
}
} else if (rollOptions.bulky) {
// Bulky: requires STR DM ≥ +1
if (strDm < 1) {
const penalty = strDm - 1;
rollFormulaParts.push(`${penalty}`);
rollModifiers.push(game.i18n.localize("MGT2.WeaponTraits.Bulky") + ` ${penalty}`);
ui.notifications.warn(game.i18n.format("MGT2.Notifications.BulkyPenalty", { penalty }));
}
}
const rollFormula = rollFormulaParts.join("");
if (!Roll.validate(rollFormula)) {
ui.notifications.error(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
// ── Ammo decrement (ranged weapons with magazine tracking) ───────────
if (rollOptions.isRanged && rollOptions.itemId && rollOptions.magazine >= 0) {
let ammoUsed = 1;
if (autoMode === "burst" && autoLevel > 0) ammoUsed = autoLevel;
else if (autoMode === "fullAuto" && autoLevel > 0) ammoUsed = autoLevel * 3;
const currentMag = rollOptions.magazine;
if (currentMag <= 0) {
ui.notifications.warn(
game.i18n.format("MGT2.Notifications.NoAmmo", { weapon: rollOptions.rollObjectName })
);
} else {
const newMag = Math.max(0, currentMag - ammoUsed);
const weaponItem = this.actor.getEmbeddedDocument("Item", rollOptions.itemId);
if (weaponItem) await weaponItem.update({ "system.magazine": newMag });
if (newMag === 0) {
ui.notifications.warn(
game.i18n.format("MGT2.Notifications.AmmoEmpty", { weapon: rollOptions.rollObjectName })
);
} else {
ui.notifications.info(
game.i18n.format("MGT2.Notifications.AmmoUsed", { used: ammoUsed, remaining: newMag, weapon: rollOptions.rollObjectName })
);
}
}
}
let roll = await new Roll(rollFormula, this.actor.getRollData()).roll({ rollMode: userRollData.rollMode });
if (isInitiative && this.token?.combatant) {
@@ -759,7 +895,7 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
rollFailure = !rollSuccess;
}
// Build effective damage formula: base + effect + STR DM (melee)
// Build effective damage formula: base + effect + STR DM (melee) + Auto burst
let effectiveDamageFormula = rollOptions.damageFormula || null;
if (effectiveDamageFormula) {
if (rollEffect !== undefined && rollEffect !== 0) {
@@ -769,6 +905,18 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
const strDm = this.actor.system.characteristics.strength?.dm ?? 0;
if (strDm !== 0) effectiveDamageFormula += (strDm >= 0 ? "+" : "") + strDm;
}
// Burst: add Auto level to damage
if (autoMode === "burst" && autoLevel > 0) {
effectiveDamageFormula += `+${autoLevel}`;
}
}
// Auto fire mode chat info
let autoInfo = null;
if (autoMode === "burst" && autoLevel > 0) {
autoInfo = game.i18n.format("MGT2.RollPrompt.AutoBurstInfo", { level: autoLevel, ammo: autoLevel });
} else if (autoMode === "fullAuto" && autoLevel > 0) {
autoInfo = game.i18n.format("MGT2.RollPrompt.AutoFullInfo", { level: autoLevel, ammo: autoLevel * 3 });
}
// ── Build roll breakdown tooltip ─────────────────────────────────────
@@ -795,6 +943,7 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
// Show damage button only if there's a formula AND (no difficulty check OR roll succeeded)
showRollDamage: !!effectiveDamageFormula && (!difficultyValue || rollSuccess),
cardButtons: cardButtons,
autoInfo,
};
if (MGT2Helper.hasValue(rollOptions, "difficulty")) {
@@ -811,7 +960,7 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
let flags = null;
if (effectiveDamageFormula) {
flags = { mgt2: { damage: { formula: effectiveDamageFormula, rollObjectName: rollOptions.rollObjectName, rollTypeName: rollOptions.rollTypeName } } };
flags = { mgt2: { damage: { formula: effectiveDamageFormula, ap: rollOptions.damageAP ?? 0, blast: rollOptions.blastRadius ?? 0, stun: rollOptions.stun ?? false, radiation: rollOptions.radiation ?? false, rollObjectName: rollOptions.rollObjectName, rollTypeName: rollOptions.rollTypeName } } };
}
if (cardButtons.length > 0) {
if (!flags) flags = { mgt2: {} };
@@ -895,17 +1044,18 @@ export default class TravellerCharacterSheet extends MGT2ActorSheet {
static async #onOpenEditor(event) {
event.preventDefault();
await CharacterPrompts.openEditorFullView(
this.actor.system.personal.species,
this.actor.system.personal.speciesText.descriptionLong
);
const title = this.actor.system.personal.species
|| game.i18n.localize("MGT2.Actor.Species")
|| "Species";
const html = this.actor.system.personal.speciesText?.descriptionLong ?? "";
await CharacterPrompts.openEditorFullView(title, html);
}
static async #onHeal(event, target) {
event.preventDefault();
const healType = target.dataset.healType;
if (canvas.tokens.controlled.length === 0) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}

View File

@@ -97,7 +97,7 @@ export default class TravellerCreatureSheet extends MGT2ActorSheet {
// ───────────────────────────────────────────────────────── Roll Helpers
static async #postCreatureRoll({ actor, roll, rollLabel, dm, difficulty, difficultyLabel, rollMode, extraTooltip }) {
static async #postCreatureRoll({ actor, roll, rollLabel, dm, difficulty, difficultyLabel, rollMode, extraTooltip, damageFormula }) {
const diffTarget = MGT2Helper.getDifficultyValue(difficulty ?? "Average");
const hasDifficulty = !!difficulty;
const success = hasDifficulty ? roll.total >= diffTarget : true;
@@ -111,6 +111,8 @@ export default class TravellerCreatureSheet extends MGT2ActorSheet {
if (extraTooltip) breakdownParts.push(extraTooltip);
const rollBreakdown = breakdownParts.join(" | ");
const showRollDamage = success && !!damageFormula;
const chatData = {
creatureName: actor.name,
creatureImg: actor.img,
@@ -126,6 +128,7 @@ export default class TravellerCreatureSheet extends MGT2ActorSheet {
effect: hasDifficulty ? effect : null,
effectStr: hasDifficulty ? effectStr : null,
modifiers: dm !== 0 ? [`DM ${dm >= 0 ? "+" : ""}${dm}`] : [],
showRollDamage,
};
const chatContent = await renderTemplate(
@@ -133,11 +136,23 @@ export default class TravellerCreatureSheet extends MGT2ActorSheet {
chatData
);
const flags = showRollDamage ? {
mgt2: {
damage: {
formula: normalizeDice(damageFormula),
effect,
rollObjectName: actor.name,
rollTypeName: rollLabel,
}
}
} : {};
await ChatMessage.create({
content: chatContent,
speaker: ChatMessage.getSpeaker({ actor }),
rolls: [roll],
rollMode: rollMode ?? game.settings.get("core", "rollMode"),
flags,
});
return { success, effect, total: roll.total };
@@ -238,24 +253,14 @@ export default class TravellerCreatureSheet extends MGT2ActorSheet {
if (chosenSkill) tooltipParts.push(`${chosenSkill.name} ${skillLevel >= 0 ? "+" : ""}${skillLevel}`);
if (customDM !== 0) tooltipParts.push(`MD perso ${customDM >= 0 ? "+" : ""}${customDM}`);
const { success } = await TravellerCreatureSheet.#postCreatureRoll({
await TravellerCreatureSheet.#postCreatureRoll({
actor, roll, rollLabel,
dm,
difficulty: result.difficulty,
rollMode: result.rollMode,
extraTooltip: tooltipParts.join(" | "),
damageFormula: attack.damage || null,
});
// Roll damage only on success
if (success && attack.damage) {
const dmgFormula = normalizeDice(attack.damage);
const dmgRoll = await new Roll(dmgFormula).evaluate();
await dmgRoll.toMessage({
speaker: ChatMessage.getSpeaker({ actor }),
flavor: `<strong>${actor.name}</strong> — ${game.i18n.localize("MGT2.Chat.Weapon.Damage")}: ${attack.name} (${attack.damage})`,
rollMode: result.rollMode ?? game.settings.get("core", "rollMode"),
});
}
}
// ───────────────────────────────────────────────────────── CRUD Handlers

View File

@@ -6,12 +6,45 @@ export class ChatHelper {
if (!message || !element) {
return;
}
// Restore disabled state for already-applied buttons
const appliedActions = message.flags?.mgt2?.appliedActions ?? [];
if (appliedActions.length > 0) {
appliedActions.forEach(action => {
element.querySelectorAll(`button[data-action="${action}"]`).forEach(btn => {
btn.disabled = true;
});
});
}
// Apply buttons are GM-only: hide them for players
const GM_ACTIONS = ["damage", "healing", "surgeryDamage"];
if (!game.user.isGM) {
GM_ACTIONS.forEach(action => {
element.querySelectorAll(`button[data-action="${action}"]`).forEach(btn => {
btn.style.display = "none";
});
});
// Hide the buttons container if no visible buttons remain
element.querySelectorAll(".mgt2-buttons").forEach(container => {
const hasVisible = [...container.querySelectorAll("button")]
.some(b => b.style.display !== "none");
if (!hasVisible) container.style.display = "none";
});
}
element.querySelectorAll('button[data-action="rollDamage"]').forEach(el => {
el.addEventListener('click', async event => {
await this._processRollDamageButtonEvent(message, event);
});
});
element.querySelectorAll('button[data-action="rollRadiation"]').forEach(el => {
el.addEventListener('click', async event => {
await this._rollRadiationDamage(message, event);
});
});
element.querySelectorAll('button[data-action="damage"]').forEach(el => {
el.addEventListener('click', async event => {
await this._applyChatCardDamage(message, event);
@@ -63,74 +96,143 @@ export class ChatHelper {
static async _processRollDamageButtonEvent(message, event) {
event.preventDefault();
event.stopPropagation();
let rollFormula = message.flags.mgt2.damage.formula;
let roll = await new Roll(rollFormula, {}).roll();
let speaker;
let selectTokens = canvas.tokens.controlled;
if (selectTokens.length > 0) {
speaker = selectTokens[0].actor;
} else {
speaker = game.user.character;
const damageFlags = message.flags?.mgt2?.damage;
if (!damageFlags?.formula) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
let rollTypeName = message.flags.mgt2.damage.rollTypeName ? message.flags.mgt2.damage.rollTypeName + " " + game.i18n.localize("MGT2.Actor.Damage") : null;
const effect = damageFlags.effect ?? 0;
const effectPart = effect > 0 ? `+${effect}` : effect < 0 ? `${effect}` : "";
const rollFormula = damageFlags.formula + effectPart;
let roll;
try {
roll = await new Roll(rollFormula, {}).roll();
} catch (e) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.InvalidRollFormula"));
return;
}
const rollTypeName = damageFlags.rollTypeName
? damageFlags.rollTypeName + " " + game.i18n.localize("MGT2.Actor.Damage")
: null;
const ap = damageFlags.ap ?? 0;
const blast = damageFlags.blast ?? 0;
const stun = damageFlags.stun ?? false;
const radiation = damageFlags.radiation ?? false;
const chatData = {
user: game.user.id,
speaker: ChatMessage.getSpeaker({ actor: speaker }),
speaker: message.speaker,
formula: roll._formula,
tooltip: await roll.getTooltip(),
total: Math.round(roll.total * 100) / 100,
showButtons: true,
hasDamage: true,
rollTypeName: rollTypeName,
rollObjectName: message.flags.mgt2.damage.rollObjectName
rollTypeName,
rollObjectName: damageFlags.rollObjectName,
apValue: ap > 0 ? ap : null,
blastRadius: blast > 0 ? blast : null,
stunWeapon: stun || null,
radiationWeapon: radiation || null,
};
const html = await foundry.applications.handlebars.renderTemplate("systems/mgt2/templates/chat/roll.html", chatData);
chatData.content = html;
// Persist ap, blast, stun, radiation in damage message flags so handlers can read them
if (ap > 0 || blast > 0 || stun || radiation) chatData.flags = { mgt2: { damage: { ap, blast, stun, radiation, rollObjectName: damageFlags.rollObjectName } } };
return roll.toMessage(chatData);
}
static _applyChatCardDamage(message, event) {
if (canvas.tokens.controlled.length === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
static async #markButtonApplied(message, btn, action) {
const existing = message.flags?.mgt2?.appliedActions ?? [];
if (!existing.includes(action)) {
await message.setFlag("mgt2", "appliedActions", [...existing, action]);
}
const roll = message.rolls[0];
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(roll.total);
}));
if (btn) btn.disabled = true;
}
static _applyChatCardHealing(message, event) {
if (canvas.tokens.controlled.length === 0) {
static async _applyChatCardDamage(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
// For First Aid/Surgery healing, use amount from flags; otherwise use roll total
const btn = event.currentTarget;
const roll = message.rolls[0];
const amount = Math.round(roll.total * 100) / 100;
const ap = message.flags?.mgt2?.damage?.ap ?? 0;
const stun = message.flags?.mgt2?.damage?.stun ?? false;
await Promise.all([...game.user.targets].map(async t => {
const result = await t.actor.applyDamage(amount, { ap, stun });
if (stun) {
const incapRounds = result?.incapRounds ?? 0;
if (incapRounds > 0) {
ui.notifications.warn(game.i18n.format("MGT2.Notifications.StunIncapacitated", { name: t.actor.name, rounds: incapRounds }));
} else {
ui.notifications.info(game.i18n.format("MGT2.Notifications.StunDamageApplied", { name: t.actor.name, amount }));
}
} else {
const notifKey = ap > 0 ? "MGT2.Notifications.DamageAppliedAP" : "MGT2.Notifications.DamageApplied";
ui.notifications.info(game.i18n.format(notifKey, { name: t.actor.name, amount, ap }));
}
}));
await ChatHelper.#markButtonApplied(message, btn, "damage");
}
static async _applyChatCardHealing(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
const btn = event.currentTarget;
const amount = message.flags?.mgt2?.healing?.amount
?? message.flags?.mgt2?.surgery?.healing
?? Math.max(1, message.rolls[0].total);
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyHealing(amount);
await Promise.all([...game.user.targets].map(async t => {
await t.actor.applyHealing(amount);
ui.notifications.info(game.i18n.format("MGT2.Notifications.HealingApplied", { name: t.actor.name, amount }));
}));
await ChatHelper.#markButtonApplied(message, btn, "healing");
}
static _applyChatCardSurgeryDamage(message, event) {
if (canvas.tokens.controlled.length === 0) {
static async _applyChatCardSurgeryDamage(message, event) {
if (game.user.targets.size === 0) {
ui.notifications.warn(game.i18n.localize("MGT2.Errors.NoTokenSelected"));
return;
}
const btn = event.currentTarget;
const amount = message.flags?.mgt2?.surgery?.surgeryDamage ?? 3;
return Promise.all(canvas.tokens.controlled.map(t => {
const a = t.actor;
return a.applyDamage(amount, { ignoreArmor: true });
await Promise.all([...game.user.targets].map(async t => {
await t.actor.applyDamage(amount, { ignoreArmor: true });
ui.notifications.info(game.i18n.format("MGT2.Notifications.DamageApplied", { name: t.actor.name, amount }));
}));
await ChatHelper.#markButtonApplied(message, btn, "surgeryDamage");
}
static async _rollRadiationDamage(message, event) {
event.preventDefault();
event.stopPropagation();
const damageFlags = message.flags?.mgt2?.damage;
const rollObjectName = damageFlags?.rollObjectName ?? "";
// 2D × 20 rads (MGT2 rule: 2d6 × 20)
const roll = await new Roll("2d6 * 20", {}).roll();
const chatData = {
user: game.user.id,
speaker: message.speaker,
formula: roll._formula,
tooltip: await roll.getTooltip(),
total: Math.round(roll.total),
rollObjectName,
};
const html = await foundry.applications.handlebars.renderTemplate("systems/mgt2/templates/chat/radiation.html", chatData);
chatData.content = html;
return roll.toMessage(chatData);
}
}

View File

@@ -13,12 +13,18 @@ export default class WeaponData extends PhysicalItemData {
schema.damage = new fields.StringField({ required: false, blank: true, trim: true });
schema.magazine = new fields.NumberField({ required: false, initial: 0, min: 0, integer: true });
schema.magazineCost = new fields.NumberField({ required: false, initial: 0, min: 0, integer: true });
schema.traits = new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true, blank: true, trim: true }),
description: new fields.StringField({ required: false, blank: true, trim: true })
})
);
schema.traits = new fields.SchemaField({
ap: new fields.NumberField({ required: false, initial: 0, min: 0, integer: true }),
auto: new fields.NumberField({ required: false, initial: 0, min: 0, integer: true }),
blast: new fields.NumberField({ required: false, initial: 0, min: 0, integer: true }),
bulky: new fields.BooleanField({ required: false, initial: false }),
veryBulky: new fields.BooleanField({ required: false, initial: false }),
stun: new fields.BooleanField({ required: false, initial: false }),
smart: new fields.BooleanField({ required: false, initial: false }),
radiation: new fields.BooleanField({ required: false, initial: false }),
scope: new fields.BooleanField({ required: false, initial: false }),
zeroG: new fields.BooleanField({ required: false, initial: false })
});
schema.options = new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true, blank: true, trim: true }),
@@ -27,4 +33,21 @@ export default class WeaponData extends PhysicalItemData {
);
return schema;
}
/** Returns a compact display string of active traits (e.g. "AP 2, Auto 3, Blast 5, Bulky") */
static getTraitsSummary(traits) {
if (!traits) return "";
const parts = [];
if (traits.ap > 0) parts.push(`AP ${traits.ap}`);
if (traits.auto > 0) parts.push(`Auto ${traits.auto}`);
if (traits.blast > 0) parts.push(`Blast ${traits.blast}`);
if (traits.bulky) parts.push(game.i18n.localize("MGT2.WeaponTraits.Bulky"));
if (traits.veryBulky) parts.push(game.i18n.localize("MGT2.WeaponTraits.VeryBulky"));
if (traits.stun) parts.push(game.i18n.localize("MGT2.WeaponTraits.Stun"));
if (traits.smart) parts.push(game.i18n.localize("MGT2.WeaponTraits.Smart"));
if (traits.radiation) parts.push(game.i18n.localize("MGT2.WeaponTraits.Radiation"));
if (traits.scope) parts.push(game.i18n.localize("MGT2.WeaponTraits.Scope"));
if (traits.zeroG) parts.push(game.i18n.localize("MGT2.WeaponTraits.ZeroG"));
return parts.join(", ");
}
}

View File

@@ -35,7 +35,14 @@ export class RollPromptHelper {
skillLevel: options.skillLevel ?? 0,
// Healing fields
showHeal: options.showHeal ?? false,
healType: options.healType ?? null
healType: options.healType ?? null,
// Ranged/Melee weapon flags
isRanged: options.isRanged ?? false,
isMelee: options.isMelee ?? false,
isAttack: (options.isRanged ?? false) || (options.isMelee ?? false),
autoLevel: options.autoLevel ?? 0,
hasScope: options.scope ?? false,
hasZeroG: options.zeroG ?? false,
});
return await DialogV2.wait({

View File

@@ -23,6 +23,7 @@ export const preloadHandlebarsTemplates = async function() {
"systems/mgt2/templates/roll-prompt.html",
"systems/mgt2/templates/chat/roll.html",
//"systems/mgt2/templates/chat/roll-characteristic.html",
"systems/mgt2/templates/chat/radiation.html",
"systems/mgt2/templates/actors/actor-config-sheet.html",
"systems/mgt2/templates/actors/actor-config-characteristic-sheet.html",
"systems/mgt2/templates/actors/trait-sheet.html",

View File

@@ -329,6 +329,114 @@ li.chat-message
.mgt2-effect-value
color: #EE4050
// AP info badge
.mgt2-ap-info
display: flex
align-items: center
justify-content: center
gap: 5px
font-size: 0.75rem
font-weight: 600
color: #6b4e1a
background: rgba(180,130,40,0.12)
border: 1px solid rgba(180,130,40,0.3)
border-radius: 4px
padding: 3px 8px
margin: 4px 8px 0 8px
i
color: #b48228
// Auto fire mode info badge
.mgt2-auto-info
display: flex
align-items: center
justify-content: center
gap: 5px
font-size: 0.75rem
font-weight: 600
color: #1a3f6b
background: rgba(40,90,180,0.10)
border: 1px solid rgba(40,90,180,0.25)
border-radius: 4px
padding: 3px 8px
margin: 4px 8px 0 8px
i
color: #2855b4
// Blast / explosion area info badge
.mgt2-blast-info
display: flex
align-items: center
justify-content: center
gap: 5px
font-size: 0.75rem
font-weight: 600
color: #7a2a00
background: rgba(210,80,20,0.10)
border: 1px solid rgba(210,80,20,0.30)
border-radius: 4px
padding: 3px 8px
margin: 4px 8px 0 8px
i
color: #d24a10
// Stun / incapacitating weapon info badge
.mgt2-stun-info
display: flex
align-items: center
justify-content: center
gap: 5px
font-size: 0.75rem
font-weight: 600
color: #4a1a6b
background: rgba(120,40,180,0.10)
border: 1px solid rgba(120,40,180,0.28)
border-radius: 4px
padding: 3px 8px
margin: 4px 8px 0 8px
i
color: #8a28c8
// Radiation weapon info badge
.mgt2-radiation-info
display: flex
align-items: center
justify-content: center
gap: 5px
font-size: 0.75rem
font-weight: 600
color: #1a4a1a
background: rgba(40,160,40,0.10)
border: 1px solid rgba(40,160,40,0.30)
border-radius: 4px
padding: 3px 8px
margin: 4px 8px 0 8px
i
color: #28a028
// Radiation roll result card
.mgt2-radiation-card
.mgt2-radiation-label
color: #28a028
font-weight: 700
i
margin-right: 4px
.mgt2-radiation-rules
font-size: 0.72rem
color: #555
background: rgba(40,160,40,0.07)
border: 1px solid rgba(40,160,40,0.20)
border-radius: 4px
padding: 4px 8px
margin: 4px 8px 0 8px
display: flex
gap: 5px
align-items: flex-start
i
color: #e0a020
margin-top: 2px
flex-shrink: 0
// Action buttons
.mgt2-buttons
display: flex
@@ -363,4 +471,12 @@ li.chat-message
background: #EE4050
border-color: #EE4050
color: #fff
box-shadow: 0 0 8px rgba(238,64,80,0.25)
box-shadow: 0 0 8px rgba(238,64,80,0.25)
&:disabled
background: #e8e0e0
border-color: #c8b8b8
color: #a08080
cursor: not-allowed
box-shadow: none
opacity: 0.6

View File

@@ -108,6 +108,39 @@
width: 14px !important
height: 14px !important
// Read-only state badges
.roll-prompt-states
display: flex !important
gap: 6px !important
flex-wrap: wrap !important
padding: 2px 0 !important
.roll-prompt-state-badge
display: inline-flex !important
align-items: center !important
gap: 4px !important
padding: 2px 8px !important
border-radius: 3px !important
font-family: 'Barlow Condensed', sans-serif !important
font-size: 0.75rem !important
font-weight: 600 !important
text-transform: uppercase !important
letter-spacing: 0.8px !important
background: #ede8e8 !important
border: 1px solid #ccc !important
color: #999 !important
i
font-size: 0.7rem !important
&.is-active
background: #fdf0f0 !important
border-color: #EE4050 !important
color: #EE4050 !important
strong
font-weight: 800 !important
// Footer buttons
.dialog-buttons, .form-footer, footer
background: #f5eeee !important
@@ -148,3 +181,95 @@
&:hover
background: #ff5060 !important
box-shadow: 0 4px 18px rgba(238,64,80,0.45) !important
// Ranged modifiers fieldset
.mgt2-ranged-modifiers
margin-top: 4px !important
.mgt2-ranged-checkboxes, .mgt2-ranged-dodge
display: flex !important
flex-wrap: wrap !important
gap: 6px !important
align-items: center !important
border-bottom: none !important
.mgt2-checkbox-tag
display: inline-flex !important
align-items: center !important
gap: 4px !important
padding: 2px 7px !important
border: 1px solid #ddc8c8 !important
border-radius: 3px !important
background: #fdf8f8 !important
font-family: 'Barlow Condensed', sans-serif !important
font-size: 0.73rem !important
font-weight: 600 !important
text-transform: uppercase !important
letter-spacing: 0.8px !important
color: #3a2020 !important
cursor: pointer !important
transition: border-color 0.15s, background 0.15s !important
em
font-style: normal !important
color: #999 !important
font-weight: 400 !important
input[type="checkbox"]
accent-color: #EE4050 !important
width: 13px !important
height: 13px !important
margin: 0 !important
&:has(input:checked)
border-color: #EE4050 !important
background: #fdf0f0 !important
color: #EE4050 !important
em
color: #EE4050 !important
// Auto fire mode selector
.mgt2-auto-mode
border-bottom: 1px solid #ddc8c8 !important
padding-bottom: 4px !important
margin-bottom: 2px !important
.mgt2-auto-hint
font-size: 0.72rem !important
color: #9a6520 !important
margin: 0 0 4px 0 !important
font-style: italic !important
text-align: center !important
.mgt2-scope-badge
display: inline-flex
align-items: center
gap: 5px
font-size: 0.72rem
font-weight: 600
color: #1a4060
background: rgba(30,100,180,0.10)
border: 1px solid rgba(30,100,180,0.28)
border-radius: 4px
padding: 2px 8px
margin-bottom: 4px
cursor: help
i
color: #1a6090
.mgt2-zerog-badge
display: inline-flex
align-items: center
gap: 5px
font-size: 0.72rem
font-weight: 600
color: #2a3a50
background: rgba(60,80,120,0.10)
border: 1px solid rgba(60,80,120,0.28)
border-radius: 4px
padding: 2px 8px
margin-bottom: 4px
cursor: help
i
color: #506090

View File

@@ -416,3 +416,44 @@
.editor,
.editor-container
min-height: 200px !important
// Weapon traits structured grid
.mgt2-weapon-traits
border: 1px solid var(--color-border-light-tertiary)
border-radius: 4px
padding: 6px 8px
margin-top: 4px
legend
font-size: 0.85em
font-weight: bold
padding: 0 4px
.mgt2-weapon-traits-grid
display: flex
flex-wrap: wrap
gap: 6px 12px
align-items: center
.mgt2-trait-num
display: flex
align-items: center
gap: 4px
label
font-size: 0.85em
white-space: nowrap
input[type="number"]
width: 44px
text-align: center
flex: 0 0 44px
.mgt2-trait-bool
display: flex
align-items: center
gap: 4px
label
font-size: 0.85em
white-space: nowrap
cursor: pointer
input[type="checkbox"]
cursor: pointer