Initial
This commit is contained in:
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
535
resources/app/client/apps/sidebar/tabs/combat-tracker.js
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user