From 809255ba790f4f773cbcb27b18e1a4200cc20fa9 Mon Sep 17 00:00:00 2001 From: WBHarry Date: Sat, 20 Dec 2025 17:14:19 +0100 Subject: [PATCH] Finished token implementation --- .../settings/homebrewSettings.mjs | 5 +- module/data/actor/base.mjs | 4 +- module/data/settings/Homebrew.mjs | 4 +- module/documents/actor.mjs | 30 +- module/documents/scene.mjs | 27 +- module/documents/token.mjs | 424 +++++++++++++++++- templates/sheets/actors/adversary/header.hbs | 6 +- 7 files changed, 466 insertions(+), 34 deletions(-) diff --git a/module/applications/settings/homebrewSettings.mjs b/module/applications/settings/homebrewSettings.mjs index fe93a4a5..3c4486c1 100644 --- a/module/applications/settings/homebrewSettings.mjs +++ b/module/applications/settings/homebrewSettings.mjs @@ -427,10 +427,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli static async resetTokenSizes() { await this.settings.updateSource({ - tokenSizes: Object.values(this.settings.schema.fields.tokenSizes.fields).reduce( - (acc, field) => ({ ...acc, [field.name]: field.initial }), - {} - ) + tokenSizes: this.settings.schema.fields.tokenSizes.initial }); this.render(); diff --git a/module/data/actor/base.mjs b/module/data/actor/base.mjs index 61a14a13..e1b40785 100644 --- a/module/data/actor/base.mjs +++ b/module/data/actor/base.mjs @@ -80,9 +80,9 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel { if (this.metadata.usesSize) schema.size = new fields.StringField({ required: true, - nullable: false, + nullable: true, choices: CONFIG.DH.ACTOR.tokenSize, - initial: CONFIG.DH.ACTOR.tokenSize.medium.id + initial: null }); return schema; } diff --git a/module/data/settings/Homebrew.mjs b/module/data/settings/Homebrew.mjs index 12ffa87f..7572c9ea 100644 --- a/module/data/settings/Homebrew.mjs +++ b/module/data/settings/Homebrew.mjs @@ -43,12 +43,12 @@ export default class DhHomebrew extends foundry.abstract.DataModel { tokenSizes: new fields.SchemaField({ tiny: new fields.NumberField({ integer: false, - initial: 0.4, + initial: 0.5, label: 'DAGGERHEART.CONFIG.TokenSize.tiny' }), small: new fields.NumberField({ integer: false, - initial: 0.6, + initial: 0.8, label: 'DAGGERHEART.CONFIG.TokenSize.small' }), medium: new fields.NumberField({ diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index c269b686..fcb0d291 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -73,16 +73,27 @@ export default class DhpActor extends Actor { /**@inheritdoc */ async _preCreate(data, options, user) { if ((await super._preCreate(data, options, user)) === false) return false; + const update = {}; + + // Set default token size. Done here as we do not want to set a datamodel default, since that would apply the sizing to third party actor modules that aren't set up with the size system. + if (this.system.metadata.usesSize) { + Object.assign(update, { + system: { + size: CONFIG.DH.ACTOR.tokenSize.medium.id + } + }); + } // Configure prototype token settings - const prototypeToken = {}; if (['character', 'companion', 'party'].includes(this.type)) - Object.assign(prototypeToken, { - sight: { enabled: true }, - actorLink: true, - disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY + Object.assign(update, { + prototypeToken: { + sight: { enabled: true }, + actorLink: true, + disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY + } }); - this.updateSource({ prototypeToken }); + this.updateSource(update); } _onUpdate(changes, options, userId) { @@ -765,9 +776,10 @@ export default class DhpActor extends Actor { } convertDamageToThreshold(damage) { - const massiveDamageEnabled=game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).massiveDamage.enabled; - if (massiveDamageEnabled && damage >= (this.system.damageThresholds.severe * 2)) { - return 4; + const massiveDamageEnabled = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules) + .massiveDamage.enabled; + if (massiveDamageEnabled && damage >= this.system.damageThresholds.severe * 2) { + return 4; } return damage >= this.system.damageThresholds.severe ? 3 : damage >= this.system.damageThresholds.major ? 2 : 1; } diff --git a/module/documents/scene.mjs b/module/documents/scene.mjs index 860f5eaa..50f520a9 100644 --- a/module/documents/scene.mjs +++ b/module/documents/scene.mjs @@ -1,11 +1,16 @@ +import DHToken from './token.mjs'; + export default class DhScene extends Scene { /** A map of `TokenDocument` IDs embedded in this scene long with new dimensions from actor size-category changes */ #sizeSyncBatch = new Map(); /** Synchronize a token's dimensions with its actor's size category. */ - syncTokenDimensions(tokenDoc, dimensions) { + syncTokenDimensions(tokenDoc, tokenSize) { if (!tokenDoc.parent?.tokens.has(tokenDoc.id)) return; - this.#sizeSyncBatch.set(tokenDoc.id, dimensions); + this.#sizeSyncBatch.set(tokenDoc.id, { + size: tokenSize, + position: { x: tokenDoc.x, y: tokenDoc.y, elevation: tokenDoc.elevation } + }); this.#processSyncBatch(); } @@ -15,8 +20,22 @@ export default class DhScene extends Scene { const entries = this.#sizeSyncBatch .entries() .toArray() - .map(([_id, { width, height }]) => ({ _id, width: tokenSizes[width], height: tokenSizes[height] })); + .map(([_id, { size, position }]) => { + const tokenSize = tokenSizes[size]; + const updatedPosition = DHToken.getSnappedPositionInSquareGrid( + this.grid, + position, + tokenSize, + tokenSize + ); + return { + _id, + width: tokenSize, + height: tokenSize, + ...updatedPosition + }; + }); this.#sizeSyncBatch.clear(); - this.updateEmbeddedDocuments('Token', entries, { animation: { movementSpeed: 1 } }); + this.updateEmbeddedDocuments('Token', entries, { animation: { movementSpeed: 1.5 } }); }, 0); } diff --git a/module/documents/token.mjs b/module/documents/token.mjs index b9a8afc6..b85e46a7 100644 --- a/module/documents/token.mjs +++ b/module/documents/token.mjs @@ -1,4 +1,4 @@ -export default class DHToken extends TokenDocument { +export default class DHToken extends CONFIG.Token.documentClass { /** * Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar. * @param {object} attributes The tracked attributes which can be chosen from @@ -102,14 +102,23 @@ export default class DHToken extends TokenDocument { } /**@inheritdoc */ - getSize(data = {}) { - if (this.actor?.system.metadata.usesSize) { - const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; - const size = tokenSizes[this.actor.system.size]; - return super.getSize({ ...data, width: size, height: size }); - } + static async _preCreateOperation(documents, operation, user) { + const allowed = await super._preCreateOperation(documents, operation, user); + if (allowed === false) return false; - return super.getSize(data); + const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; + for (const document of documents) { + const actor = document.actor; + if (actor?.system.metadata.usesSize) { + const tokenSize = tokenSizes[actor.system.size]; + if (tokenSize) { + document.updateSource({ + width: tokenSize, + height: tokenSize + }); + } + } + } } /**@inheritdoc */ @@ -118,11 +127,402 @@ export default class DHToken extends TokenDocument { if (!this.actor?.isOwner) return; const activeGM = game.users.activeGM; // Let the active GM take care of updates if available - if (this.actor.system.metadata.usesSize && activeGM && game.user.id === activeGM.id) { - const dimensions = { height: this.actor.system.size, width: this.actor.system.size }; - if (dimensions.width !== this.width || dimensions.height !== this.height) { - this.parent?.syncTokenDimensions(this, dimensions); + if (this.actor.system.metadata.usesSize && update.system?.size && activeGM && game.user.id === activeGM.id) { + const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; + const tokenSize = tokenSizes[update.system.size]; + if (tokenSize !== this.width || tokenSize !== this.height) { + this.parent?.syncTokenDimensions(this, update.system.size); } } } + + /**@inheritdoc */ + getSnappedPosition(data = {}) { + const grid = this.parent?.grid ?? BaseScene.defaultGrid; + const x = data.x ?? this.x; + const y = data.y ?? this.y; + let elevation = data.elevation ?? this.elevation; + const unsnapped = { x, y, elevation }; + + // Gridless grid + if (grid.isGridless) return unsnapped; + + // Get position and elevation + elevation = Math.round(elevation / grid.distance) * grid.distance; + + let width = data.width ?? this.width; + let height = data.height ?? this.height; + + if (this.actor?.system.metadata.usesSize) { + const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; + const tokenSize = tokenSizes[this.actor.system.size]; + width = tokenSize ?? width; + height = tokenSize ?? height; + } + + // Round width and height to nearest multiple of 0.5 if not small + width = width < 1 ? width : Math.round(width * 2) / 2; + height = height < 1 ? height : Math.round(height * 2) / 2; + const shape = data.shape ?? this.shape; + + // Square grid + let snapped; + if (grid.isSquare) snapped = DHToken.getSnappedPositionInSquareGrid(grid, unsnapped, width, height); + // Hexagonal grid + else snapped = DHToken.getSnappedPositionInHexagonalGrid(grid, unsnapped, width, height, shape); + return { x: snapped.x, y: snapped.y, elevation }; + } + + static getSnappedPositionInSquareGrid(grid, position, width, height) { + const M = CONST.GRID_SNAPPING_MODES; + // Small tokens snap to any vertex of the subgrid with resolution 4 + // where the token is fully contained within the grid space + const isTiny = (width === 0.5 && height <= 1) || (width <= 1 && height === 0.5); + if (isTiny) { + let x = position.x / grid.size; + let y = position.y / grid.size; + if (width === 1) x = Math.round(x); + else { + x = Math.floor(x * 8); + const k = ((x % 8) + 8) % 8; + if (k >= 6) x = Math.ceil(x / 8); + else if (k === 5) x = Math.floor(x / 8) + 0.5; + else x = Math.round(x / 2) / 4; + } + if (height === 1) y = Math.round(y); + else { + y = Math.floor(y * 8); + const k = ((y % 8) + 8) % 8; + if (k >= 6) y = Math.ceil(y / 8); + else if (k === 5) y = Math.floor(y / 8) + 0.5; + else y = Math.round(y / 2) / 4; + } + + x *= grid.size; + y *= grid.size; + + return { x, y }; + } else if (width < 1 && height < 1) { + // isSmall + let xGrid = Math.round(position.x / grid.size); + let yGrid = Math.round(position.y / grid.size); + + const x = xGrid * grid.size + grid.size / 2 - (width * grid.size) / 2; + const y = yGrid * grid.size + grid.size / 2 - (height * grid.size) / 2; + + return { x, y }; + } + + const modeX = Number.isInteger(width) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER; + const modeY = Number.isInteger(height) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER; + + if (modeX === modeY) return grid.getSnappedPoint(position, { mode: modeX }); + + return { + x: grid.getSnappedPoint(position, { mode: modeX }).x, + y: grid.getSnappedPoint(position, { mode: modeY }).y + }; + } + + //#region CopyPasta for mean private methods that have to be duplicated + static getSnappedPositionInHexagonalGrid(grid, position, width, height, shape) { + // Hexagonal shape + const hexagonalShape = DHToken.#getHexagonalShape(width, height, shape, grid.columns); + if (hexagonalShape) { + const offsetX = hexagonalShape.anchor.x * grid.sizeX; + const offsetY = hexagonalShape.anchor.y * grid.sizeY; + position = grid.getCenterPoint({ x: position.x + offsetX, y: position.y + offsetY }); + position.x -= offsetX; + position.y -= offsetY; + return position; + } + + // Rectagular shape + const M = CONST.GRID_SNAPPING_MODES; + return grid.getSnappedPoint(position, { mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT }); + } + + /** + * The cache of hexagonal shapes. + * @type {Map>} + */ + static #hexagonalShapes = new Map(); + + static #getHexagonalShape(width, height, shape, columns) { + if (!Number.isInteger(width * 2) || !Number.isInteger(height * 2)) return null; + + // TODO: can we set a max of 2^13 on width and height so that we may use an integer key? + const key = `${width},${height},${shape}${columns ? 'C' : 'R'}`; + let data = DHToken.#hexagonalShapes.get(key); + if (data) return data; + + // Hexagon symmetry + if (columns) { + const rowData = BaseToken.#getHexagonalShape(height, width, shape, false); + if (!rowData) return null; + + // Transpose the offsets/points of the shape in row orientation + const offsets = { even: [], odd: [] }; + for (const { i, j } of rowData.offsets.even) offsets.even.push({ i: j, j: i }); + for (const { i, j } of rowData.offsets.odd) offsets.odd.push({ i: j, j: i }); + offsets.even.sort(({ i: i0, j: j0 }, { i: i1, j: j1 }) => j0 - j1 || i0 - i1); + offsets.odd.sort(({ i: i0, j: j0 }, { i: i1, j: j1 }) => j0 - j1 || i0 - i1); + const points = []; + for (let i = rowData.points.length; i > 0; i -= 2) { + points.push(rowData.points[i - 1], rowData.points[i - 2]); + } + data = { + offsets, + points, + center: { x: rowData.center.y, y: rowData.center.x }, + anchor: { x: rowData.anchor.y, y: rowData.anchor.x } + }; + } + + // Small hexagon + else if (width === 0.5 && height === 0.5) { + data = { + offsets: { even: [{ i: 0, j: 0 }], odd: [{ i: 0, j: 0 }] }, + points: [0.25, 0.0, 0.5, 0.125, 0.5, 0.375, 0.25, 0.5, 0.0, 0.375, 0.0, 0.125], + center: { x: 0.25, y: 0.25 }, + anchor: { x: 0.25, y: 0.25 } + }; + } + + // Normal hexagon + else if (width === 1 && height === 1) { + data = { + offsets: { even: [{ i: 0, j: 0 }], odd: [{ i: 0, j: 0 }] }, + points: [0.5, 0.0, 1.0, 0.25, 1, 0.75, 0.5, 1.0, 0.0, 0.75, 0.0, 0.25], + center: { x: 0.5, y: 0.5 }, + anchor: { x: 0.5, y: 0.5 } + }; + } + + // Hexagonal ellipse or trapezoid + else if (shape <= CONST.TOKEN_SHAPES.TRAPEZOID_2) { + data = DHToken.#createHexagonalEllipseOrTrapezoid(width, height, shape); + } + + // Hexagonal rectangle + else if (shape <= CONST.TOKEN_SHAPES.RECTANGLE_2) { + data = DHToken.#createHexagonalRectangle(width, height, shape); + } + + // Cache the shape + if (data) { + foundry.utils.deepFreeze(data); + DHToken.#hexagonalShapes.set(key, data); + } + + return data; + } + + static #createHexagonalEllipseOrTrapezoid(width, height, shape) { + if (!Number.isInteger(width) || !Number.isInteger(height)) return null; + const points = []; + let top; + let bottom; + switch (shape) { + case CONST.TOKEN_SHAPES.ELLIPSE_1: + if (height >= 2 * width) return null; + top = Math.floor(height / 2); + bottom = Math.floor((height - 1) / 2); + break; + case CONST.TOKEN_SHAPES.ELLIPSE_2: + if (height >= 2 * width) return null; + top = Math.floor((height - 1) / 2); + bottom = Math.floor(height / 2); + break; + case CONST.TOKEN_SHAPES.TRAPEZOID_1: + if (height > width) return null; + top = height - 1; + bottom = 0; + break; + case CONST.TOKEN_SHAPES.TRAPEZOID_2: + if (height > width) return null; + top = 0; + bottom = height - 1; + break; + } + const offsets = { even: [], odd: [] }; + for (let i = bottom; i > 0; i--) { + for (let j = 0; j < width - i; j++) { + offsets.even.push({ i: bottom - i, j: j + (((bottom & 1) + i + 1) >> 1) }); + offsets.odd.push({ i: bottom - i, j: j + (((bottom & 1) + i) >> 1) }); + } + } + for (let i = 0; i <= top; i++) { + for (let j = 0; j < width - i; j++) { + offsets.even.push({ i: bottom + i, j: j + (((bottom & 1) + i + 1) >> 1) }); + offsets.odd.push({ i: bottom + i, j: j + (((bottom & 1) + i) >> 1) }); + } + } + let x = 0.5 * bottom; + let y = 0.25; + for (let k = width - bottom; k--; ) { + points.push(x, y); + x += 0.5; + y -= 0.25; + points.push(x, y); + x += 0.5; + y += 0.25; + } + points.push(x, y); + for (let k = bottom; k--; ) { + y += 0.5; + points.push(x, y); + x += 0.5; + y += 0.25; + points.push(x, y); + } + y += 0.5; + for (let k = top; k--; ) { + points.push(x, y); + x -= 0.5; + y += 0.25; + points.push(x, y); + y += 0.5; + } + for (let k = width - top; k--; ) { + points.push(x, y); + x -= 0.5; + y += 0.25; + points.push(x, y); + x -= 0.5; + y -= 0.25; + } + points.push(x, y); + for (let k = top; k--; ) { + y -= 0.5; + points.push(x, y); + x -= 0.5; + y -= 0.25; + points.push(x, y); + } + y -= 0.5; + for (let k = bottom; k--; ) { + points.push(x, y); + x += 0.5; + y -= 0.25; + points.push(x, y); + y -= 0.5; + } + return { + offsets, + points, + // We use the centroid of the polygon for ellipse and trapzoid shapes + center: foundry.utils.polygonCentroid(points), + anchor: bottom % 2 ? { x: 0.0, y: 0.5 } : { x: 0.5, y: 0.5 } + }; + } + + /** + * Create the row-based hexagonal rectangle given the type, width, and height. + * @param {number} width The width of the Token (positive) + * @param {number} height The height of the Token (positive) + * @param {TokenShapeType} shape The shape type (must be RECTANGLE_1 or RECTANGLE_2) + * @returns {TokenHexagonalShapeData|null} The hexagonal shape or null if there is no shape + * for the given combination of arguments + */ + static #createHexagonalRectangle(width, height, shape) { + if (width < 1 || !Number.isInteger(height)) return null; + if (width === 1 && height > 1) return null; + if (!Number.isInteger(width) && height === 1) return null; + + const even = shape === CONST.TOKEN_SHAPES.RECTANGLE_1 || height === 1; + const offsets = { even: [], odd: [] }; + for (let i = 0; i < height; i++) { + const j0 = even ? 0 : (i + 1) & 1; + const j1 = ((width + (i & 1) * 0.5) | 0) - (even ? i & 1 : 0); + for (let j = j0; j < j1; j++) { + offsets.even.push({ i, j: j + (i & 1) }); + offsets.odd.push({ i, j }); + } + } + let x = even ? 0.0 : 0.5; + let y = 0.25; + const points = [x, y]; + while (x + 1 <= width) { + x += 0.5; + y -= 0.25; + points.push(x, y); + x += 0.5; + y += 0.25; + points.push(x, y); + } + if (x !== width) { + y += 0.5; + points.push(x, y); + x += 0.5; + y += 0.25; + points.push(x, y); + } + while (y + 1.5 <= 0.75 * height) { + y += 0.5; + points.push(x, y); + x -= 0.5; + y += 0.25; + points.push(x, y); + y += 0.5; + points.push(x, y); + x += 0.5; + y += 0.25; + points.push(x, y); + } + if (y + 0.75 < 0.75 * height) { + y += 0.5; + points.push(x, y); + x -= 0.5; + y += 0.25; + points.push(x, y); + } + y += 0.5; + points.push(x, y); + while (x - 1 >= 0) { + x -= 0.5; + y += 0.25; + points.push(x, y); + x -= 0.5; + y -= 0.25; + points.push(x, y); + } + if (x !== 0) { + y -= 0.5; + points.push(x, y); + x -= 0.5; + y -= 0.25; + points.push(x, y); + } + while (y - 1.5 > 0) { + y -= 0.5; + points.push(x, y); + x += 0.5; + y -= 0.25; + points.push(x, y); + y -= 0.5; + points.push(x, y); + x -= 0.5; + y -= 0.25; + points.push(x, y); + } + if (y - 0.75 > 0) { + y -= 0.5; + points.push(x, y); + x += 0.5; + y -= 0.25; + points.push(x, y); + } + return { + offsets, + points, + // We use center of the rectangle (and not the centroid of the polygon) for the rectangle shapes + center: { + x: width / 2, + y: (0.75 * Math.floor(height) + 0.5 * (height % 1) + 0.25) / 2 + }, + anchor: even ? { x: 0.5, y: 0.5 } : { x: 0.0, y: 0.5 } + }; + } + //#endregion } diff --git a/templates/sheets/actors/adversary/header.hbs b/templates/sheets/actors/adversary/header.hbs index 6492fac8..7de8e45b 100644 --- a/templates/sheets/actors/adversary/header.hbs +++ b/templates/sheets/actors/adversary/header.hbs @@ -23,7 +23,11 @@ {{/if}}
- {{localize (concat "DAGGERHEART.CONFIG.TokenSize." source.system.size)}} + {{#if source.system.size}} + {{localize (concat "DAGGERHEART.CONFIG.TokenSize." source.system.size)}} + {{else}} + {{source.prototypeToken.width}}x{{source.prototypeToken.height}} + {{/if}}