/**
* LittleJS Drawing Utilities Plugin
* - Extra drawing functions for LittleJS
* - Nine slice and three slice drawing, and TileSlice to keep one as a style, like a UI skin
* @namespace DrawUtilities
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
/** Draw a scalable nine-slice UI element in screen space, drawNineSlice with screenSpace set
* - Draws with the 2D context by default, on top of what WebGL drew, like drawTextScreen
* - With no angle its pieces land on whole pixels and meet exactly, so pixel art lines up and extraSpace is not used
* @param {Vector2} pos - Screen space position
* @param {Vector2} size - Screen space size
* @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample (see drawNineSlice)
* @param {Color|number} [color=WHITE] - Color to modulate with; a number here is the borderSize of the order before 1.20, deprecated
* @param {number} [borderSize] - Rendered thickness of the border sections
* @param {Color} [additiveColor] - Additive color
* @param {number} [extraSpace] - Extra spacing adjustment
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL] - Use WebGL for rendering
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use
* @memberof DrawUtilities */
function drawNineSliceScreen(pos, size, startTile, color=WHITE, borderSize=32, additiveColor, extraSpace=2, angle=0, useWebGL=false, context)
{
if (isNumber(color)) // deprecated since 1.20, the order before it: borderSize, extraSpace and angle after startTile
{
[borderSize, extraSpace, angle] = [/** @type {number} */ (color), arguments[4] ?? 2, arguments[5] ?? 0];
color = WHITE, additiveColor = undefined;
}
drawNineSlice(pos, size, startTile, /** @type {Color} */ (color), borderSize, additiveColor, extraSpace, angle, useWebGL, true, context);
}
/** Draw a scalable nine-slice UI element in world space
* Color and additive color apply in WebGL and Canvas2D (with canvasColorTiles)
* The nine-slice samples a 3x3 block of tiles from the tilesheet, it does not
* subdivide a single tile. Pass the top-left tile of that block as startTile;
* the other 8 tiles (edges, corners, and center) are taken automatically from
* the 3x3 grid of tiles extending right and down from it. borderSize only sets
* the rendered thickness of the edges and corners, not how the texture is cut.
* @param {Vector2} pos - World space position
* @param {Vector2} size - World space size
* @param {TileInfo} startTile - Top-left tile of the 3x3 block to sample the nine-slice from
* @param {Color} [color] - Color to modulate with
* @param {number} [borderSize] - Rendered thickness of the border sections, 1 in world space and 32 pixels in
* screen space by default
* @param {Color} [additiveColor] - Additive color
* @param {number} [extraSpace] - Extra spacing adjustment, .05 in world space and 2 pixels in screen space by
* default
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
* @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use
* @memberof DrawUtilities */
function drawNineSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
// the border and spacing defaults are in the units of the space it draws in
borderSize ??= screenSpace ? 32 : 1;
extraSpace ??= screenSpace ? 2 : .05;
if (!size.x || !size.y) return; // nothing to draw, the spacing must not make a sliver of it
if (color && color.a < 1)
extraSpace = 0; // see through, the overlap that hides a seam would draw twice and show brighter
// setup nine slice tiles - startTile is the top-left of a 3x3 tile block,
// so the center tile is one tile down and right from it, stepping over
// the padding around each tile the way tile() lays out the grid
const step = startTile.size.add(vec2(startTile.padding*2));
if (screenSpace && !angle)
{
// on whole pixels, the pieces in the screen's rows and columns of the block
drawSliceSnapped(pos, size, borderSize, (col, row)=>
[startTile.offset(step.multiply(vec2(col, row))), 0], color, additiveColor, useWebGL, context);
return;
}
borderSize = min(borderSize, abs(size.x)/2, abs(size.y)/2); // a box too small for two borders splits between them
const centerTile = startTile.offset(step);
const centerSize = size.add(vec2(extraSpace-borderSize*2));
const cornerSize = vec2(borderSize);
const cornerOffset = size.scale(.5).subtract(cornerSize.scale(.5));
const flip = screenSpace ? -1 : 1;
const rotateAngle = screenSpace ? -angle : angle;
// center
drawTile(pos, centerSize, centerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context);
for (let i=4; i--;)
{
// sides
const horizontal = i%2;
const sidePos = cornerOffset.multiply(vec2(horizontal?i===1?1:-1:0, horizontal?0:i?-1:1));
const sideSize = vec2(horizontal ? borderSize : centerSize.x, horizontal ? centerSize.y : borderSize);
const sideTile = centerTile.offset(step.multiply(vec2(i===1?1:i===3?-1:0,i===0?-flip:i===2?flip:0)))
drawTile(pos.add(sidePos.rotate(rotateAngle)), sideSize, sideTile, color, angle, false, additiveColor, useWebGL, screenSpace, context);
}
for (let i=4; i--;)
{
// corners
const flipX = i>1;
const flipY = i && i<3;
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-1:1));
const cornerTile = centerTile.offset(step.multiply(vec2(flipX?-1:1,flipY?flip:-flip)));
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context);
}
}
/** Draw a scalable three-slice UI element in screen space, drawThreeSlice with screenSpace set
* - Draws with the 2D context by default, on top of what WebGL drew, like drawTextScreen
* - With no angle its pieces land on whole pixels and meet exactly, so pixel art lines up and extraSpace is not used
* @param {Vector2} pos - Screen space position
* @param {Vector2} size - Screen space size
* @param {TileInfo} startTile - First of 3 consecutive tiles: corner, side, center (see drawThreeSlice)
* @param {Color|number} [color=WHITE] - Color to modulate with; a number here is the borderSize of the order before 1.20, deprecated
* @param {number} [borderSize] - Rendered thickness of the border sections
* @param {Color} [additiveColor] - Additive color
* @param {number} [extraSpace] - Extra spacing adjustment
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL] - Use WebGL for rendering
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use
* @memberof DrawUtilities */
function drawThreeSliceScreen(pos, size, startTile, color=WHITE, borderSize=32, additiveColor, extraSpace=2, angle=0, useWebGL=false, context)
{
if (isNumber(color)) // deprecated since 1.20, the order before it: borderSize, extraSpace and angle after startTile
{
[borderSize, extraSpace, angle] = [/** @type {number} */ (color), arguments[4] ?? 2, arguments[5] ?? 0];
color = WHITE, additiveColor = undefined;
}
drawThreeSlice(pos, size, startTile, /** @type {Color} */ (color), borderSize, additiveColor, extraSpace, angle, useWebGL, true, context);
}
/** Draw a scalable three-slice UI element in world space
* Color and additive color apply in WebGL and Canvas2D (with canvasColorTiles)
* The three-slice samples 3 consecutive tiles from the tilesheet, it does not
* subdivide a single tile. Pass the first tile as startTile; the three tiles
* are used in order as corner, side, and center, then rotated and mirrored to
* build all four edges and corners. borderSize only sets the rendered thickness.
* @param {Vector2} pos - World space position
* @param {Vector2} size - World space size
* @param {TileInfo} startTile - First of 3 consecutive tiles (corner, side, center) for the three-slice
* @param {Color} [color] - Color to modulate with
* @param {number} [borderSize] - Rendered thickness of the border sections, 1 in world space and 32 pixels in
* screen space by default
* @param {Color} [additiveColor] - Additive color
* @param {number} [extraSpace] - Extra spacing adjustment, .05 in world space and 2 pixels in screen space by
* default
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
* @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use
* @memberof DrawUtilities */
function drawThreeSlice(pos, size, startTile, color, borderSize, additiveColor, extraSpace, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
// the border and spacing defaults are in the units of the space it draws in
borderSize ??= screenSpace ? 32 : 1;
extraSpace ??= screenSpace ? 2 : .05;
if (!size.x || !size.y) return; // nothing to draw, the spacing must not make a sliver of it
if (color && color.a < 1)
extraSpace = 0; // see through, the overlap that hides a seam would draw twice and show brighter
// setup three slice tiles - 3 tiles in a row starting at startTile
const cornerTile = startTile.frame(0);
const sideTile = startTile.frame(1);
const centerTile = startTile.frame(2);
if (screenSpace && !angle)
{
// on whole pixels, each corner and side the top left one's tile turned a quarter more going clockwise
const turns = [[0,0,1],[3,-1,1],[3,2,2]]; // by column in each row, -1 for the center
drawSliceSnapped(pos, size, borderSize, (col, row)=>
{
const turn = turns[row][col], corner = col !== 1 && row !== 1;
return turn < 0 ? [centerTile, 0] : [corner ? cornerTile : sideTile, turn*PI/2];
}, color, additiveColor, useWebGL, context);
return;
}
borderSize = min(borderSize, abs(size.x)/2, abs(size.y)/2); // a box too small for two borders splits between them
const centerSize = size.add(vec2(extraSpace-borderSize*2));
const cornerSize = vec2(borderSize);
const cornerOffset = size.scale(.5).subtract(cornerSize.scale(.5));
const flip = screenSpace ? -1 : 1;
const rotateAngle = screenSpace ? -angle : angle;
// center
drawTile(pos, centerSize, centerTile, color, angle, false, additiveColor, useWebGL, screenSpace, context);
for (let i=4; i--;)
{
// sides
const a = angle + i*PI/2;
const horizontal = i%2;
const sidePos = cornerOffset.multiply(vec2(horizontal?i===1?1:-1:0, horizontal?0:i?-flip:flip));
const sideSize = vec2(horizontal ? centerSize.y : centerSize.x, borderSize);
drawTile(pos.add(sidePos.rotate(rotateAngle)), sideSize, sideTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
}
for (let i=4; i--;)
{
// corners
const a = angle + i*PI/2;
const flipX = !i || i>2;
const flipY = i>1;
const cornerPos = cornerOffset.multiply(vec2(flipX?-1:1, flipY?-flip:flip));
drawTile(pos.add(cornerPos.rotate(rotateAngle)), cornerSize, cornerTile, color, a, false, additiveColor, useWebGL, screenSpace, context);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* A tile drawn as a box of any size, kept as a style to draw with, like a UI skin
* - 9 slices is a nine-slice from the 3x3 block of tiles at tileInfo, see drawNineSlice
* - 3 slices is a three-slice from the 3 tiles in a row at tileInfo, see drawThreeSlice
* - 1 slice is the whole tile stretched over the box, a plain image
* - The UI system draws a widget's background with one, see uiSystem.defaultSlice
* @memberof DrawUtilities
* @example
* const panel = new TileSlice(tile(0, 16), 9, .5); // a border of half a world unit
* panel.draw(vec2(0, 5), vec2(10, 4));
* uiSystem.defaultSlice = new TileSlice(tile(0, 16), 9, 16); // UI sizes are pixels, every widget made after this
*/
class TileSlice
{
/** Create a tile slice style
* @param {TileInfo} tileInfo - The tile, or the first of the tiles, to draw with
* @param {number} [slices] - 9 for a nine-slice, 3 for a three-slice, 1 for the whole tile
* @param {number} [borderSize] - Drawn thickness of the edges and corners, undefined for the draw's own default
* @param {number} [extraSpace] - Extra spacing adjustment of the slices, undefined for the draw's own default */
constructor(tileInfo, slices=9, borderSize, extraSpace)
{
ASSERT(tileInfo instanceof TileInfo, 'tileInfo must be a TileInfo');
ASSERT(slices === 9 || slices === 3 || slices === 1, 'slices must be 9, 3 or 1');
ASSERT(borderSize === undefined || isNumber(borderSize), 'borderSize must be a number');
/** @property {TileInfo} - The tile, or the first of the tiles, to draw with */
this.tileInfo = tileInfo;
/** @property {number} - 9 for a nine-slice, 3 for a three-slice, 1 for the whole tile */
this.slices = slices;
/** @property {number|undefined} - Drawn thickness of the edges and corners, undefined for the draw's default
* @type {number|undefined} */
this.borderSize = borderSize;
/** @property {number|undefined} - Extra spacing adjustment of the slices, undefined for the draw's default
* @type {number|undefined} */
this.extraSpace = extraSpace;
}
/** Draw it as a box in world space, or in screen space
* @param {Vector2} pos - Center position
* @param {Vector2} size - Size of the box
* @param {Color} [color] - Color to modulate with
* @param {Color} [additiveColor] - Additive color
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
* @param {boolean} [screenSpace=drawScreenSpace] - Are pos and size in screen space?
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use */
draw(pos, size, color=WHITE, additiveColor, angle=0, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
if (screenSpace) // with the screen space defaults for the border and spacing
return this.drawScreen(pos, size, color, additiveColor, angle, useWebGL, context);
if (this.slices === 9)
drawNineSlice(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, screenSpace, context);
else if (this.slices === 3)
drawThreeSlice(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, screenSpace, context);
else
drawTile(pos, size, this.tileInfo, color, angle, false, additiveColor, useWebGL, screenSpace, context);
}
/** Draw it as a box in screen space, with the 2D context by default, on top of what WebGL drew, like drawTextScreen
* @param {Vector2} pos - Screen space center position
* @param {Vector2} size - Screen space size
* @param {Color} [color] - Color to modulate with
* @param {Color} [additiveColor] - Additive color
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [useWebGL] - Use WebGL for rendering
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use */
drawScreen(pos, size, color=WHITE, additiveColor, angle=0, useWebGL=false, context)
{
if (this.slices === 9)
drawNineSliceScreen(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, context);
else if (this.slices === 3)
drawThreeSliceScreen(pos, size, this.tileInfo, color, this.borderSize, additiveColor, this.extraSpace, angle, useWebGL, context);
else
drawTile(pos, size, this.tileInfo, color, angle, false, additiveColor, useWebGL, true, context);
}
}
// draw a slice box in screen space with no angle on whole pixels: the edges and the border are rounded, and every
// piece is cut from them, so the pieces meet exactly and their texels line up where they do, with no overlap to hide
// a seam; pieceTile(col, row) gives the tile and angle for the piece in that column and row of the box, top left 0
function drawSliceSnapped(pos, size, borderSize, pieceTile, color, additiveColor, useWebGL, context)
{
// the pixels it lands on are the device's: a 2D context's own scale and offset, like the UI under nativeHeight
// centered on an odd sized canvas, or the pixel ratio; the draws land half a pixel past, see shift below
const canvas2D = !useWebGL || !glEnable || !!context;
const transform = canvas2D && (context || drawContext)?.getTransform?.();
const ratio = getCanvasPixelRatio();
const [scaleX, scaleY, offsetX, offsetY] = transform ?
[transform.a || 1, transform.d || 1, transform.e || 0, transform.f || 0] : [ratio, ratio, 0, 0];
const edges = (center, length, scale, offset)=>
{
// snapped in device order, which a mirrored context reverses, then put back in the box's own order
const border = max(1, round(borderSize * abs(scale)));
const a = round((center - length/2) * scale + offset), b = a + round(length * scale);
const start = min(a, b), end = max(a, b);
const inner = min(start + border, floor((start + end)/2)); // a box too small for two borders splits
const device = [start, inner, max(end - border, inner), end];
return (scale < 0 ? device.reverse() : device).map(edge=> (edge - offset) / scale);
};
const xs = edges(pos.x, size.x, scaleX, offsetX), ys = edges(pos.y, size.y, scaleY, offsetY);
const shift = .5; // a screen space draw lands half a pixel past its position, in Canvas2D and WebGL alike
for (let row = 3; row--;)
for (let col = 3; col--;)
{
const w = xs[col+1] - xs[col], h = ys[row+1] - ys[row];
if (w <= 0 || h <= 0) continue;
const [tileInfo, angle] = pieceTile(col, row);
const turned = round(angle / (PI/2)) % 2; // a quarter turn swaps the sides it is drawn with
const piecePos = vec2(xs[col] + w/2 - shift, ys[row] + h/2 - shift);
drawTile(piecePos, turned ? vec2(h, w) : vec2(w, h), tileInfo, color, angle, false, additiveColor, useWebGL, true, context);
}
}
/** Draw a crescent / moon-phase shape built from a polygon
* Routes through drawPoly, so it supports WebGL, screen space, color, and outlines
* - At angle 0 the lit side faces up while waxing (percent below .5) and down while waning, and the lit width
* grows evenly with the phase, not as the real moon's does
* @param {Vector2} pos - Center position
* @param {number} [size] - Diameter
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
* @param {Color} [color] - Fill color
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [invert] - Draw the unlit part of the disk instead of the lit part
* @param {number} [lineWidth] - Outline width, 0 for no outline
* @param {Color} [lineColor] - Outline color
* @param {boolean} [useWebGL=glEnable] - Use WebGL for rendering
* @param {boolean} [screenSpace=drawScreenSpace] - Use screen space coordinates
* @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context] - Canvas context to use
* @memberof DrawUtilities */
function drawCrescent(pos, size=1, percent=0, color=WHITE, angle=0, invert=false, lineWidth=0, lineColor=BLACK, useWebGL=glEnable, screenSpace=drawScreenSpace, context)
{
// build local-space points and let drawPoly apply pos/angle so screen space works
const points = getCrescentPoints(vec2(), size, percent, 0, invert);
drawPoly(points, color, lineWidth, lineColor, pos, angle, useWebGL, screenSpace, context);
}
/** Get the list of points that make up a crescent / moon-phase shape
* Returns world-space points with pos and angle baked in, ready for drawPoly or other use
* @param {Vector2} pos - Center position
* @param {number} [size] - Diameter
* @param {number} [percent] - Moon phase over a full cycle (0=new, .25=first quarter, .5=full, .75=last quarter), wraps
* @param {number} [angle] - Angle to rotate by
* @param {boolean} [invert] - The unlit part of the disk instead of the lit part
* @param {number} [sides=glCircleSides] - Number of sides for a full circle (halved per arc)
* @return {Array<Vector2>} - List of points making up the crescent
* @memberof DrawUtilities */
function getCrescentPoints(pos, size=1, percent=0, angle=0, invert=false, sides=glCircleSides)
{
ASSERT(isVector2(pos), 'pos must be a vec2');
ASSERT(isNumber(size) && isNumber(percent), 'size and percent must be numbers');
// map phase to a signed terminator curve: -1 new, 0 half, 1 full
let p = mod(percent*4, 4); // quarter phase 0..4
if (p >= 2) // second half of cycle flips orientation
angle += PI;
p = p <= 2 ? p-1 : 3-p;
if (invert) // the unlit part: the other side of the same curve
{
p = -p;
angle += PI;
}
// build the crescent: outer semicircle, then inner half-ellipse traced back
const points = [];
const segs = max(3, sides>>1);
const radius = size/2;
for (let i=0; i<=segs; i++)
{
const t = i/segs*PI;
points.push(vec2(radius*cos(t), radius*sin(t)).rotate(angle).add(pos));
}
for (let i=segs; i>=0; i--)
{
const t = i/segs*PI;
points.push(vec2(radius*cos(t), -radius*p*sin(t)).rotate(angle).add(pos));
}
return points;
}