Finished token implementation

This commit is contained in:
WBHarry 2025-12-20 17:14:19 +01:00
parent 3b237a223f
commit 809255ba79
7 changed files with 466 additions and 34 deletions

View file

@ -427,10 +427,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
static async resetTokenSizes() { static async resetTokenSizes() {
await this.settings.updateSource({ await this.settings.updateSource({
tokenSizes: Object.values(this.settings.schema.fields.tokenSizes.fields).reduce( tokenSizes: this.settings.schema.fields.tokenSizes.initial
(acc, field) => ({ ...acc, [field.name]: field.initial }),
{}
)
}); });
this.render(); this.render();

View file

@ -80,9 +80,9 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
if (this.metadata.usesSize) if (this.metadata.usesSize)
schema.size = new fields.StringField({ schema.size = new fields.StringField({
required: true, required: true,
nullable: false, nullable: true,
choices: CONFIG.DH.ACTOR.tokenSize, choices: CONFIG.DH.ACTOR.tokenSize,
initial: CONFIG.DH.ACTOR.tokenSize.medium.id initial: null
}); });
return schema; return schema;
} }

View file

@ -43,12 +43,12 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
tokenSizes: new fields.SchemaField({ tokenSizes: new fields.SchemaField({
tiny: new fields.NumberField({ tiny: new fields.NumberField({
integer: false, integer: false,
initial: 0.4, initial: 0.5,
label: 'DAGGERHEART.CONFIG.TokenSize.tiny' label: 'DAGGERHEART.CONFIG.TokenSize.tiny'
}), }),
small: new fields.NumberField({ small: new fields.NumberField({
integer: false, integer: false,
initial: 0.6, initial: 0.8,
label: 'DAGGERHEART.CONFIG.TokenSize.small' label: 'DAGGERHEART.CONFIG.TokenSize.small'
}), }),
medium: new fields.NumberField({ medium: new fields.NumberField({

View file

@ -73,16 +73,27 @@ export default class DhpActor extends Actor {
/**@inheritdoc */ /**@inheritdoc */
async _preCreate(data, options, user) { async _preCreate(data, options, user) {
if ((await super._preCreate(data, options, user)) === false) return false; 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 // Configure prototype token settings
const prototypeToken = {};
if (['character', 'companion', 'party'].includes(this.type)) if (['character', 'companion', 'party'].includes(this.type))
Object.assign(prototypeToken, { Object.assign(update, {
sight: { enabled: true }, prototypeToken: {
actorLink: true, sight: { enabled: true },
disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY actorLink: true,
disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
}
}); });
this.updateSource({ prototypeToken }); this.updateSource(update);
} }
_onUpdate(changes, options, userId) { _onUpdate(changes, options, userId) {
@ -765,9 +776,10 @@ export default class DhpActor extends Actor {
} }
convertDamageToThreshold(damage) { convertDamageToThreshold(damage) {
const massiveDamageEnabled=game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).massiveDamage.enabled; const massiveDamageEnabled = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules)
if (massiveDamageEnabled && damage >= (this.system.damageThresholds.severe * 2)) { .massiveDamage.enabled;
return 4; if (massiveDamageEnabled && damage >= this.system.damageThresholds.severe * 2) {
return 4;
} }
return damage >= this.system.damageThresholds.severe ? 3 : damage >= this.system.damageThresholds.major ? 2 : 1; return damage >= this.system.damageThresholds.severe ? 3 : damage >= this.system.damageThresholds.major ? 2 : 1;
} }

View file

@ -1,11 +1,16 @@
import DHToken from './token.mjs';
export default class DhScene extends Scene { export default class DhScene extends Scene {
/** A map of `TokenDocument` IDs embedded in this scene long with new dimensions from actor size-category changes */ /** A map of `TokenDocument` IDs embedded in this scene long with new dimensions from actor size-category changes */
#sizeSyncBatch = new Map(); #sizeSyncBatch = new Map();
/** Synchronize a token's dimensions with its actor's size category. */ /** 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; 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(); this.#processSyncBatch();
} }
@ -15,8 +20,22 @@ export default class DhScene extends Scene {
const entries = this.#sizeSyncBatch const entries = this.#sizeSyncBatch
.entries() .entries()
.toArray() .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.#sizeSyncBatch.clear();
this.updateEmbeddedDocuments('Token', entries, { animation: { movementSpeed: 1 } }); this.updateEmbeddedDocuments('Token', entries, { animation: { movementSpeed: 1.5 } });
}, 0); }, 0);
} }

View file

@ -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. * 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 * @param {object} attributes The tracked attributes which can be chosen from
@ -102,14 +102,23 @@ export default class DHToken extends TokenDocument {
} }
/**@inheritdoc */ /**@inheritdoc */
getSize(data = {}) { static async _preCreateOperation(documents, operation, user) {
if (this.actor?.system.metadata.usesSize) { const allowed = await super._preCreateOperation(documents, operation, user);
const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes; if (allowed === false) return false;
const size = tokenSizes[this.actor.system.size];
return super.getSize({ ...data, width: size, height: size });
}
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 */ /**@inheritdoc */
@ -118,11 +127,402 @@ export default class DHToken extends TokenDocument {
if (!this.actor?.isOwner) return; if (!this.actor?.isOwner) return;
const activeGM = game.users.activeGM; // Let the active GM take care of updates if available 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) { if (this.actor.system.metadata.usesSize && update.system?.size && activeGM && game.user.id === activeGM.id) {
const dimensions = { height: this.actor.system.size, width: this.actor.system.size }; const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes;
if (dimensions.width !== this.width || dimensions.height !== this.height) { const tokenSize = tokenSizes[update.system.size];
this.parent?.syncTokenDimensions(this, dimensions); 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<string, DeepReadonly<TokenHexagonalShapeData>>}
*/
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
} }

View file

@ -23,7 +23,11 @@
{{/if}} {{/if}}
<div class="tag" data-tooltip="DAGGERHEART.UI.Tooltip.tokenSize"> <div class="tag" data-tooltip="DAGGERHEART.UI.Tooltip.tokenSize">
<i class="fa-solid fa-circle-user"></i> <i class="fa-solid fa-circle-user"></i>
<span>{{localize (concat "DAGGERHEART.CONFIG.TokenSize." source.system.size)}}</span> {{#if source.system.size}}
<span>{{localize (concat "DAGGERHEART.CONFIG.TokenSize." source.system.size)}}</span>
{{else}}
<span>{{source.prototypeToken.width}}x{{source.prototypeToken.height}}</span>
{{/if}}
</div> </div>
</div> </div>
<line-div></line-div> <line-div></line-div>