/** * 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} 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: '', callback: this._onConfigureCombatant.bind(this) }, { name: "COMBAT.CombatantClear", icon: '', 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: '', callback: li => { const combatant = this.viewed.combatants.get(li.data("combatant-id")); if ( combatant ) return this.viewed.rollInitiative([combatant.id]); } }, { name: "COMBAT.CombatantRemove", icon: '', 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); } }