TheMillsFabula/scripts/token_combat_border.mjs
trotFunky 77ae01a7b3 Borders: Don't rely on .inCombat to check combat status
When changing scenes, *a lot* of data is either empty or points to
the previous scene, preventing us from knowing whether a token is in
combat or not directly from the token.

To work around that, go through the list of combats directly and try
to match them with the scene and token we are drawing instead of using
`.inCombat`.
We cannot rely on `game.combats.active` either as it points to the
active combat in the previous scene.

This is far more work, but it is the only working solution I found.
It depends quite a bit on Fabula Ultima's system combat type which
lists combatants.

This leaves a more minor issue that the borders are not updated when
switching between encounters on the same scene, but this is a rarer
scenario.

Remove the issue from the README and mention the encounter switch issue.
2025-06-01 23:49:35 +01:00

326 lines
13 KiB
JavaScript

import * as MillsFabula from "./mills_fabula.mjs";
import { FUHooks } from "/systems/projectfu/module/hooks.mjs"
import { FU } from "/systems/projectfu/module/helpers/config.mjs"
import { FUCombat } from '/systems/projectfu/module/ui/combat.mjs';
// NOTES
// Tokens are working PIXI.js objects, can .addChild() a sprite
// Get Token : game.scenes.active.collections.tokens.contents[0]._object
// Paths are relative to either the core data, or the base data. No need to do anything.
// https://pixijs.download/v4.8.0/docs/PIXI.Sprite.html
/**
* Socket to use to execute things on clients, may be shared by other scripts of the module.
* @type {SocketlibSocket}
*/
let socket;
const SocketMessages = Object.freeze({
CombatUpdateBorder: "combat-update-border",
SetBorder: "set-border",
})
const BorderSettings = Object.freeze({
BorderEnabled: "borderEnabled",
BaseBorderPath: "baseBorderPath",
PlayedBorderPath: "playedBorderPath",
})
/**
* The texture that will be used to create the base player token borders
* Will be created after fetching a path from the settings.
* @type {PIXI.BaseTexture}
*/
let base_border;
/**
* The texture that will be used to create the borders for player tokens that have taken their turns
* Will be created after fetching a path from the settings.
* @type {PIXI.BaseTexture}
*/
let played_border;
// Enum for the border types.
const BorderTypes = Object.freeze({
Active: "active",
Played: "played",
});
/**
* @param {BorderTypes} type The kind of border to create, deciding which texture to use
* @param {number} width Width of the final sprite on the canvas, in pixels
* @param {number} height Height of the final sprite on the canvas, in pixels
* @param {boolean} visible Should the new sprite be visible now ?
* @returns {PIXI.Sprite} A new sprite object corresponding to the border requested
*/
function create_new_border(type, width, height, visible) {
let border_texture;
switch (type) {
case BorderTypes.Active:
border_texture = base_border;
break;
case BorderTypes.Played:
border_texture = played_border;
break;
default:
throw new TypeError("Invalid BorderTypes Enum value: " + type);
}
/** @type {PIXI.Sprite} */
let border = PIXI.Sprite.from(border_texture);
border.interactive = false;
border.width = width;
border.height = height;
border.visible = visible;
/**
* Custom member, to be able to toggle border visibility dynamically
* @type {BorderTypes}
*/
border.borderType = type;
return border;
}
/**
* Toggle the borders of a token.
* @param {Token} token The token to update
* @param {boolean} played Status of the token to update
*/
function token_set_border_visibility(token, played) {
// Get the borders we manually added in the `drawToken` hook, if it has any.
let borders = token.children.filter((child) => child.borderType);
// console.debug("↓↓↓ set_border_visibility ↓↓↓");
// console.debug(token)
for (let border of borders) {
if (border.borderType === BorderTypes.Active) {
border.visible = !played;
} else {
border.visible = played;
}
}
// console.debug("↑↑↑ set_border_visibility ↑↑↑");
}
/**
* A wrapper around `token_set_border_visibility()` to be invoked via SocketLib.
* It checks that the client is on the proper scene, otherwise the Token wouldn't be found.
*
* @param {string} scene_id The ID of the scene of the token to update
* @param {string} token_id The ID of the token to update
* @param {boolean} played Should the visible borders be the base or the played one ?
*/
function token_remote_set_border_visibility(scene_id, token_id, played) {
/*
* Check that we are on the same scene as the token being updated,
* otherwise it won't exist for us.
*/
if (scene_id !== canvas.scene?.id) {
return;
}
token_set_border_visibility(canvas.scene?.tokens.get(token_id).object, played)
}
/**
* Check if a token has played in a specific round, or the current combat round if it exists for the token.
* The function does not rely on the token's internal data, as it is not properly set when changing scenes.
* @param {Token} token The actual token object to check combat status
* @param {number} [round] The round to check the token's status in, will check the token's combat current turn otherwise
* @returns {boolean} If the token has played in the current or requested round
*/
function token_has_played(token, round = -1) {
// console.debug("↓↓↓ token_has_played ↓↓↓");
// console.debug(token.inCombat)
/**
* When we change scene, the token might not be updated with the combat
* data, so we might need to go check ourselves if they are not set.
* @type {FUCombat}
*/
let combat = token.combatant?.combat;
/** @type {Combatant} */
let combatant = token.combatant;
// We might be changing scene, so let's check if that's true.
if (!combatant) {
/*
* There should only be one active combat per scene.
* If there isn't one, we're sure that the token is not in combat.
*/
let active_scene_combat = game.combats.contents.filter(
(combat) => combat.active && combat.scene.id === token.scene.id)
if (active_scene_combat.length === 0) {
return false;
}
combat = active_scene_combat[0];
// Now search among the combatants
for (let active_combatant of combat.combatants) {
if (active_combatant.tokenId === token.id) {
combatant = active_combatant;
break;
}
}
// We have not found our token in the active combat, assume it isn't in combat.
if (!combatant) {
return false;
}
}
let round_turns_taken = combat?.flags.projectfu.CombatantsTurnTaken[round < 0 ? combat?.round : round]
// console.debug(`Testing played for round : ${combat?.round}`)
console.debug(round_turns_taken)
// No token has taken a turn, or all turns were reverted.
if (!round_turns_taken || round_turns_taken.length === 0) {
return false;
}
// console.debug("↑↑↑ token_has_played ↑↑↑");
// Token might have played, let's search now.
return round_turns_taken.includes(combatant.id)
}
/**
* A wrapper function around `token_set_border_visibility()` that is called via SocketLib for combat updates.
* It is executed by every client, which allows us to check if we are on the proper scene, ensuring that
* we can find the token on each relevant client, rather than only on the GM's side which doesn't
* work if they are not in the combat scene.
*
* Scene and Token are passed by IDs as the Foundry objects are recursive and cannot be passed via SocketLib.
* @param {string} scene_id The ID of the scene of the token to update
* @param {string} token_id The ID of the token to update
* @param {number} round The combat round relating to the update
*/
function token_combat_visibility_remote_update(scene_id, token_id, round) {
/*
* Check that we are on the same scene as the token being updated,
* otherwise it won't exist for us.
*/
if (scene_id !== canvas.scene?.id) {
return;
}
let token = canvas.scene?.tokens.get(token_id).object;
token_set_border_visibility(token, token_has_played(token, round))
}
/**
* Called by turn and round hooks, so the borders can be updated when going backwards as well as forwards.
* @param {Combat} combat The combat in which the token is active
* @param {{round: number, turn: number}} data The turn data for this update
*/
function combat_hook_update_token_borders(combat, data) {
// Turns is the array of *tokens having turns in this encounter*.
// console.debug("↓↓↓ combat_hook_update_token_borders ↓↓↓");
for (let combatant of combat.turns) {
// The combat passed by the hook is still on the previous round or turn, which would make the check
// use the previous round rather than the new one. Use the round contained in data instead,
// which is always the new one.
socket.executeForEveryone(SocketMessages.CombatUpdateBorder,
combatant.sceneId, combatant.tokenId, data.round).then();
}
// console.debug("↑↑↑ combat_hook_update_token_borders ↑↑↑");
}
function combat_border_main() {
Hooks.once("init", () => {
game.settings.register(MillsFabula.id, BorderSettings.BorderEnabled, {
name: game.i18n.localize('MF.Border.Settings.BorderEnabled.Name'),
hint: game.i18n.localize('MF.Border.Settings.BorderEnabled.Hint'),
type: Boolean,
config: true,
scope: 'world',
requiresReload: true,
default: true
});
// Only show the settings if the borders are enabled.
let borders_enabled = game.settings.get(MillsFabula.id, BorderSettings.BorderEnabled);
game.settings.register(MillsFabula.id, BorderSettings.BaseBorderPath, {
name: game.i18n.localize('MF.Border.Settings.BaseBorder.Name'),
hint: game.i18n.localize('MF.Border.Settings.BaseBorder.Hint'),
type: String,
config: borders_enabled,
scope: 'world',
requiresReload: true,
filePicker: 'image',
default: 'modules/the-mills-fabula/assets/default-borders/base.webp'
});
game.settings.register(MillsFabula.id, BorderSettings.PlayedBorderPath, {
name: game.i18n.localize('MF.Border.Settings.PlayedBorder.Name'),
hint: game.i18n.localize('MF.Border.Settings.PlayedBorder.Hint'),
type: String,
config: borders_enabled,
scope: 'world',
requiresReload: true,
filePicker: 'image',
default: 'modules/the-mills-fabula/assets/default-borders/played.webp'
})
// Create the border textures based on the image chose in the settings.
base_border = PIXI.BaseTexture.from(game.settings.get(MillsFabula.id, BorderSettings.BaseBorderPath));
played_border = PIXI.BaseTexture.from(game.settings.get(MillsFabula.id, BorderSettings.PlayedBorderPath));
});
Hooks.once("socketlib.ready", () => {
socket = socketlib.registerModule(MillsFabula.id);
socket.register(SocketMessages.CombatUpdateBorder, token_combat_visibility_remote_update);
socket.register(SocketMessages.SetBorder, token_remote_set_border_visibility);
})
// Create the borders from defined textures and add them to the tokens when they are first created on canvas.
Hooks.on("drawToken", (drawn_token) => {
// FIXME: Handle deactivation properly
if (!game.settings.get(MillsFabula.id, BorderSettings.BorderEnabled)) {
return;
}
// Only apply the borders to player tokens
if (drawn_token.actor?.type !== "character")
return;
let has_played_turn = token_has_played(drawn_token);
// console.log(drawn_token)
// console.log(`Is in combat ? ${drawn_token.inCombat}`)
// console.log(`Combatant ? ${drawn_token.combatant}`)
// console.log(`Combat ?`)
// console.log(drawn_token.combatant.combat);
// console.log(game.combats.combats)
const token_size = drawn_token.getSize();
drawn_token.addChild(create_new_border(BorderTypes.Active, token_size.width, token_size.height, !has_played_turn));
drawn_token.addChild(create_new_border(BorderTypes.Played, token_size.width, token_size.height, has_played_turn));
// console.log("============")
})
Hooks.on("ready", () => {
// FIXME: Handle deactivation properly
if (!game.settings.get(MillsFabula.id, BorderSettings.BorderEnabled)) {
return;
}
// Players cannot run the combat hooks used here, which trigger for the GM no matter what.
// So register them for the GM only, who will execute the updates for players via SocketLib.
if (!game.user?.isGM) {
return;
}
// console.debug("↓↓↓ Registering ↓↓↓")
Hooks.on("combatTurn", combat_hook_update_token_borders);
Hooks.on("combatRound", combat_hook_update_token_borders);
// console.debug("↑↑↑ Registering ↑↑↑")
// No Foundry hook on end of combat, so use Fabula Ultima's.
Hooks.on(FUHooks.COMBAT_EVENT, (combat_event) => {
if (combat_event.type === FU.combatEvent.endOfCombat) {
for (let combatant of combat_event.combatants) {
// End of combat, clear all tokens.
socket.executeForEveryone(SocketMessages.SetBorder,
combatant.sceneId, combatant.tokenId, false).then();
}
}
})
})
}
combat_border_main()