Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5842,6 +5842,10 @@
"Full": "Full",
"NoBlocking": "Partial",
"None": "None"
},
"SenseVision": {
"Name": "Sync Senses to Token Vision",
"Hint": "Automatically configure token vision range, vision mode, and detection modes based on the actor's senses."
}
},
"BLOODIED": {
Expand Down
2 changes: 1 addition & 1 deletion module/applications/actor/api/base-actor-sheet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export default class BaseActorSheet extends PrimarySheetMixin(
*/
_prepareSenses(context) {
return [
...Object.entries(CONFIG.DND5E.senses).map(([k, label]) => {
...Object.entries(CONFIG.DND5E.senses).map(([k, { label }]) => {
const value = context.system.attributes.senses.ranges[k];
return value ? { label, value } : null;
}, {}).filter(_ => _),
Expand Down
2 changes: 1 addition & 1 deletion module/applications/shared/movement-senses-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default class MovementSensesConfig extends BaseConfigSheet {
context.extras = this._prepareExtraFields(context);
context.types = this.types.map(key => ({
field: this.subPath ? context.fields[this.subPath].model : context.fields[key],
label: this.options.type === "movement" ? CONFIG.DND5E.movementTypes[key].label : CONFIG.DND5E.senses[key],
label: this.options.type === "movement" ? CONFIG.DND5E.movementTypes[key].label : CONFIG.DND5E.senses[key]?.label,
name: this.subPath ? `system.${this.keyPath}.${this.subPath}.${key}` : `system.${this.keyPath}.${key}`,
value: this.subPath ? context.data[this.subPath][key] : context.data[key],
placeholder: placeholderData?.[key] ?? ""
Expand Down
37 changes: 31 additions & 6 deletions module/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2938,17 +2938,42 @@ preLocalize("restTypes", { key: "label" });

/* -------------------------------------------- */

/**
* Configuration data for an actor sense type.
*
* @typedef {object} SenseConfiguration
* @property {string} label Localized label for the sense.
* @property {string} [detectionMode] Detection mode ID to add to the token (e.g. "blindsight", "feelTremor").
* @property {boolean} [grantsSight] Whether this sense grants token vision (sight.enabled & sight.range).
* @property {string} [visionMode] Vision mode ID to set on the token when this sense provides sight.
*/

/**
* The set of possible sensory perception types which an Actor may have.
* @enum {string}
* @enum {SenseConfiguration}
*/
DND5E.senses = {
blindsight: "DND5E.SenseBlindsight",
darkvision: "DND5E.SenseDarkvision",
tremorsense: "DND5E.SenseTremorsense",
truesight: "DND5E.SenseTruesight"
blindsight: {
label: "DND5E.SenseBlindsight",
detectionMode: "blindsight"
},
darkvision: {
label: "DND5E.SenseDarkvision",
grantsSight: true,
visionMode: "darkvision"
},
tremorsense: {
label: "DND5E.SenseTremorsense",
detectionMode: "feelTremor"
},
truesight: {
label: "DND5E.SenseTruesight",
detectionMode: "seeAll",
grantsSight: true,
visionMode: "darkvision"
}
};
preLocalize("senses", { sort: true });
preLocalize("senses", { key: "label", sort: true });

/* -------------------------------------------- */
/* Attacks */
Expand Down
2 changes: 2 additions & 0 deletions module/data/actor/character.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export default class CharacterData extends CreatureTemplate {
async _preCreate(data, options, user) {
if ( (await super._preCreate(data, options, user)) === false ) return false;
await TraitsFields.preCreateSize.call(this, data, options, user);
await AttributesFields.preCreateSenses.call(this, data, options, user);

if ( this.parent._stats?.compendiumSource?.startsWith("Compendium.") ) return;
this.parent.updateSource({
Expand All @@ -272,6 +273,7 @@ export default class CharacterData extends CreatureTemplate {
if ( (await super._preUpdate(changes, options, user)) === false ) return false;
await AttributesFields.preUpdateHP.call(this, changes, options, user);
await TraitsFields.preUpdateSize.call(this, changes, options, user);
await AttributesFields.preUpdateSenses.call(this, changes, options, user);
}

/* -------------------------------------------- */
Expand Down
4 changes: 3 additions & 1 deletion module/data/actor/npc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export default class NPCData extends CreatureTemplate {
async _preCreate(data, options, user) {
if ( (await super._preCreate(data, options, user)) === false ) return false;
await TraitsFields.preCreateSize.call(this, data, options, user);
await AttributesFields.preCreateSenses.call(this, data, options, user);
}

/* -------------------------------------------- */
Expand All @@ -473,6 +474,7 @@ export default class NPCData extends CreatureTemplate {
if ( (await super._preUpdate(changes, options, user)) === false ) return false;
await AttributesFields.preUpdateHP.call(this, changes, options, user);
await TraitsFields.preUpdateSize.call(this, changes, options, user);
await AttributesFields.preUpdateSenses.call(this, changes, options, user);

for ( const k of ["legact", "legres"] ) {
if ( !foundry.utils.hasProperty(changes, `system.resources.${k}.value`) ) continue;
Expand Down Expand Up @@ -722,7 +724,7 @@ export default class NPCData extends CreatureTemplate {
formatter.format([
...Object.entries(CONFIG.DND5E.senses)
.filter(([k]) => this.attributes.senses.ranges[k])
.map(([k, label]) =>
.map(([k, { label }]) =>
prepareMeasured(this.attributes.senses.ranges[k], this.attributes.senses.units, label)
),
...splitSemicolons(this.attributes.senses.special)
Expand Down
58 changes: 58 additions & 0 deletions module/data/actor/templates/attributes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,64 @@ export default class AttributesFields {
this.attributes.spell.mod = ability ? ability.mod : 0;
}

/* -------------------------------------------- */
/* Sense-to-Token Sync */
/* -------------------------------------------- */

/**
* Set prototype token sight and detection modes for a newly created actor.
* @this {CharacterData|NPCData}
* @param {object} data The initial data object provided to the document creation request.
* @param {object} options Additional options which modify the creation request.
*/
static async preCreateSenses(data, options) {
if ( this.parent._stats?.compendiumSource?.startsWith("Compendium.") ) return;
if ( !game.settings.get("dnd5e", "senseVisionSync") ) return;
const senses = this.attributes.senses;
if ( !senses ) return;
const TokenDocument5e = CONFIG.Token.documentClass;
const { sight, detectionModes } = TokenDocument5e.computeSenseOverrides(senses);
const prototypeToken = {};
if ( sight.enabled && !foundry.utils.hasProperty(data, "prototypeToken.sight.range") ) {
prototypeToken.sight = { enabled: true, range: sight.range, visionMode: sight.visionMode };
}
if ( detectionModes.length && !foundry.utils.hasProperty(data, "prototypeToken.detectionModes") ) {
prototypeToken.detectionModes = detectionModes;
}
if ( !foundry.utils.isEmpty(prototypeToken) ) this.parent.updateSource({ prototypeToken });
}

/* -------------------------------------------- */

/**
* Update prototype token sight and detection modes when senses are directly edited.
* @this {CharacterData|NPCData}
* @param {object} changes The candidate changes to the Document.
* @param {object} options Additional options which modify the update request.
*/
static async preUpdateSenses(changes, options) {
if ( !game.settings.get("dnd5e", "senseVisionSync") ) return;
const newSenses = foundry.utils.getProperty(changes, "system.attributes.senses");
if ( !newSenses?.ranges || foundry.utils.hasProperty(changes, "prototypeToken.detectionModes") ) return;

// Merge changed ranges with existing ranges to get the full picture
const currentRanges = this.attributes.senses.ranges;
const mergedRanges = { ...currentRanges, ...newSenses.ranges };

const TokenDocument5e = CONFIG.Token.documentClass;
const { sight, detectionModes } = TokenDocument5e.computeSenseOverrides({ ranges: mergedRanges });

// Preserve non-sense detection modes
const userModes = (this.parent.prototypeToken._source.detectionModes ?? [])
.filter(m => !TokenDocument5e.senseDetectionModeIds.has(m.id));

changes.prototypeToken ??= {};
changes.prototypeToken.detectionModes = [...userModes, ...detectionModes];
changes.prototypeToken.sight = sight.enabled
? { enabled: true, range: sight.range, visionMode: sight.visionMode }
: { range: 0, visionMode: "basic" };
}

/* -------------------------------------------- */
/* Socket Event Handlers */
/* -------------------------------------------- */
Expand Down
4 changes: 2 additions & 2 deletions module/data/item/race.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default class RaceData extends ItemDataModel.mixin(AdvancementTemplate, I
*/
get sensesLabels() {
const units = this.senses.units || defaultUnits("length");
return Object.entries(CONFIG.DND5E.senses).reduce((arr, [k, label]) => {
return Object.entries(CONFIG.DND5E.senses).reduce((arr, [k, { label }]) => {
const value = this.senses.ranges[k];
if ( value ) arr.push(`${label} ${formatLength(value, units)}`);
return arr;
Expand Down Expand Up @@ -160,7 +160,7 @@ export default class RaceData extends ItemDataModel.mixin(AdvancementTemplate, I
classes: "info-sm info-grid",
config: "senses",
tooltip: "DND5E.SensesConfig",
value: Object.entries(CONFIG.DND5E.senses).reduce((str, [k, label]) => {
value: Object.entries(CONFIG.DND5E.senses).reduce((str, [k, { label }]) => {
const value = this.senses.ranges[k];
if ( !value ) return str;
return `${str}
Expand Down
43 changes: 42 additions & 1 deletion module/documents/actor/actor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3375,6 +3375,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) {
await this.update({ "system.attributes.exhaustion": 1 });
}
if ( collection === "items" ) await this.updateEncumbrance(options);
await this._syncTokenSenses();
}
super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
}
Expand All @@ -3383,7 +3384,10 @@ export default class Actor5e extends SystemDocumentMixin(Actor) {

/** @inheritDoc */
async _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
if ( (userId === game.userId) && (collection === "items") ) await this.updateEncumbrance(options);
if ( userId === game.userId ) {
if ( collection === "items" ) await this.updateEncumbrance(options);
await this._syncTokenSenses();
}
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
}

Expand All @@ -3397,6 +3401,7 @@ export default class Actor5e extends SystemDocumentMixin(Actor) {
}
if ( collection === "items" ) await this.updateEncumbrance(options);
await this._clearFavorites(documents);
await this._syncTokenSenses();
}
super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
}
Expand Down Expand Up @@ -3544,6 +3549,42 @@ export default class Actor5e extends SystemDocumentMixin(Actor) {

/* -------------------------------------------- */

/**
* Sync prototype token sight and detection modes to match the actor's current senses.
* Called from descendant document hooks when items or effects change.
* @returns {Promise<Actor5e>|void}
* @protected
*/
async _syncTokenSenses() {
if ( !game.settings.get("dnd5e", "senseVisionSync") ) return;
if ( !this.system?.attributes?.senses ) return;

const TokenDocument5e = CONFIG.Token.documentClass;
const { sight, detectionModes } = TokenDocument5e.computeSenseOverrides(this.system.attributes.senses);

// Preserve non-sense detection modes from the stored prototype token
const userModes = (this.prototypeToken._source.detectionModes ?? [])
.filter(m => !TokenDocument5e.senseDetectionModeIds.has(m.id));
const mergedModes = [...userModes, ...detectionModes];

// Build the update, reset sight to defaults when no senses grant it
const update = { detectionModes: mergedModes };
update.sight = sight.enabled
? { enabled: true, range: sight.range, visionMode: sight.visionMode }
: { range: 0, visionMode: "basic" };

// Diff check: skip update if nothing changed
const currentSource = this.prototypeToken._source;
const sightUnchanged = (currentSource.sight?.range === update.sight.range)
&& (currentSource.sight?.visionMode === update.sight.visionMode)
&& (!sight.enabled || (currentSource.sight?.enabled === update.sight.enabled));
if ( sightUnchanged && foundry.utils.objectsEqual(currentSource.detectionModes, mergedModes) ) return;

return this.update({ prototypeToken: update });
}

/* -------------------------------------------- */

/**
* Handle applying/removing encumbrance statuses.
* @param {DocumentModificationContext} options Additional options supplied with the update.
Expand Down
93 changes: 93 additions & 0 deletions module/documents/token.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export default class TokenDocument5e extends SystemFlagsMixin(TokenDocument) {
return this.ring.enabled;
}

/**
* The set of detection mode IDs that correspond to actor senses.
* @type {Set<string>}
*/
static get senseDetectionModeIds() {
return new Set(Object.values(CONFIG.DND5E.senses).map(c => c.detectionMode).filter(Boolean));
}

/* -------------------------------------------- */
/* Data Migration */
/* -------------------------------------------- */
Expand All @@ -38,6 +46,91 @@ export default class TokenDocument5e extends SystemFlagsMixin(TokenDocument) {
/* Data Preparation */
/* -------------------------------------------- */

/** @inheritDoc */
prepareBaseData() {
super.prepareBaseData();
this._prepareSenseOverrides();
}

/* -------------------------------------------- */

/**
* Compute token sight and detection mode overrides from the actor's senses.
* @protected
*/
_prepareSenseOverrides() {
if ( !game.settings.get("dnd5e", "senseVisionSync") ) return;
const actor = this.actor;
if ( !actor?.system?.attributes?.senses ) return;
TokenDocument5e.applySenseOverrides(actor.system.attributes.senses, this);
}

/* -------------------------------------------- */

/**
* Compute sense-derived sight and detection mode data from actor senses.
* @param {object} senses Object containing sense ranges.
* @param {Record<string, number>} senses.ranges Mapping of sense keys to their range values.
* @returns {{ sight: object, detectionModes: object[] }}
*/
static computeSenseOverrides(senses) {
const result = { sight: {}, detectionModes: [] };
let maxSightRange = 0;
let sightVisionMode = null;

for ( const [key, config] of Object.entries(CONFIG.DND5E.senses) ) {
const range = senses.ranges[key];
if ( !range ) continue;

if ( config.detectionMode ) {
result.detectionModes.push({ id: config.detectionMode, enabled: true, range });
}

if ( config.grantsSight && (range > maxSightRange) ) {
maxSightRange = range;
sightVisionMode = config.visionMode ?? null;
}
}

if ( maxSightRange > 0 ) {
result.sight.enabled = true;
result.sight.range = maxSightRange;
result.sight.visionMode = sightVisionMode ?? "basic";
}

return result;
}

/* -------------------------------------------- */

/**
* Apply sense-derived overrides to a token-like target's prepared data.
* @param {object} senses Object containing sense ranges.
* @param {Record<string, number>} senses.ranges Mapping of sense keys to their range values.
* @param {object} target Target with `sight` and `detectionModes` properties.
*/
static applySenseOverrides(senses, target) {
const { sight, detectionModes } = TokenDocument5e.computeSenseOverrides(senses);

for ( const mode of detectionModes ) {
const existing = target.detectionModes.find(m => m.id === mode.id);
if ( existing ) {
existing.range = Math.max(existing.range ?? 0, mode.range);
existing.enabled = true;
} else {
target.detectionModes.push(mode);
}
}

if ( sight.enabled ) {
target.sight.enabled = true;
target.sight.range = sight.range;
target.sight.visionMode = sight.visionMode;
}
}

/* -------------------------------------------- */

/** @inheritDoc */
getBarAttribute(barName, options={}) {
const attribute = options.alternative || this[barName]?.attribute;
Expand Down
Loading