/** * 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} */ 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} 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(); } }