Files
Foundry-VTT-Docker/resources/app/client/apps/sidebar/tabs/combat-tracker.js
2025-01-04 00:34:03 +01:00

536 lines
17 KiB
JavaScript

/**
* The sidebar directory which organizes and displays world-level Combat documents.
*/
class CombatTracker extends SidebarTab {
constructor(options) {
super(options);
if ( !this.popOut ) game.combats.apps.push(this);
/**
* Record a reference to the currently highlighted Token
* @type {Token|null}
* @private
*/
this._highlighted = null;
/**
* Record the currently tracked Combat encounter
* @type {Combat|null}
*/
this.viewed = null;
// Initialize the starting encounter
this.initialize({render: false});
}
/* -------------------------------------------- */
/** @inheritdoc */
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "combat",
template: "templates/sidebar/combat-tracker.html",
title: "COMBAT.SidebarTitle",
scrollY: [".directory-list"]
});
}
/* -------------------------------------------- */
/**
* Return an array of Combat encounters which occur within the current Scene.
* @type {Combat[]}
*/
get combats() {
return game.combats.combats;
}
/* -------------------------------------------- */
/* Methods */
/* -------------------------------------------- */
/** @inheritdoc */
createPopout() {
const pop = super.createPopout();
pop.initialize({combat: this.viewed, render: true});
return pop;
}
/* -------------------------------------------- */
/**
* Initialize the combat tracker to display a specific combat encounter.
* If no encounter is provided, the tracker will be initialized with the first encounter in the viewed scene.
* @param {object} [options] Additional options to configure behavior.
* @param {Combat|null} [options.combat=null] The combat encounter to initialize
* @param {boolean} [options.render=true] Whether to re-render the sidebar after initialization
*/
initialize({combat=null, render=true}={}) {
// Retrieve a default encounter if none was provided
if ( combat === null ) {
const combats = this.combats;
combat = combats.length ? combats.find(c => c.active) || combats[0] : null;
combat?.updateCombatantActors();
}
// Prepare turn order
if ( combat && !combat.turns ) combat.turns = combat.setupTurns();
// Set flags
this.viewed = combat;
this._highlighted = null;
// Also initialize the popout
if ( this._popout ) {
this._popout.viewed = combat;
this._popout._highlighted = null;
}
// Render the tracker
if ( render ) this.render();
}
/* -------------------------------------------- */
/**
* Scroll the combat log container to ensure the current Combatant turn is centered vertically
*/
scrollToTurn() {
const combat = this.viewed;
if ( !combat || (combat.turn === null) ) return;
let active = this.element.find(".active")[0];
if ( !active ) return;
let container = active.parentElement;
const nViewable = Math.floor(container.offsetHeight / active.offsetHeight);
container.scrollTop = (combat.turn * active.offsetHeight) - ((nViewable/2) * active.offsetHeight);
}
/* -------------------------------------------- */
/** @inheritdoc */
async getData(options={}) {
let context = await super.getData(options);
// Get the combat encounters possible for the viewed Scene
const combat = this.viewed;
const hasCombat = combat !== null;
const combats = this.combats;
const currentIdx = combats.findIndex(c => c === combat);
const previousId = currentIdx > 0 ? combats[currentIdx-1].id : null;
const nextId = currentIdx < combats.length - 1 ? combats[currentIdx+1].id : null;
const settings = game.settings.get("core", Combat.CONFIG_SETTING);
// Prepare rendering data
context = foundry.utils.mergeObject(context, {
combats: combats,
currentIndex: currentIdx + 1,
combatCount: combats.length,
hasCombat: hasCombat,
combat,
turns: [],
previousId,
nextId,
started: this.started,
control: false,
settings,
linked: combat?.scene !== null,
labels: {}
});
context.labels.scope = game.i18n.localize(`COMBAT.${context.linked ? "Linked" : "Unlinked"}`);
if ( !hasCombat ) return context;
// Format information about each combatant in the encounter
let hasDecimals = false;
const turns = [];
for ( let [i, combatant] of combat.turns.entries() ) {
if ( !combatant.visible ) continue;
// Prepare turn data
const resource = combatant.permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
const turn = {
id: combatant.id,
name: combatant.name,
img: await this._getCombatantThumbnail(combatant),
active: i === combat.turn,
owner: combatant.isOwner,
defeated: combatant.isDefeated,
hidden: combatant.hidden,
initiative: combatant.initiative,
hasRolled: combatant.initiative !== null,
hasResource: resource !== null,
resource: resource,
canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS")
};
if ( (turn.initiative !== null) && !Number.isInteger(turn.initiative) ) hasDecimals = true;
turn.css = [
turn.active ? "active" : "",
turn.hidden ? "hidden" : "",
turn.defeated ? "defeated" : ""
].join(" ").trim();
// Actor and Token status effects
turn.effects = new Set();
for ( const effect of (combatant.actor?.temporaryEffects || []) ) {
if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
else if ( effect.img ) turn.effects.add(effect.img);
}
turns.push(turn);
}
// Format initiative numeric precision
const precision = CONFIG.Combat.initiative.decimals;
turns.forEach(t => {
if ( t.initiative !== null ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
});
// Confirm user permission to advance
const isPlayerTurn = combat.combatant?.players?.includes(game.user);
const canControl = combat.turn && combat.turn.between(1, combat.turns.length - 2)
? combat.canUserModify(game.user, "update", {turn: 0})
: combat.canUserModify(game.user, "update", {round: 0});
// Merge update data for rendering
return foundry.utils.mergeObject(context, {
round: combat.round,
turn: combat.turn,
turns: turns,
control: isPlayerTurn && canControl
});
}
/* -------------------------------------------- */
/**
* Retrieve a source image for a combatant.
* @param {Combatant} combatant The combatant queried for image.
* @returns {Promise<string>} The source image attributed for this combatant.
* @protected
*/
async _getCombatantThumbnail(combatant) {
if ( combatant._videoSrc && !combatant.img ) {
if ( combatant._thumb ) return combatant._thumb;
return combatant._thumb = await game.video.createThumbnail(combatant._videoSrc, {width: 100, height: 100});
}
return combatant.img ?? CONST.DEFAULT_TOKEN;
}
/* -------------------------------------------- */
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const tracker = html.find("#combat-tracker");
const combatants = tracker.find(".combatant");
// Create new Combat encounter
html.find(".combat-create").click(ev => this._onCombatCreate(ev));
// Display Combat settings
html.find(".combat-settings").click(ev => {
ev.preventDefault();
new CombatTrackerConfig().render(true);
});
// Cycle the current Combat encounter
html.find(".combat-cycle").click(ev => this._onCombatCycle(ev));
// Combat control
html.find(".combat-control").click(ev => this._onCombatControl(ev));
// Combatant control
html.find(".combatant-control").click(ev => this._onCombatantControl(ev));
// Hover on Combatant
combatants.hover(this._onCombatantHoverIn.bind(this), this._onCombatantHoverOut.bind(this));
// Click on Combatant
combatants.click(this._onCombatantMouseDown.bind(this));
// Context on right-click
if ( game.user.isGM ) this._contextMenu(html);
// Intersection Observer for Combatant avatars
const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: tracker[0]});
combatants.each((i, li) => observer.observe(li));
}
/* -------------------------------------------- */
/**
* Handle new Combat creation request
* @param {Event} event
* @private
*/
async _onCombatCreate(event) {
event.preventDefault();
let scene = game.scenes.current;
const cls = getDocumentClass("Combat");
await cls.create({scene: scene?.id, active: true});
}
/* -------------------------------------------- */
/**
* Handle a Combat cycle request
* @param {Event} event
* @private
*/
async _onCombatCycle(event) {
event.preventDefault();
const btn = event.currentTarget;
const combat = game.combats.get(btn.dataset.documentId);
if ( !combat ) return;
await combat.activate({render: false});
}
/* -------------------------------------------- */
/**
* Handle click events on Combat control buttons
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatControl(event) {
event.preventDefault();
const combat = this.viewed;
const ctrl = event.currentTarget;
if ( ctrl.getAttribute("disabled") ) return;
else ctrl.setAttribute("disabled", true);
try {
const fn = combat[ctrl.dataset.control];
if ( fn ) await fn.bind(combat)();
} finally {
ctrl.removeAttribute("disabled");
}
}
/* -------------------------------------------- */
/**
* Handle a Combatant control toggle
* @private
* @param {Event} event The originating mousedown event
*/
async _onCombatantControl(event) {
event.preventDefault();
event.stopPropagation();
const btn = event.currentTarget;
const li = btn.closest(".combatant");
const combat = this.viewed;
const c = combat.combatants.get(li.dataset.combatantId);
// Switch control action
switch ( btn.dataset.control ) {
// Toggle combatant visibility
case "toggleHidden":
return c.update({hidden: !c.hidden});
// Toggle combatant defeated flag
case "toggleDefeated":
return this._onToggleDefeatedStatus(c);
// Roll combatant initiative
case "rollInitiative":
return combat.rollInitiative([c.id]);
// Actively ping the Combatant
case "pingCombatant":
return this._onPingCombatant(c);
case "panToCombatant":
return this._onPanToCombatant(c);
}
}
/* -------------------------------------------- */
/**
* Handle toggling the defeated status effect on a combatant Token
* @param {Combatant} combatant The combatant data being modified
* @returns {Promise} A Promise that resolves after all operations are complete
* @private
*/
async _onToggleDefeatedStatus(combatant) {
const isDefeated = !combatant.isDefeated;
await combatant.update({defeated: isDefeated});
const defeatedId = CONFIG.specialStatusEffects.DEFEATED;
await combatant.actor?.toggleStatusEffect(defeatedId, {overlay: true, active: isDefeated});
}
/* -------------------------------------------- */
/**
* Handle pinging a combatant Token
* @param {Combatant} combatant The combatant data
* @returns {Promise}
* @protected
*/
async _onPingCombatant(combatant) {
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
await canvas.ping(combatant.token.object.center);
}
/* -------------------------------------------- */
/**
* Handle panning to a combatant Token
* @param {Combatant} combatant The combatant data
* @returns {Promise}
* @protected
*/
async _onPanToCombatant(combatant) {
if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
const {x, y} = combatant.token.object.center;
await canvas.animatePan({x, y, scale: Math.max(canvas.stage.scale.x, 0.5)});
}
/* -------------------------------------------- */
/**
* Handle mouse-down event on a combatant name in the tracker
* @param {Event} event The originating mousedown event
* @returns {Promise} A Promise that resolves once the pan is complete
* @private
*/
async _onCombatantMouseDown(event) {
event.preventDefault();
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token;
if ( !combatant.actor?.testUserPermission(game.user, "OBSERVER") ) return;
const now = Date.now();
// Handle double-left click to open sheet
const dt = now - this._clickTime;
this._clickTime = now;
if ( dt <= 250 ) return combatant.actor?.sheet.render(true);
// Control and pan to Token object
if ( token?.object ) {
token.object?.control({releaseOthers: true});
return canvas.animatePan(token.object.center);
}
}
/* -------------------------------------------- */
/**
* Handle mouse-hover events on a combatant in the tracker
* @private
*/
_onCombatantHoverIn(event) {
event.preventDefault();
if ( !canvas.ready ) return;
const li = event.currentTarget;
const combatant = this.viewed.combatants.get(li.dataset.combatantId);
const token = combatant.token?.object;
if ( token?.isVisible ) {
if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
this._highlighted = token;
}
}
/* -------------------------------------------- */
/**
* Handle mouse-unhover events for a combatant in the tracker
* @private
*/
_onCombatantHoverOut(event) {
event.preventDefault();
if ( this._highlighted ) this._highlighted._onHoverOut(event);
this._highlighted = null;
}
/* -------------------------------------------- */
/**
* Highlight a hovered combatant in the tracker.
* @param {Combatant} combatant The Combatant
* @param {boolean} hover Whether they are being hovered in or out.
*/
hoverCombatant(combatant, hover) {
const trackers = [this.element[0]];
if ( this._popout ) trackers.push(this._popout.element[0]);
for ( const tracker of trackers ) {
const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
if ( !li ) continue;
if ( hover ) li.classList.add("hover");
else li.classList.remove("hover");
}
}
/* -------------------------------------------- */
/** @inheritdoc */
_contextMenu(html) {
ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions());
}
/* -------------------------------------------- */
/**
* Get the Combatant entry context options
* @returns {object[]} The Combatant entry context options
* @private
*/
_getEntryContextOptions() {
return [
{
name: "COMBAT.CombatantUpdate",
icon: '<i class="fas fa-edit"></i>',
callback: this._onConfigureCombatant.bind(this)
},
{
name: "COMBAT.CombatantClear",
icon: '<i class="fas fa-undo"></i>',
condition: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
return Number.isNumeric(combatant?.initiative);
},
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.update({initiative: null});
}
},
{
name: "COMBAT.CombatantReroll",
icon: '<i class="fas fa-dice-d20"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
}
},
{
name: "COMBAT.CombatantRemove",
icon: '<i class="fas fa-trash"></i>',
callback: li => {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
if ( combatant ) return combatant.delete();
}
}
];
}
/* -------------------------------------------- */
/**
* Display a dialog which prompts the user to enter a new initiative value for a Combatant
* @param {jQuery} li
* @private
*/
_onConfigureCombatant(li) {
const combatant = this.viewed.combatants.get(li.data("combatant-id"));
new CombatantConfig(combatant, {
top: Math.min(li[0].offsetTop, window.innerHeight - 350),
left: window.innerWidth - 720,
width: 400
}).render(true);
}
}