bondage-college-mirr/BondageClub/Scripts/DynamicDraw.js
2023-02-28 23:47:14 +01:00

403 lines
17 KiB
JavaScript

"use strict";
/**
* DynamicDraw.js
* --------------
* This file contains common utilities for dynamically drawing text onto assets.
*
* An options hash that can be used to customize dynamically drawn text. No options need be specified, and some options are only applicable
* to certain drawing functions.
* @typedef DynamicDrawOptions
* @type {object}
* @property {number} [fontSize] - The target font size. Note that if space is constrained, the actual drawn font size will be reduced
* automatically to fit. Defaults to 30px.
* @property {string} [fontFamily] - The desired font family to draw text in. This can be a single font name, or a full CSS font stack
* (e.g. "'Helvetica', 'Arial', sans-serif"). Defaults to the player's chosen global font.
* @property {CanvasTextAlign} [textAlign] - The text alignment to use. Can be any valid
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/text-align text alignment}. Not applicable to the {@link DynamicDrawTextArc}
* function. Defaults to "center".
* @property {CanvasTextBaseline} [textBaseline] - The text baseline to use. Can be any valid
* {@link https://developer.mozilla.org/en-us/docs/Web/CSS/vertical-align vertical alignment}. Defaults to "middle".
* @property {string} [color] - The color that the text should be drawn in. Can be any valid CSS color string. Defaults to "#000".
* @property {string} [strokeColor] - The stroke color that should be used for the text. Can be any valid CSS color
* string. Defaults to the same value as the `color` property.
* @property {number} [strokeWidth] - The stroke width that should be used when stroking the text. Only used if a `strokeColor` is defined. Defaults to 1.
* @property {DynamicDrawTextEffect} [effect] - A dynamic text effect to apply. No effects are applied by default.
* @property {number} [width] - The maximum width of the drawn text. Not applicable to the {@link DynamicDrawTextFromTo} function, as
* constraints are defined by the endpoints. When defined for the {@link DynamicDrawTextArc} function, it defines the maximum width of the
* text perpendicular to the radius line on which the text is centered. Unlimited by default.
* @property {boolean} [contain] - Whether or not the text should be fully contained in the box defined by the from/to coordinates. Only
* applicable to the {@link DynamicDrawTextFromTo} function. Defaults to true.
* @property {number} [angle] - The angle at which the text should be drawn, relative to the center of the circle. Angles are measured
* clockwise in radians starting at the vertical 12 o'clock position. For example 0 corresponds to 12 o'clock, PI/2 corresponds to
* 3 o'clock, PI corresponds to 6 o'clock, and 3PI/2 corresponds to 9 o'clock. Only applicable to the {@link DynamicDrawTextArc} function.
* Defaults to 0.
* @property {number} [radius] - The radius in pixels of the circle whose arc the text should be drawn along. A smaller radius will result
* in a greater text curvature and vice versa. Only applicable to the {@link DynamicDrawTextArc} function. Defaults to 450px.
* @property {number} [maxAngle] - The maximum angle that the text should be drawn along. This effectively determines the maximum length of
* the arc along which the text will be drawn. Only applicable to the {@link DynamicDrawTextArc} function. Defaults to PI (a semicircle).
* @property {DynamicDrawTextDirection} [direction] - The direction the text should be drawn in along the circular arc. Only applicable to
* the {@link DynamicDrawTextArc} function. Defaults to {@link DynamicDrawTextDirection.CLOCKWISE};
* @property {DynamicDrawTextCurve} [textCurve] - The direction of the curve of the text. This determines whether the center of the text
* curves upwards ({@link DynamicDrawTextCurve.SMILEY}) or downwards ({@link DynamicDrawTextCurve.FROWNY}). Only applicable to the
* {@link DynamicDrawTextArc} function. Defaults to {@link DynamicDrawTextCurve.FROWNY}.
*
* A drawing callback, used to add drawing effects to dynamic text.
* @callback DynamicDrawTextEffectFunction
* @param {string} text - The text to draw
* @param {CanvasRenderingContext2D} ctx - The canvas rendering context
* @param {number} x - The x coordinate at which the text should be drawn
* @param {number} y - The y coordinate at which the text should be drawn
* @param {DynamicDrawOptions} [options] - Additional drawing options
*
* A definition object that wraps the drawing functions that define a dynamic text effect
* @typedef DynamicDrawTextEffectDefinition
* @type {object}
* @property {DynamicDrawTextEffectFunction} [before] - A drawing callback that is called before the dynamic text is drawn
* @property {DynamicDrawTextEffectFunction} [after] - A drawing callback that is called after the dynamic text is drawn
*
* @see {@link DynamicDrawText} - for drawing basic horizontal text.
* @see {@link DynamicDrawTextFromTo} - for drawing text in a straight line between any given two coordinates.
* @see {@link DynamicDrawTextArc} - for drawing text in a circular arc.
*/
/**
* A common regex that can be used to check whether a given string is permitted for dynamic drawing (the character limitations are primarily
* to restrict the use of control characters and unicode characters that would cause odd behavior).
* @type {RegExp}
*/
const DynamicDrawTextRegex = /^(?:\w|[ ~!$#%*+])*$/;
/**
* A regex pattern that can be attached to HTML input elements to check for validity - matches the DynamicDrawTextRegex
* @type {string}
*/
const DynamicDrawTextInputPattern = "(?:\\w|[ ~!$#%*+])*";
/**
* An array of valid printable characters that are permitted for dynamic drawing (used internally for text measurement purposes)
* @type {string[]}
*/
const DynamicDrawValidTextCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_- ~!$#%*+".split("");
/**
* A padding multiplier for text when drawn in an arc. The extra padding helps ensure that the bottoms of characters don't collide
* @type {number}
*/
const DynamicDrawTextArcPaddingRatio = 1.15;
/**
* Cache for font measurements. These are used to make text which is drawn in an arc look more natural by respecting the widths of
* characters in various fonts.
* @type {Record.<string, {
* width: number,
* weights: Record.<string, number>
* }>}
*/
const DynamicDrawFontMeasurements = {};
/**
* An enum encapsulating the directions that circular text can be drawn in (clockwise and anticlockwise)
* @enum {number}
*/
const DynamicDrawTextDirection = {
CLOCKWISE: 1,
ANTICLOCKWISE: -1,
};
/**
* An enum encapsulating the possible curve directions of circular text. Can be "SMILEY" (bottom of text on the outer arc) or "FROWNY"
* (bottom of text on the inner arc).
* @enum {number}
*/
const DynamicDrawTextCurve = {
SMILEY: -1,
FROWNY: 1,
};
/**
* An enum encapsulating the available drawing effects that can be applied to dynamic text.
* @type {{ BURN: "burn" }}
*/
const DynamicDrawTextEffect = {
BURN: "burn",
};
/**
* The default options that are used for dynamic text drawing.
* @type {DynamicDrawOptions}
*/
const DynamicDrawTextDefaultOptions = {
fontSize: 30,
fontFamily: CommonGetFontName(),
textAlign: "center",
textBaseline: "middle",
color: "#000",
strokeWidth: 1,
effect: undefined,
width: undefined,
contain: true,
angle: 0,
radius: 450,
maxAngle: Math.PI,
direction: DynamicDrawTextDirection.CLOCKWISE,
textCurve: DynamicDrawTextCurve.FROWNY,
};
/**
* Dynamic text effect definitions. The definitions define the drawing effects that can be applied to dynamically drawn text.
* @type {Record.<DynamicDrawTextEffect, DynamicDrawTextEffectDefinition>}
*/
const DynamicDrawTextEffects = {
[DynamicDrawTextEffect.BURN]: {
before(text, ctx, x, y, { width }) {
ctx.save();
ctx.fillStyle = "#000";
ctx.fillText(text, x - 1, y - 1, width);
ctx.restore();
},
},
};
/**
* Pre-loads a font family and calculates font measurements for the family. This should generally be called in an item's load function so
* that font data is loaded in preparation for dynamic font drawing. This will also be called at draw-time, but if the font is already
* pre-loaded, this function will do nothing.
* @param {string} fontFamily - the font family to load. Can be a single font name or a full CSS font stack
* (e.g. "'Helvetica', 'Arial', sans-serif")
* @returns {void} - Nothing
*/
function DynamicDrawLoadFont(fontFamily) {
// If we've already measured the font, do nothing
if (DynamicDrawFontMeasurements[fontFamily]) return;
const canvas = document.createElement("canvas");
canvas.width = 20;
canvas.height = 20;
const ctx = canvas.getContext("2d");
// Dummy text fill to force the browser to load the font (otherwise it won't get loaded until after the first time
// the text has been populated, causing the first draw to fallback)
ctx.font = `1px ${fontFamily}`;
ctx.fillText("", 0, 0);
// Measure each of the valid characters in the given font and record the maximum width
let maxWidth = 0;
const measurements = DynamicDrawValidTextCharacters.map(char => {
const width = ctx.measureText(char).width;
if (width > maxWidth) maxWidth = width;
return width;
});
// Capture the maximum character width for the font, and set up a relative map for character weights
const weightMap = DynamicDrawFontMeasurements[fontFamily] = {
width: maxWidth,
weights: {},
};
// Normalise the width of each character as a weight relative to the max width
DynamicDrawValidTextCharacters.forEach((char, i) => {
weightMap.weights[char] = measurements[i] / maxWidth;
});
}
/**
* Draws the given text to the provided canvas rendering context at the given positions. Text is drawn horizontally, respecting the
* configuration in the provided options (if any).
* @param {string} text - The text to draw
* @param {CanvasRenderingContext2D} ctx - The rendering context to draw the text to
* @param {number} x - The x coordinate at which to draw the text
* @param {number} y - The y coordinate at which to draw the text
* @param {DynamicDrawOptions} options - Additional drawing options
* @returns {void} - Nothing
*/
function DynamicDrawText(text, ctx, x, y, options) {
options = DynamicDrawParseOptions(options);
DynamicDrawTextAndEffects(text, ctx, x, y, options);
}
/**
* Draws the given text in a straight line between the two provided coordinates. If the contain option is specified, the text will be fully
* contained in the rectangle defined by the from and to positions.
* @param {string} text - The text to draw
* @param {CanvasRenderingContext2D} ctx - The rendering context to draw the text to
* @param {readonly number[]} from - The [x, y] coordinates to start drawing the text at
* @param {readonly number[]} to - The [x, y] coordinates to end drawing the text at
* @param {DynamicDrawOptions} options - Additional drawing options
* @returns {void} - Nothing
*/
function DynamicDrawTextFromTo(text, ctx, from, to, options) {
const { fontSize, contain, width } = options = DynamicDrawParseOptions(options);
// From coordinate (x0, y0)
const x0 = from[0];
const y0 = from[1];
// To coordinate (x1, y1)
const x1 = to[0];
const y1 = to[1];
// Calculate x & y deltas
const dx = x1 - x0;
const dy = y1 - y0;
// Diagonal distance
if (!width) {
options.width = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
if (contain && dx != 0) {
// If the text should be fully contained within the [x0, y0], [x1, y1] box, subtract appropriately
options.width -= 2 * Math.abs(dy / dx) * (fontSize / 2);
}
}
// Center point (cx, cy)
const cx = x0 + 0.5 * dx;
const cy = y0 + 0.5 * dy;
// Calculate the angle of the text
let angle;
if (dx === 0) {
// If dx is 0, the text is vertical
angle = dy > 0 ? Math.PI / 2 : -Math.PI / 2;
} else {
angle = Math.atan(dy / dx);
}
// If dx < 0, then we need to rotate 180 degrees to respect directionality
if (dx < 0) angle += Math.PI;
// Save the canvas state and rotate by the calculated angle about the center point
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.translate(-cx, -cy);
// Draw the text and any dynamic text effects
DynamicDrawTextAndEffects(text, ctx, cx, cy, options);
// Restore the canvas rotation
ctx.restore();
}
/**
* Draws the given text in a circular arc at the given [x, y] coordinate. The text will be drawn so that the center of the text is
* positioned on the given coordinates.
* @see {@link DynamicDrawOptions}
* @param {string} text - The text to draw
* @param {CanvasRenderingContext2D} ctx - The rendering context to draw the text to
* @param {number} x - The x coordinate at which to center the text
* @param {number} y - The y coordinate at which to center the text
* @param {DynamicDrawOptions} options - Additional drawing options. These can be used to specify the radius of the circle (determining how
* curved the text appears), as well as the direction along the circle that the text is drawn in (by default, text is drawn clockwise).
* @returns {void} - Nothing
*/
function DynamicDrawTextArc(text, ctx, x, y, options) {
let { fontFamily, angle, radius, width, maxAngle, fontSize, direction, textCurve } = options = DynamicDrawParseOptions(options);
// Load the font measurements if they haven't already been populated
DynamicDrawLoadFont(fontFamily);
// Calculate the circle's center based on the desired text position and the angle
const cx = x - radius * Math.sin(angle);
const cy = y - radius * Math.cos(angle);
// Retrieve the character weight map for the font
const weightMap = DynamicDrawFontMeasurements[fontFamily] || {
width: 1,
weights: {},
};
// Calculate the total weight of the desired text
let totalWeight = 0;
for (let i = 0; i < text.length; i++) {
totalWeight += weightMap.weights[text[i]] || 1;
}
if (width == null || width > 2 * radius + fontSize) {
width = 2 * radius + fontSize;
}
// Check whether the maximum angle should be constrained by the maximum width
const angleConstraint = 2 * Math.asin(width / (fontSize + 2 * radius));
maxAngle = Math.min(maxAngle, angleConstraint);
// Check whether the font size should be constrained by the maximum angle
const baseWidth = weightMap.width * totalWeight * DynamicDrawTextArcPaddingRatio;
const fontSizeConstraint = (2 * maxAngle * radius) / (2 * baseWidth + maxAngle);
options.fontSize = Math.min(fontSize, fontSizeConstraint);
// Based on the computed font size, calculate the actual angle that the text will occupy (may be less than the max
// angle)
const actualAngle = options.fontSize * baseWidth / radius;
// Apply drawing options
DynamicDrawApplyOptions(ctx, options);
// Prepare the canvas by translating to the intended drawing position, then translating over to the center of the
// circle. Then rotate the canvas around to the angle where the text should be draw, and rotate back again half the
// angle occupied by the text
ctx.save();
ctx.translate(x, y);
ctx.translate(x - cx, y - cy);
ctx.rotate(-angle);
ctx.rotate(-1 * direction * actualAngle / 2);
// Draw each character in turn, rotating a little before and after each character to space them out evenly
for (let n = 0; n < text.length; n++) {
const char = text[n];
const rotationAngle = direction * 0.5 * actualAngle * (weightMap.weights[char] || 1) / totalWeight;
ctx.rotate(rotationAngle);
ctx.save();
ctx.translate(0, -radius);
ctx.transform(direction, 0, 0, textCurve, 0, 0);
DynamicDrawTextAndEffects(char, ctx, 0, 0, options);
ctx.restore();
ctx.rotate(rotationAngle);
}
// Restore the canvas back to its original position and orientation
ctx.restore();
}
/**
* Internal utility function for drawing text and applying text effects.
* @param {string} text - The text to draw
* @param {CanvasRenderingContext2D} ctx - The rendering context to draw the text to
* @param {number} x - The x coordinate at which to draw the text
* @param {number} y - The y coordinate at which to draw the text
* @param {DynamicDrawOptions} options - Additional drawing options
* @returns {void} - Nothing
*/
function DynamicDrawTextAndEffects(text, ctx, x, y, options) {
DynamicDrawApplyOptions(ctx, options);
const effect = DynamicDrawTextEffects[options.effect] || {};
if (typeof effect.before === "function") effect.before(text, ctx, x, y, options);
ctx.fillText(text, x, y, options.width);
if (options.strokeColor) {
ctx.strokeText(text, x, y, options.width);
}
if (typeof effect.after === "function") effect.after(text, ctx, x, y, options);
}
/**
* Parses a dynamic drawing options object, returning default values for properties that aren't defined.
* @param {DynamicDrawOptions} [options] - The options object to parse
* @returns {DynamicDrawOptions} - A complete options object, with default values where not specified
*/
function DynamicDrawParseOptions(options) {
options = options || {};
return Object.assign({}, DynamicDrawTextDefaultOptions, options);
}
/**
* Applies a set of dynamic drawing options to a canvas rendering context. This sets the canvas up with the relevant font size, color, etc.
* ready for drawing text
* @param {CanvasRenderingContext2D} ctx - The rendering context to draw the text to
* @param {DynamicDrawOptions} options - The drawing options to apply
* @returns {void} - Nothing
*/
function DynamicDrawApplyOptions(ctx, { fontSize, fontFamily, textAlign, textBaseline, color, strokeColor, strokeWidth }) {
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.fillStyle = color;
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
}
}