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); for (let border of borders) { if (border.borderType === BorderTypes.Active) { border.visible = !played; } else { border.visible = played; } } } /** * 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) { /** * 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] // No token has taken a turn, or all turns were reverted. if (!round_turns_taken || round_turns_taken.length === 0) { return false; } // 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}} 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*. 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(); } } function combat_border_main() { 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) => { // Only apply the borders to player tokens if (drawn_token.actor?.type !== "character") return; let has_played_turn = token_has_played(drawn_token); 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)); }) Hooks.on("ready", () => { // 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; } Hooks.on("combatTurn", combat_hook_update_token_borders); Hooks.on("combatRound", combat_hook_update_token_borders); // Hook on the combat document update directly to handle active combat switches. Hooks.on("updateCombat", (combat, changed) => { // We only care about switching active combats if (!("active" in changed)) { return; } /* * The *previous* active combat is switched to inactive *first*, so we can * just revert all borders to the default state. * When the new combat becomes active, we can just check as if it were a * regular combat update. * This makes sure to handle tokens which were in the previous combat, but not * in the new one. */ if (!changed.active) { for (let combatant of combat.combatants) { socket.executeForEveryone(SocketMessages.SetBorder, combatant.sceneId, combatant.tokenId, false).then(); } } else { combat_hook_update_token_borders(combat, {round: combat.round}) } }) // 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(); } } }) }) } // We need to setup socketlib early, doing it in the `init` hook is too late. Hooks.once("socketlib.ready", () => { socket = socketlib.registerModule(MillsFabula.id); }) 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' }) if (!game.settings.get(MillsFabula.id, BorderSettings.BorderEnabled)) { return; } // 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)); combat_border_main() });