276 lines
9.9 KiB
JavaScript
276 lines
9.9 KiB
JavaScript
/**
|
|
* The client-side User document which extends the common BaseUser model.
|
|
* Each User document contains UserData which defines its data schema.
|
|
*
|
|
* @extends foundry.documents.BaseUser
|
|
* @mixes ClientDocumentMixin
|
|
*
|
|
* @see {@link Users} The world-level collection of User documents
|
|
* @see {@link foundry.applications.sheets.UserConfig} The User configuration application
|
|
*/
|
|
class User extends ClientDocumentMixin(foundry.documents.BaseUser) {
|
|
|
|
/**
|
|
* Track whether the user is currently active in the game
|
|
* @type {boolean}
|
|
*/
|
|
active = false;
|
|
|
|
/**
|
|
* Track references to the current set of Tokens which are targeted by the User
|
|
* @type {Set<Token>}
|
|
*/
|
|
targets = new UserTargets(this);
|
|
|
|
/**
|
|
* Track the ID of the Scene that is currently being viewed by the User
|
|
* @type {string|null}
|
|
*/
|
|
viewedScene = null;
|
|
|
|
/**
|
|
* A flag for whether the current User is a Trusted Player
|
|
* @type {boolean}
|
|
*/
|
|
get isTrusted() {
|
|
return this.hasRole("TRUSTED");
|
|
}
|
|
|
|
/**
|
|
* A flag for whether this User is the connected client
|
|
* @type {boolean}
|
|
*/
|
|
get isSelf() {
|
|
return game.userId === this.id;
|
|
}
|
|
|
|
/* ---------------------------------------- */
|
|
|
|
/** @inheritdoc */
|
|
prepareDerivedData() {
|
|
super.prepareDerivedData();
|
|
this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
|
|
this.border = this.color.multiply(2);
|
|
}
|
|
|
|
/* ---------------------------------------- */
|
|
/* User Methods */
|
|
/* ---------------------------------------- */
|
|
|
|
/**
|
|
* Assign a Macro to a numbered hotbar slot between 1 and 50
|
|
* @param {Macro|null} macro The Macro document to assign
|
|
* @param {number|string} [slot] A specific numbered hotbar slot to fill
|
|
* @param {number} [fromSlot] An optional origin slot from which the Macro is being shifted
|
|
* @returns {Promise<User>} A Promise which resolves once the User update is complete
|
|
*/
|
|
async assignHotbarMacro(macro, slot, {fromSlot}={}) {
|
|
if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided");
|
|
const hotbar = this.hotbar;
|
|
|
|
// If a slot was not provided, get the first available slot
|
|
if ( Number.isNumeric(slot) ) slot = Number(slot);
|
|
else {
|
|
for ( let i=1; i<=50; i++ ) {
|
|
if ( !(i in hotbar ) ) {
|
|
slot = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if ( !slot ) throw new Error("No available Hotbar slot exists");
|
|
if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested");
|
|
if ( macro && (hotbar[slot] === macro.id) ) return this;
|
|
const current = hotbar[slot];
|
|
|
|
// Update the macro for the new slot
|
|
const update = foundry.utils.deepClone(hotbar);
|
|
if ( macro ) update[slot] = macro.id;
|
|
else delete update[slot];
|
|
|
|
// Replace or remove the macro in the old slot
|
|
if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) {
|
|
if ( current ) update[fromSlot] = current;
|
|
else delete update[fromSlot];
|
|
}
|
|
return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Assign a specific boolean permission to this user.
|
|
* Modifies the user permissions to grant or restrict access to a feature.
|
|
*
|
|
* @param {string} permission The permission name from USER_PERMISSIONS
|
|
* @param {boolean} allowed Whether to allow or restrict the permission
|
|
*/
|
|
assignPermission(permission, allowed) {
|
|
if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`);
|
|
const permissions = {[permission]: allowed};
|
|
return this.update({permissions});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* @typedef {object} PingData
|
|
* @property {boolean} [pull=false] Pulls all connected clients' views to the pinged coordinates.
|
|
* @property {string} style The ping style, see CONFIG.Canvas.pings.
|
|
* @property {string} scene The ID of the scene that was pinged.
|
|
* @property {number} zoom The zoom level at which the ping was made.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ActivityData
|
|
* @property {string|null} [sceneId] The ID of the scene that the user is viewing.
|
|
* @property {{x: number, y: number}} [cursor] The position of the user's cursor.
|
|
* @property {RulerData|null} [ruler] The state of the user's ruler, if they are currently using one.
|
|
* @property {string[]} [targets] The IDs of the tokens the user has targeted in the currently viewed
|
|
* scene.
|
|
* @property {boolean} [active] Whether the user has an open WS connection to the server or not.
|
|
* @property {PingData} [ping] Is the user emitting a ping at the cursor coordinates?
|
|
* @property {AVSettingsData} [av] The state of the user's AV settings.
|
|
*/
|
|
|
|
/**
|
|
* Submit User activity data to the server for broadcast to other players.
|
|
* This type of data is transient, persisting only for the duration of the session and not saved to any database.
|
|
* Activity data uses a volatile event to prevent unnecessary buffering if the client temporarily loses connection.
|
|
* @param {ActivityData} activityData An object of User activity data to submit to the server for broadcast.
|
|
* @param {object} [options]
|
|
* @param {boolean|undefined} [options.volatile] If undefined, volatile is inferred from the activity data.
|
|
*/
|
|
broadcastActivity(activityData={}, {volatile}={}) {
|
|
volatile ??= !(("sceneId" in activityData)
|
|
|| (activityData.ruler === null)
|
|
|| ("targets" in activityData)
|
|
|| ("ping" in activityData)
|
|
|| ("av" in activityData));
|
|
if ( volatile ) game.socket.volatile.emit("userActivity", this.id, activityData);
|
|
else game.socket.emit("userActivity", this.id, activityData);
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Get an Array of Macro Documents on this User's Hotbar by page
|
|
* @param {number} page The hotbar page number
|
|
* @returns {Array<{slot: number, macro: Macro|null}>}
|
|
*/
|
|
getHotbarMacros(page=1) {
|
|
const macros = Array.from({length: 50}, () => "");
|
|
for ( let [k, v] of Object.entries(this.hotbar) ) {
|
|
macros[parseInt(k)-1] = v;
|
|
}
|
|
const start = (page-1) * 10;
|
|
return macros.slice(start, start+10).map((m, i) => {
|
|
return {
|
|
slot: start + i + 1,
|
|
macro: m ? game.macros.get(m) : null
|
|
};
|
|
});
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/**
|
|
* Update the set of Token targets for the user given an array of provided Token ids.
|
|
* @param {string[]} targetIds An array of Token ids which represents the new target set
|
|
*/
|
|
updateTokenTargets(targetIds=[]) {
|
|
|
|
// Clear targets outside of the viewed scene
|
|
if ( this.viewedScene !== canvas.scene.id ) {
|
|
for ( let t of this.targets ) {
|
|
t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update within the viewed Scene
|
|
const targets = new Set(targetIds);
|
|
if ( this.targets.equals(targets) ) return;
|
|
|
|
// Remove old targets
|
|
for ( let t of this.targets ) {
|
|
if ( !targets.has(t.id) ) t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
|
|
}
|
|
|
|
// Add new targets
|
|
for ( let id of targets ) {
|
|
const token = canvas.tokens.get(id);
|
|
if ( !token || this.targets.has(token) ) continue;
|
|
token.setTarget(true, {user: this, releaseOthers: false, groupSelection: true});
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
/* Event Handlers */
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
_onUpdate(changed, options, userId) {
|
|
super._onUpdate(changed, options, userId);
|
|
|
|
// If the user role changed, we need to re-build the immutable User object
|
|
if ( this._source.role !== this.role ) {
|
|
const user = this.clone({}, {keepId: true});
|
|
game.users.set(user.id, user);
|
|
return user._onUpdate(changed, options, userId);
|
|
}
|
|
|
|
// If your own password or role changed - you must re-authenticate
|
|
const isSelf = changed._id === game.userId;
|
|
if ( isSelf && ["password", "role"].some(k => k in changed) ) return game.logOut();
|
|
if ( !game.ready ) return;
|
|
|
|
// User Color
|
|
if ( "color" in changed ) {
|
|
document.documentElement.style.setProperty(`--user-color-${this.id}`, this.color.css);
|
|
if ( isSelf ) document.documentElement.style.setProperty("--user-color", this.color.css);
|
|
}
|
|
|
|
// Redraw Navigation
|
|
if ( ["active", "character", "color", "role"].some(k => k in changed) ) {
|
|
ui.nav?.render();
|
|
ui.players?.render();
|
|
}
|
|
|
|
// Redraw Hotbar
|
|
if ( isSelf && ("hotbar" in changed) ) ui.hotbar?.render();
|
|
|
|
// Reconnect to Audio/Video conferencing, or re-render camera views
|
|
const webRTCReconnect = ["permissions", "role"].some(k => k in changed);
|
|
if ( webRTCReconnect && (changed._id === game.userId) ) {
|
|
game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render());
|
|
} else if ( ["name", "avatar", "character"].some(k => k in changed) ) game.webrtc?.render();
|
|
|
|
// Update Canvas
|
|
if ( canvas.ready ) {
|
|
|
|
// Redraw Cursor
|
|
if ( "color" in changed ) {
|
|
canvas.controls.drawCursor(this);
|
|
const ruler = canvas.controls.getRulerForUser(this.id);
|
|
if ( ruler ) ruler.color = Color.from(changed.color);
|
|
}
|
|
if ( "active" in changed ) canvas.controls.updateCursor(this, null);
|
|
|
|
// Modify impersonated character
|
|
if ( isSelf && ("character" in changed) ) {
|
|
canvas.perception.initialize();
|
|
canvas.tokens.cycleTokens(true, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @inheritDoc */
|
|
_onDelete(options, userId) {
|
|
super._onDelete(options, userId);
|
|
if ( this.id === game.user.id ) return game.logOut();
|
|
}
|
|
}
|